From d71abf4d39124ecdb145e4aab54846ccb6ddbf70 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 12:10:25 -0600 Subject: [PATCH 1/7] docs: reword loader usage. --- README.md | 2 +- packages/css/README.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 4b8a81b..be0f213 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ [![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css) [![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css) -`@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. +`@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. ## What it does (at a glance) diff --git a/packages/css/README.md b/packages/css/README.md index acc212f..e9ef9ea 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -4,7 +4,7 @@ [![codecov](https://codecov.io/gh/knightedcodemonkey/css/graph/badge.svg?token=q93Qqwvq6l)](https://codecov.io/gh/knightedcodemonkey/css) [![NPM version](https://img.shields.io/npm/v/@knighted/css.svg)](https://www.npmjs.com/package/@knighted/css) -`@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. +`@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. ## Why @@ -23,7 +23,7 @@ I needed a single source of truth for UI components that could drop into both li ## Features -- Traverses module graphs with a built-in walker to find transitive style imports (no bundler required). +- Traverses module graphs with a built-in walker to find transitive style imports (bundler optional—works standalone or through bundler loaders). - 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. - Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box. - Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts. From 1c4b5b71cb0d074cdd266d030484d603c5c2c8d6 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 13:21:38 -0600 Subject: [PATCH 2/7] test: add css import attribute test. --- packages/css/test/moduleGraph.test.ts | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index bd4d214..fb1bdb5 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -115,6 +115,32 @@ export async function load() { } }) +test('collectStyleImports picks up CSS imports that include import attributes', async () => { + const project = await createProject('knighted-module-graph-attr-') + try { + await project.writeFile('styles/button.css', '.button { color: coral; }') + await project.writeFile( + 'entry.ts', + `import styles from './styles/button.css' with { type: "css" } +void styles +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/button.css')]), + ) + } finally { + await project.cleanup() + } +}) + test('collectStyleImports honors tsconfig paths loaded from a file', async () => { const project = await createProject('knighted-module-graph-tsconfig-file-') try { From 9c6921a99be3f676a71f689b0ed6d9fec5186412 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 13:52:43 -0600 Subject: [PATCH 3/7] feat: better import attribute support. --- README.md | 1 + packages/css/README.md | 2 +- packages/css/src/moduleGraph.ts | 173 ++++++++++++++++++++++++-- packages/css/test/moduleGraph.test.ts | 168 +++++++++++++++++++++++++ 4 files changed, 334 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index be0f213..1e163c3 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ - **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). - **Multi-dialect compilation**: Runs Sass, Less, Lightning CSS, or vanilla-extract integrations on demand so every dependency ends up as plain CSS. +- **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. - **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. - **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. diff --git a/packages/css/README.md b/packages/css/README.md index e9ef9ea..d8cf03b 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -23,7 +23,7 @@ I needed a single source of truth for UI components that could drop into both li ## Features -- Traverses module graphs with a built-in walker to find transitive style imports (bundler optional—works standalone or through bundler loaders). +- 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. - 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. - Compiles `*.css`, `*.scss`, `*.sass`, `*.less`, and `*.css.ts` (vanilla-extract) files out of the box. - Optional post-processing via [`lightningcss`](https://github.com/parcel-bundler/lightningcss) for minification, prefixing, media query optimizations, or specificity boosts. diff --git a/packages/css/src/moduleGraph.ts b/packages/css/src/moduleGraph.ts index 30c9f15..ab9c7b3 100644 --- a/packages/css/src/moduleGraph.ts +++ b/packages/css/src/moduleGraph.ts @@ -44,6 +44,11 @@ interface CollectOptions { graphOptions?: ModuleGraphOptions } +type ExtractedSpecifier = { + specifier: string + assertedType?: 'css' +} + type TsconfigLike = string | Record interface TsconfigPathsResult { @@ -93,7 +98,7 @@ export async function collectStyleImports( return } const specifiers = extractModuleSpecifiers(source, absolutePath) - for (const specifier of specifiers) { + for (const { specifier, assertedType } of specifiers) { if (!specifier || isBuiltinSpecifier(specifier)) { continue } @@ -105,6 +110,13 @@ export async function collectStyleImports( if (!filter(normalized)) { continue } + if (assertedType === 'css') { + if (!seenStyles.has(normalized)) { + seenStyles.add(normalized) + styleOrder.push(normalized) + } + continue + } if (isStyleExtension(normalized, normalizedStyles)) { if (!seenStyles.has(normalized)) { seenStyles.add(normalized) @@ -202,7 +214,10 @@ async function readSourceFile(filePath: string): Promise { } } -function extractModuleSpecifiers(sourceText: string, filePath: string): string[] { +function extractModuleSpecifiers( + sourceText: string, + filePath: string, +): ExtractedSpecifier[] { let program try { ;({ program } = parseSync(filePath, sourceText, { sourceType: 'unambiguous' })) @@ -210,28 +225,28 @@ function extractModuleSpecifiers(sourceText: string, filePath: string): string[] return [] } - const specifiers: string[] = [] - const addSpecifier = (raw?: string | null) => { + const specifiers: ExtractedSpecifier[] = [] + const addSpecifier = (raw?: string | null, assertedType?: 'css') => { if (!raw) { return } const normalized = normalizeSpecifier(raw) if (normalized) { - specifiers.push(normalized) + specifiers.push({ specifier: normalized, assertedType }) } } const visitor = new Visitor({ ImportDeclaration(node) { - addSpecifier(node.source?.value) + addSpecifier(node.source?.value, getImportAssertedType(node)) }, ExportNamedDeclaration(node) { if (node.source) { - addSpecifier(node.source.value) + addSpecifier(node.source.value, getImportAssertedType(node)) } }, ExportAllDeclaration(node) { - addSpecifier(node.source?.value) + addSpecifier(node.source?.value, getImportAssertedType(node)) }, TSImportEqualsDeclaration(node: TSImportEqualsDeclaration) { const specifier = extractImportEqualsSpecifier(node) @@ -242,7 +257,7 @@ function extractModuleSpecifiers(sourceText: string, filePath: string): string[] ImportExpression(node: ImportExpression) { const specifier = getStringFromExpression(node.source) if (specifier) { - addSpecifier(specifier) + addSpecifier(specifier, getImportExpressionAssertedType(node)) } }, CallExpression(node) { @@ -280,6 +295,146 @@ function normalizeSpecifier(raw: string): string { return withoutQuery } +function getImportAssertedType(node: unknown): 'css' | undefined { + const attributes = getImportAttributes(node) + for (const attribute of attributes) { + const key = getAttributeKey(attribute) + const value = getAttributeValue(attribute) + if (key === 'type' && value === 'css') { + return 'css' + } + } + return undefined +} + +function getImportAttributes(node: unknown): unknown[] { + const attributes: unknown[] = [] + const candidate = node as { [key: string]: unknown } + + const withClause = candidate?.withClause as { attributes?: unknown } + if (withClause && Array.isArray(withClause.attributes)) { + attributes.push(...withClause.attributes) + } + + const directAttributes = candidate?.attributes + if (Array.isArray(directAttributes)) { + attributes.push(...directAttributes) + } + + const assertions = candidate?.assertions + if (Array.isArray(assertions)) { + attributes.push(...assertions) + } + + return attributes +} + +function getAttributeKey(attribute: unknown): string | undefined { + const attr = attribute as { [key: string]: unknown } + const key = attr?.key as { [key: string]: unknown } | undefined + if (!key) { + return undefined + } + if (typeof (key as { name?: unknown }).name === 'string') { + return (key as { name: string }).name + } + const value = (key as { value?: unknown }).value + if (typeof value === 'string') { + return value + } + return undefined +} + +function getAttributeValue(attribute: unknown): string | undefined { + const attr = attribute as { [key: string]: unknown } + const value = attr?.value as { [key: string]: unknown } | unknown + if (typeof value === 'string') { + return value + } + if (value && typeof (value as { value?: unknown }).value === 'string') { + return (value as { value: string }).value + } + return undefined +} + +function getImportExpressionAssertedType(node: ImportExpression): 'css' | undefined { + // Stage-3 import attributes proposal shape: import(spec, { with: { type: "css" } }) + const options = (node as { options?: Expression | null | undefined }).options + if (!options) { + return undefined + } + + const withObject = getStaticObjectProperty(options, 'with') + if (withObject && isObjectExpression(withObject)) { + const typeValue = getStaticObjectString(withObject, 'type') + if (typeValue === 'css') { + return 'css' + } + } + + const assertObject = getStaticObjectProperty(options, 'assert') + if (assertObject && isObjectExpression(assertObject)) { + const typeValue = getStaticObjectString(assertObject, 'type') + if (typeValue === 'css') { + return 'css' + } + } + + return undefined +} + +function isObjectExpression( + expression: Expression, +): (Expression & { type: 'ObjectExpression'; properties: unknown[] }) | undefined { + return expression && expression.type === 'ObjectExpression' + ? (expression as Expression & { type: 'ObjectExpression'; properties: unknown[] }) + : undefined +} + +function getStaticObjectProperty( + expression: Expression, + name: string, +): Expression | undefined { + const objectExpression = isObjectExpression(expression) + if (!objectExpression) { + return undefined + } + for (const prop of objectExpression.properties as unknown[]) { + const maybeProp = prop as { key?: unknown; value?: unknown; type?: string } + if (maybeProp.type && maybeProp.type !== 'Property') { + continue + } + const keyName = getPropertyKeyName(maybeProp.key) + if (keyName === name) { + const value = maybeProp.value as Expression | undefined + if (value) { + return value + } + } + } + return undefined +} + +function getPropertyKeyName(key: unknown): string | undefined { + if (!key) return undefined + const asAny = key as { name?: unknown; value?: unknown; type?: string } + if (typeof asAny.name === 'string') { + return asAny.name + } + if (typeof asAny.value === 'string') { + return asAny.value + } + return undefined +} + +function getStaticObjectString(expression: Expression, name: string): string | undefined { + const valueExpression = getStaticObjectProperty(expression, name) + if (!valueExpression) { + return undefined + } + return getStringFromExpression(valueExpression) +} + function extractImportEqualsSpecifier( node: TSImportEqualsDeclaration, ): string | undefined { diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index fb1bdb5..13805d5 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -141,6 +141,174 @@ void styles } }) +test('collectStyleImports treats extensionless imports with attributes as styles', async () => { + const project = await createProject('knighted-module-graph-attr-extensionless-') + try { + await project.writeFile('styles/panel.css', '.panel { color: teal; }') + await project.writeFile( + 'entry.ts', + `import panel from './styles/panel' with { type: "css" } +void panel +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/panel.css')]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports honors attributes with path-mapped specifiers', async () => { + const project = await createProject('knighted-module-graph-attr-paths-') + try { + await project.writeFile('styles/theme.css', '.theme { color: sienna; }') + await project.writeFile( + 'entry.ts', + `import theme from '@theme/panel' with { type: "css" } +void theme +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: filePath => filePath.startsWith(project.root), + graphOptions: { + tsConfig: { + compilerOptions: { + baseUrl: '.', + paths: { + '@theme/panel': ['styles/theme.css'], + }, + }, + }, + }, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/theme.css')]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports honors attributes on re-exports', async () => { + const project = await createProject('knighted-module-graph-attr-reexport-') + try { + await project.writeFile('styles/reexport.css', '.reexport { color: khaki; }') + await project.writeFile( + 'entry.ts', + `export * from './styles/reexport.css' with { type: "css" } +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/reexport.css')]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports supports legacy assert syntax for css type', async () => { + const project = await createProject('knighted-module-graph-attr-assert-') + try { + await project.writeFile('styles/assert.css', '.assert { color: plum; }') + await project.writeFile( + 'entry.ts', + `import styles from './styles/assert.css' assert { type: "css" } +void styles +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/assert.css')]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports supports dynamic import attributes with static specifiers', async () => { + const project = await createProject('knighted-module-graph-attr-dynamic-') + try { + await project.writeFile('styles/dynamic.css', '.dynamic { color: orchid; }') + await project.writeFile( + 'entry.ts', + `export async function load() { + return import('./styles/dynamic', { with: { type: "css" } }) +} +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/dynamic.css')]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports ignores dynamic import attributes with non-static specifiers', async () => { + const project = await createProject('knighted-module-graph-attr-dynamic-negative-') + try { + await project.writeFile('styles/skip.css', '.skip { color: gray; }') + await project.writeFile( + 'entry.ts', + [ + "const name = 'skip'", + 'async function load() {', + ' return import(`./styles/${name}.css`, { with: { type: "css" } })', + '}', + 'void load', + '', + ].join('\n'), + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual(styles, []) + } finally { + await project.cleanup() + } +}) + test('collectStyleImports honors tsconfig paths loaded from a file', async () => { const project = await createProject('knighted-module-graph-tsconfig-file-') try { From 10a69f36b5591bb70b255c5a09dbdd665e909d3b Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 14:15:03 -0600 Subject: [PATCH 4/7] test: playwright import attrs. --- packages/playwright/rspack.config.js | 2 +- .../attr-import-card/attr-import-card.css | 21 +++++++++++++++++++ .../attr-import-card/attr-import-card.tsx | 12 +++++++++++ .../cards/attr-import-card/attr-import.css | 21 +++++++++++++++++++ .../playwright/src/lit-react/showcase.tsx | 9 ++++++++ packages/playwright/test/lit-react.spec.ts | 1 + 6 files changed, 65 insertions(+), 1 deletion(-) create mode 100644 packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css create mode 100644 packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.tsx create mode 100644 packages/playwright/src/lit-react/cards/attr-import-card/attr-import.css diff --git a/packages/playwright/rspack.config.js b/packages/playwright/rspack.config.js index b8d31b2..1fc3b75 100644 --- a/packages/playwright/rspack.config.js +++ b/packages/playwright/rspack.config.js @@ -18,7 +18,7 @@ export default { }, }, resolve: { - extensions: ['.tsx', '.ts', '.js'], + extensions: ['.tsx', '.ts', '.js', '.css'], extensionAlias: { '.js': ['.js', '.ts', '.tsx'], }, diff --git a/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css new file mode 100644 index 0000000..db8dea6 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css @@ -0,0 +1,21 @@ +.pw-attr-import { + color: #7c3aed; + background: linear-gradient(90deg, #ede9fe 0%, #c4b5fd 50%, #a78bfa 100%); + padding: 0.4rem 0.75rem; + border-radius: 0.65rem; + border: 1px solid #6d28d9; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.pw-attr-import::before { + content: 'attr'; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #312e81; + background: #ede9fe; + padding: 0.1rem 0.35rem; + border-radius: 999px; +} diff --git a/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.tsx b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.tsx new file mode 100644 index 0000000..c4c4ac6 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.tsx @@ -0,0 +1,12 @@ +// Extensionless to exercise the exact attribute-only path. +import './attr-import' with { type: 'css' } + +export const ATTR_IMPORT_TEST_ID = 'dialect-attr-import' + +export function AttrImportCard() { + return ( + + Import attribute CSS + + ) +} diff --git a/packages/playwright/src/lit-react/cards/attr-import-card/attr-import.css b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import.css new file mode 100644 index 0000000..db8dea6 --- /dev/null +++ b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import.css @@ -0,0 +1,21 @@ +.pw-attr-import { + color: #7c3aed; + background: linear-gradient(90deg, #ede9fe 0%, #c4b5fd 50%, #a78bfa 100%); + padding: 0.4rem 0.75rem; + border-radius: 0.65rem; + border: 1px solid #6d28d9; + display: inline-flex; + align-items: center; + gap: 0.4rem; +} + +.pw-attr-import::before { + content: 'attr'; + font-size: 0.65rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: #312e81; + background: #ede9fe; + padding: 0.1rem 0.35rem; + border-radius: 999px; +} diff --git a/packages/playwright/src/lit-react/showcase.tsx b/packages/playwright/src/lit-react/showcase.tsx index 3a2fd63..0df9d7b 100644 --- a/packages/playwright/src/lit-react/showcase.tsx +++ b/packages/playwright/src/lit-react/showcase.tsx @@ -29,6 +29,10 @@ import { NESTED_COMBINED_TEST_ID, nestedCombinedCardCss, } from './cards/nested-combined-card/nested-combined-card.js' +import { + AttrImportCard, + ATTR_IMPORT_TEST_ID, +} from './cards/attr-import-card/attr-import-card.js' import { CombinedTypesCard, COMBINED_TYPES_TEST_ID, @@ -117,6 +121,11 @@ const hostManagedDialects: HostManagedDialect[] = [ hint: 'Lit host injects the Less-gradient card', Component: LessCard, }, + { + id: ATTR_IMPORT_TEST_ID, + hint: 'Import attribute (`with { type: "css" }`) styles this card', + Component: AttrImportCard, + }, ] type ShowcaseProps = { diff --git a/packages/playwright/test/lit-react.spec.ts b/packages/playwright/test/lit-react.spec.ts index 6931fa1..8351514 100644 --- a/packages/playwright/test/lit-react.spec.ts +++ b/packages/playwright/test/lit-react.spec.ts @@ -16,6 +16,7 @@ const dialectCases = [ { id: 'dialect-combined-types', property: 'border-color' }, { id: 'dialect-fallback-oxc', property: 'background-image' }, { id: 'dialect-named-only', property: 'background-image' }, + { id: 'dialect-attr-import', property: 'color' }, ] test.describe('Lit + React wrapper demo', () => { From bb51e4284e0013a3a72a761b8f127123f1f77f3a Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 14:44:20 -0600 Subject: [PATCH 5/7] test: exercise native import css attrs. --- packages/playwright/README.md | 18 ++++++++--- .../playwright/src/native-attr/index.html | 15 +++++++++ packages/playwright/src/native-attr/main.js | 18 +++++++++++ .../src/native-attr/native-attr.css | 21 +++++++++++++ packages/playwright/test/native-attr.spec.ts | 31 +++++++++++++++++++ 5 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 packages/playwright/src/native-attr/index.html create mode 100644 packages/playwright/src/native-attr/main.js create mode 100644 packages/playwright/src/native-attr/native-attr.css create mode 100644 packages/playwright/test/native-attr.spec.ts diff --git a/packages/playwright/README.md b/packages/playwright/README.md index 4eda58a..b97b4c7 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -1,8 +1,18 @@ # @knighted/css Playwright fixtures -This package builds the demo surface that Playwright pokes during CI. It now renders two scenarios side by side: +This package builds the demo surface Playwright hits in CI. It now covers three scenarios: -- **Lit + React wrapper**: the existing showcase that exercises vanilla CSS, Sass/Less, vanilla-extract, and the combined loader queries. -- **Hash-imports workspace demo**: a minimal npm workspace under `src/hash-imports-workspace/` where `apps/hash-import-demo` uses `package.json#imports` (hash-prefixed specifiers) to resolve UI modules provided by a sibling workspace package. The fixture proves that `@knighted/css/loader` and the standalone `css()` API honor `#workspace/*` specifiers with zero extra configuration. +- Lit + React wrapper (bundled via Rspack/Webpack): exercises vanilla CSS, Sass/Less, vanilla-extract, combined loader queries, and the attribute-import card that uses `with { type: "css" }` in a bundled flow. +- Hash-imports workspace demo: minimal npm workspace under `src/hash-imports-workspace/` proving `@knighted/css/loader` and `css()` respect hash-prefixed `package.json#imports` (`#workspace/*`). +- Native CSS import attributes (no bundler): plain ESM page at `/src/native-attr/index.html` that imports `./native-attr.css` with `{ type: 'css' }` and applies the stylesheet at runtime. -Run `npm run test -- --project=chromium hash-imports.spec.ts` from this directory to rebuild the preview bundle and execute only the hash-imports checks. The default `npm test` target still runs the full matrix (chromium on CI plus the webpack + SSR builds). +## How to run + +- Full matrix (default): `npm test` (runs chromium plus webpack + SSR builds). +- Hash-imports only: `npm run test -- --project=chromium hash-imports.spec.ts`. +- Local preview server: `npm run preview -w @knighted/css-playwright-fixture` (serves at http://localhost:4174 after building Rspack/webpack/SSR outputs). + +## Exercising CSS import attributes + +- Bundled path (Lit/React attribute card): after `npm run preview -w @knighted/css-playwright-fixture`, open http://localhost:4174/ and locate the card with test id `dialect-attr-import` (rendered by `lit-react.spec.ts`). +- Native path (no bundler): open http://localhost:4174/src/native-attr/index.html. Chrome 123+ supports CSS module scripts; the page adopts the imported stylesheet so you should see the chip styling without the "Waiting for styles…" placeholder. diff --git a/packages/playwright/src/native-attr/index.html b/packages/playwright/src/native-attr/index.html new file mode 100644 index 0000000..e886411 --- /dev/null +++ b/packages/playwright/src/native-attr/index.html @@ -0,0 +1,15 @@ + + + + + Native Import Attribute Demo + + +
+
+ Waiting for styles… +
+
+ + + diff --git a/packages/playwright/src/native-attr/main.js b/packages/playwright/src/native-attr/main.js new file mode 100644 index 0000000..5cff1e0 --- /dev/null +++ b/packages/playwright/src/native-attr/main.js @@ -0,0 +1,18 @@ +import sheet from './native-attr.css' with { type: 'css' } + +// CSS module imports do not auto-apply; wire them into the page. +if (sheet && 'adoptedStyleSheets' in document) { + document.adoptedStyleSheets = [...document.adoptedStyleSheets, sheet] +} else if (sheet?.cssRules) { + const style = document.createElement('style') + style.textContent = Array.from(sheet.cssRules) + .map(rule => rule.cssText) + .join('\n') + document.head.appendChild(style) +} + +const target = document.getElementById('native-attr-target') +if (target) { + target.textContent = '' + target.classList.add('native-attr-chip') +} diff --git a/packages/playwright/src/native-attr/native-attr.css b/packages/playwright/src/native-attr/native-attr.css new file mode 100644 index 0000000..249eb43 --- /dev/null +++ b/packages/playwright/src/native-attr/native-attr.css @@ -0,0 +1,21 @@ +.native-attr-chip { + display: inline-flex; + align-items: center; + gap: 0.35rem; + color: #0f172a; + background: linear-gradient(135deg, #dbeafe 0%, #c7d2fe 50%, #ede9fe 100%); + padding: 0.35rem 0.75rem; + border-radius: 999px; + border: 1px solid #6366f1; +} + +.native-attr-chip::before { + content: 'native attr'; + text-transform: uppercase; + letter-spacing: 0.04em; + font-size: 0.65rem; + color: #312e81; + background: #eef2ff; + padding: 0.1rem 0.4rem; + border-radius: 999px; +} diff --git a/packages/playwright/test/native-attr.spec.ts b/packages/playwright/test/native-attr.spec.ts new file mode 100644 index 0000000..7aaf0b4 --- /dev/null +++ b/packages/playwright/test/native-attr.spec.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test' + +// Only Chromium supports CSS import attributes today; skip WebKit/Firefox. +// The bundled path is exercised separately via the Lit/React attribute card in lit-react.spec.ts. +test.skip( + ({ browserName }) => browserName !== 'chromium', + 'CSS import attributes not supported', +) + +test.describe('Native import attributes (no bundler)', () => { + test('applies styles via with { type: "css" }', async ({ page }) => { + await page.goto('/src/native-attr/index.html') + + const target = page.getByTestId('native-attr-shell').locator('#native-attr-target') + await expect(target).toBeVisible() + + const styles = await target.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + return { + color: style.getPropertyValue('color').trim(), + background: style.getPropertyValue('background-image').trim(), + border: style.getPropertyValue('border-top-color').trim(), + } + }) + + expect(styles.color).toBe('rgb(15, 23, 42)') + expect(styles.background).toContain('linear-gradient') + expect(styles.border).toBe('rgb(99, 102, 241)') + }) +}) From 4f0b49efff74044ef7deff37be631bc52c8f67e6 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 14:46:15 -0600 Subject: [PATCH 6/7] chore: bump version. --- package-lock.json | 4 ++-- packages/css/package.json | 2 +- packages/playwright/package.json | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2abb500..43a1389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11337,7 +11337,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.9", + "version": "1.0.10", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11372,7 +11372,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.9", + "@knighted/css": "1.0.10", "@knighted/jsx": "^1.6.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index 404b56a..f1ea37b 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.9", + "version": "1.0.10", "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.", "type": "module", "main": "./dist/css.js", diff --git a/packages/playwright/package.json b/packages/playwright/package.json index fe1dd24..d7380e9 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -16,7 +16,7 @@ "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.0.9", + "@knighted/css": "1.0.10", "@knighted/jsx": "^1.6.1", "lit": "^3.2.1", "react": "^19.0.0", From c6fdbc11e13049052b467e29d9b06959bdd7fa15 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 10 Jan 2026 15:00:48 -0600 Subject: [PATCH 7/7] chore: bump test coverage remove unused file. --- packages/css/test/moduleGraph.test.ts | 58 +++++++++++++++++++ .../attr-import-card/attr-import-card.css | 21 ------- 2 files changed, 58 insertions(+), 21 deletions(-) delete mode 100644 packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index 13805d5..3f58ab9 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -228,6 +228,34 @@ test('collectStyleImports honors attributes on re-exports', async () => { } }) +test('collectStyleImports honors asserts on re-exports', async () => { + const project = await createProject('knighted-module-graph-attr-reexport-assert-') + try { + await project.writeFile( + 'styles/reexport-assert.css', + '.reexport-assert { color: indigo; }', + ) + await project.writeFile( + 'entry.ts', + `export * from './styles/reexport-assert.css' assert { type: "css" } +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/reexport-assert.css')]), + ) + } finally { + await project.cleanup() + } +}) + test('collectStyleImports supports legacy assert syntax for css type', async () => { const project = await createProject('knighted-module-graph-attr-assert-') try { @@ -281,6 +309,36 @@ test('collectStyleImports supports dynamic import attributes with static specifi } }) +test('collectStyleImports supports dynamic import assert with static specifiers', async () => { + const project = await createProject('knighted-module-graph-attr-dynamic-assert-') + try { + await project.writeFile( + 'styles/assert-dynamic.css', + '.assert-dynamic { color: olive; }', + ) + await project.writeFile( + 'entry.ts', + `export async function load() { + return import('./styles/assert-dynamic', { assert: { type: "css" } }) +} +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/assert-dynamic.css')]), + ) + } finally { + await project.cleanup() + } +}) + test('collectStyleImports ignores dynamic import attributes with non-static specifiers', async () => { const project = await createProject('knighted-module-graph-attr-dynamic-negative-') try { diff --git a/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css b/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css deleted file mode 100644 index db8dea6..0000000 --- a/packages/playwright/src/lit-react/cards/attr-import-card/attr-import-card.css +++ /dev/null @@ -1,21 +0,0 @@ -.pw-attr-import { - color: #7c3aed; - background: linear-gradient(90deg, #ede9fe 0%, #c4b5fd 50%, #a78bfa 100%); - padding: 0.4rem 0.75rem; - border-radius: 0.65rem; - border: 1px solid #6d28d9; - display: inline-flex; - align-items: center; - gap: 0.4rem; -} - -.pw-attr-import::before { - content: 'attr'; - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.05em; - color: #312e81; - background: #ede9fe; - padding: 0.1rem 0.35rem; - border-radius: 999px; -}