Skip to content

Commit f8cf325

Browse files
feat: better import attribute support. (#53)
1 parent d0bda67 commit f8cf325

17 files changed

Lines changed: 567 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,13 @@
44
[![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css)
55
[![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css)
66

7-
`@knighted/css` is a zero-bundler CSS pipeline for JavaScript and TypeScript projects. Point it at an entry module and it walks the graph, compiles every CSS-like dependency (CSS, Sass/SCSS, Less, vanilla-extract), and hands back both a concatenated stylesheet string and optional `.knighted-css.*` selector manifests for type-safe loaders.
7+
`@knighted/css` is a bundler-optional CSS pipeline for JavaScript and TypeScript projects. Use it standalone or plug it into your bundler via the `?knighted-css` loader query—it walks the graph, compiles every CSS-like dependency (CSS, Sass/SCSS, Less, vanilla-extract), and hands back both a concatenated stylesheet string and optional `.knighted-css.*` selector manifests for type-safe loaders.
88

99
## What it does (at a glance)
1010

1111
- **Graph walking**: Follows `import` trees the same way Node does (tsconfig `paths`, package `exports`/`imports`, hash specifiers, etc.) using [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver).
1212
- **Multi-dialect compilation**: Runs Sass, Less, Lightning CSS, or vanilla-extract integrations on demand so every dependency ends up as plain CSS.
13+
- **Attribute-aware imports**: Honors static `with { type: "css" }` import attributes (including extensionless/aliased and static dynamic imports) so CSS gets pulled into the graph even when extensions aren’t present.
1314
- **Loader + CLI**: Ship CSS at runtime via `?knighted-css` loader queries or ahead of time via the `css()` API and the `knighted-css-generate-types` command.
1415
- **Shadow DOM + SSR ready**: Inline styles in server renders, ship them alongside web components, or keep classic DOM apps in sync—all without wiring a full bundler.
1516

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/css/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
[![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css)
55
[![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css)
66

7-
`@knighted/css` walks your JavaScript/TypeScript module graph, compiles every CSS-like dependency (plain CSS, Sass/SCSS, Less, vanilla-extract), and ships both the concatenated stylesheet string and optional `.knighted-css.*` imports that keep selectors typed. Use it when you need fully materialized styles ahead of runtime—Shadow DOM surfaces, server-rendered routes, static site builds, or any entry point that should inline CSS without spinning up a full bundler.
7+
`@knighted/css` walks your module graph, compiles every CSS-like dependency (plain CSS, Sass/SCSS, Less, vanilla-extract), and ships both the concatenated stylesheet string and optional `.knighted-css.*` imports that keep selectors typed. Use it with or without a bundler: run the `css()` API in scripts/SSR pipelines, or lean on the `?knighted-css` loader query so bundlers import compiled CSS alongside modules. Either path yields fully materialized styles for Shadow DOM surfaces, server-rendered routes, static site builds, or any entry point that should inline CSS.
88

99
## Why
1010

@@ -23,7 +23,7 @@ I needed a single source of truth for UI components that could drop into both li
2323

2424
## Features
2525

26-
- Traverses module graphs with a built-in walker to find transitive style imports (no bundler required).
26+
- Traverses module graphs with a built-in walker to find transitive style imports (bundler optional—works standalone or through bundler loaders), including static import attributes (`with { type: "css" }`) for extensionless or aliased specifiers.
2727
- Resolution parity via [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver): tsconfig `paths`, package `exports` + `imports`, and extension aliasing (e.g., `.css.js``.css.ts`) are honored without wiring up a bundler.
2828
- Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box.
2929
- Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts.

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.0.9",
3+
"version": "1.0.10",
44
"description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.",
55
"type": "module",
66
"main": "./dist/css.js",

packages/css/src/moduleGraph.ts

Lines changed: 164 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,11 @@ interface CollectOptions {
4444
graphOptions?: ModuleGraphOptions
4545
}
4646

47+
type ExtractedSpecifier = {
48+
specifier: string
49+
assertedType?: 'css'
50+
}
51+
4752
type TsconfigLike = string | Record<string, unknown>
4853

4954
interface TsconfigPathsResult {
@@ -93,7 +98,7 @@ export async function collectStyleImports(
9398
return
9499
}
95100
const specifiers = extractModuleSpecifiers(source, absolutePath)
96-
for (const specifier of specifiers) {
101+
for (const { specifier, assertedType } of specifiers) {
97102
if (!specifier || isBuiltinSpecifier(specifier)) {
98103
continue
99104
}
@@ -105,6 +110,13 @@ export async function collectStyleImports(
105110
if (!filter(normalized)) {
106111
continue
107112
}
113+
if (assertedType === 'css') {
114+
if (!seenStyles.has(normalized)) {
115+
seenStyles.add(normalized)
116+
styleOrder.push(normalized)
117+
}
118+
continue
119+
}
108120
if (isStyleExtension(normalized, normalizedStyles)) {
109121
if (!seenStyles.has(normalized)) {
110122
seenStyles.add(normalized)
@@ -202,36 +214,39 @@ async function readSourceFile(filePath: string): Promise<string | undefined> {
202214
}
203215
}
204216

205-
function extractModuleSpecifiers(sourceText: string, filePath: string): string[] {
217+
function extractModuleSpecifiers(
218+
sourceText: string,
219+
filePath: string,
220+
): ExtractedSpecifier[] {
206221
let program
207222
try {
208223
;({ program } = parseSync(filePath, sourceText, { sourceType: 'unambiguous' }))
209224
} catch {
210225
return []
211226
}
212227

213-
const specifiers: string[] = []
214-
const addSpecifier = (raw?: string | null) => {
228+
const specifiers: ExtractedSpecifier[] = []
229+
const addSpecifier = (raw?: string | null, assertedType?: 'css') => {
215230
if (!raw) {
216231
return
217232
}
218233
const normalized = normalizeSpecifier(raw)
219234
if (normalized) {
220-
specifiers.push(normalized)
235+
specifiers.push({ specifier: normalized, assertedType })
221236
}
222237
}
223238

224239
const visitor = new Visitor({
225240
ImportDeclaration(node) {
226-
addSpecifier(node.source?.value)
241+
addSpecifier(node.source?.value, getImportAssertedType(node))
227242
},
228243
ExportNamedDeclaration(node) {
229244
if (node.source) {
230-
addSpecifier(node.source.value)
245+
addSpecifier(node.source.value, getImportAssertedType(node))
231246
}
232247
},
233248
ExportAllDeclaration(node) {
234-
addSpecifier(node.source?.value)
249+
addSpecifier(node.source?.value, getImportAssertedType(node))
235250
},
236251
TSImportEqualsDeclaration(node: TSImportEqualsDeclaration) {
237252
const specifier = extractImportEqualsSpecifier(node)
@@ -242,7 +257,7 @@ function extractModuleSpecifiers(sourceText: string, filePath: string): string[]
242257
ImportExpression(node: ImportExpression) {
243258
const specifier = getStringFromExpression(node.source)
244259
if (specifier) {
245-
addSpecifier(specifier)
260+
addSpecifier(specifier, getImportExpressionAssertedType(node))
246261
}
247262
},
248263
CallExpression(node) {
@@ -280,6 +295,146 @@ function normalizeSpecifier(raw: string): string {
280295
return withoutQuery
281296
}
282297

298+
function getImportAssertedType(node: unknown): 'css' | undefined {
299+
const attributes = getImportAttributes(node)
300+
for (const attribute of attributes) {
301+
const key = getAttributeKey(attribute)
302+
const value = getAttributeValue(attribute)
303+
if (key === 'type' && value === 'css') {
304+
return 'css'
305+
}
306+
}
307+
return undefined
308+
}
309+
310+
function getImportAttributes(node: unknown): unknown[] {
311+
const attributes: unknown[] = []
312+
const candidate = node as { [key: string]: unknown }
313+
314+
const withClause = candidate?.withClause as { attributes?: unknown }
315+
if (withClause && Array.isArray(withClause.attributes)) {
316+
attributes.push(...withClause.attributes)
317+
}
318+
319+
const directAttributes = candidate?.attributes
320+
if (Array.isArray(directAttributes)) {
321+
attributes.push(...directAttributes)
322+
}
323+
324+
const assertions = candidate?.assertions
325+
if (Array.isArray(assertions)) {
326+
attributes.push(...assertions)
327+
}
328+
329+
return attributes
330+
}
331+
332+
function getAttributeKey(attribute: unknown): string | undefined {
333+
const attr = attribute as { [key: string]: unknown }
334+
const key = attr?.key as { [key: string]: unknown } | undefined
335+
if (!key) {
336+
return undefined
337+
}
338+
if (typeof (key as { name?: unknown }).name === 'string') {
339+
return (key as { name: string }).name
340+
}
341+
const value = (key as { value?: unknown }).value
342+
if (typeof value === 'string') {
343+
return value
344+
}
345+
return undefined
346+
}
347+
348+
function getAttributeValue(attribute: unknown): string | undefined {
349+
const attr = attribute as { [key: string]: unknown }
350+
const value = attr?.value as { [key: string]: unknown } | unknown
351+
if (typeof value === 'string') {
352+
return value
353+
}
354+
if (value && typeof (value as { value?: unknown }).value === 'string') {
355+
return (value as { value: string }).value
356+
}
357+
return undefined
358+
}
359+
360+
function getImportExpressionAssertedType(node: ImportExpression): 'css' | undefined {
361+
// Stage-3 import attributes proposal shape: import(spec, { with: { type: "css" } })
362+
const options = (node as { options?: Expression | null | undefined }).options
363+
if (!options) {
364+
return undefined
365+
}
366+
367+
const withObject = getStaticObjectProperty(options, 'with')
368+
if (withObject && isObjectExpression(withObject)) {
369+
const typeValue = getStaticObjectString(withObject, 'type')
370+
if (typeValue === 'css') {
371+
return 'css'
372+
}
373+
}
374+
375+
const assertObject = getStaticObjectProperty(options, 'assert')
376+
if (assertObject && isObjectExpression(assertObject)) {
377+
const typeValue = getStaticObjectString(assertObject, 'type')
378+
if (typeValue === 'css') {
379+
return 'css'
380+
}
381+
}
382+
383+
return undefined
384+
}
385+
386+
function isObjectExpression(
387+
expression: Expression,
388+
): (Expression & { type: 'ObjectExpression'; properties: unknown[] }) | undefined {
389+
return expression && expression.type === 'ObjectExpression'
390+
? (expression as Expression & { type: 'ObjectExpression'; properties: unknown[] })
391+
: undefined
392+
}
393+
394+
function getStaticObjectProperty(
395+
expression: Expression,
396+
name: string,
397+
): Expression | undefined {
398+
const objectExpression = isObjectExpression(expression)
399+
if (!objectExpression) {
400+
return undefined
401+
}
402+
for (const prop of objectExpression.properties as unknown[]) {
403+
const maybeProp = prop as { key?: unknown; value?: unknown; type?: string }
404+
if (maybeProp.type && maybeProp.type !== 'Property') {
405+
continue
406+
}
407+
const keyName = getPropertyKeyName(maybeProp.key)
408+
if (keyName === name) {
409+
const value = maybeProp.value as Expression | undefined
410+
if (value) {
411+
return value
412+
}
413+
}
414+
}
415+
return undefined
416+
}
417+
418+
function getPropertyKeyName(key: unknown): string | undefined {
419+
if (!key) return undefined
420+
const asAny = key as { name?: unknown; value?: unknown; type?: string }
421+
if (typeof asAny.name === 'string') {
422+
return asAny.name
423+
}
424+
if (typeof asAny.value === 'string') {
425+
return asAny.value
426+
}
427+
return undefined
428+
}
429+
430+
function getStaticObjectString(expression: Expression, name: string): string | undefined {
431+
const valueExpression = getStaticObjectProperty(expression, name)
432+
if (!valueExpression) {
433+
return undefined
434+
}
435+
return getStringFromExpression(valueExpression)
436+
}
437+
283438
function extractImportEqualsSpecifier(
284439
node: TSImportEqualsDeclaration,
285440
): string | undefined {

0 commit comments

Comments
 (0)