diff --git a/README.md b/README.md index 44326f3..96bdc55 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,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). -- Resolution parity via [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver): tsconfig `paths`, package `exports` conditions, and extension aliasing (e.g., `.css.js` → `.css.ts`) are honored without wiring up a bundler. +- 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. - Pluggable resolver/filter hooks for custom module resolution (e.g., Rspack/Vite/webpack aliases) or selective inclusion. @@ -172,6 +172,9 @@ export async function render(url: string) { The built-in walker already leans on [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver), so tsconfig `paths`, package `exports` conditions, and common extension aliases work out of the box. If you still need to mirror bespoke behavior (virtual modules, framework-specific loaders, etc.), plug in a custom resolver. Here’s how to use [`enhanced-resolve`](https://github.com/webpack/enhanced-resolve): +> [!TIP] +> Hash-prefixed specifiers defined in `package.json#imports` resolve automatically—no extra loader or `css()` options required. Reach for a custom resolver only when you need behavior beyond what `oxc-resolver` already mirrors. + ```ts import { ResolverFactory } from 'enhanced-resolve' import { css } from '@knighted/css' diff --git a/docs/loader.md b/docs/loader.md index 04d2cfe..da0762a 100644 --- a/docs/loader.md +++ b/docs/loader.md @@ -34,6 +34,9 @@ export default { } ``` +> [!NOTE] +> The loader shares the same auto-configured `oxc-resolver` as the standalone `css()` API, so hash-prefixed specifiers declared under `package.json#imports` (for example, `#ui/button`) resolve without additional options. + ### Combined imports Need the component exports **and** the compiled CSS from a single import? Use `?knighted-css&combined` and narrow the result with `KnightedCssCombinedModule` to keep TypeScript happy: diff --git a/package-lock.json b/package-lock.json index ae87cf1..b2fb417 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11922,7 +11922,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.14", + "version": "1.0.0-rc.15", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -12222,7 +12222,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.14", + "@knighted/css": "1.0.0-rc.15", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index 0a975dd..69bfc9a 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.14", + "version": "1.0.0-rc.15", "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/css/src/moduleGraph.ts b/packages/css/src/moduleGraph.ts index 25160f7..30c9f15 100644 --- a/packages/css/src/moduleGraph.ts +++ b/packages/css/src/moduleGraph.ts @@ -266,7 +266,10 @@ function normalizeSpecifier(raw: string): string { if (!trimmed || trimmed.startsWith('\0')) { return '' } - const queryIndex = trimmed.search(/[?#]/) + const querySearchOffset = trimmed.startsWith('#') ? 1 : 0 + const remainder = trimmed.slice(querySearchOffset) + const queryMatchIndex = remainder.search(/[?#]/) + const queryIndex = queryMatchIndex === -1 ? -1 : querySearchOffset + queryMatchIndex const withoutQuery = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex) if (!withoutQuery) { return '' @@ -394,9 +397,7 @@ function createResolverFactory( options.extensionAlias = extensionAlias } const tsconfigOption = resolveResolverTsconfig(graphOptions?.tsConfig, cwd) - if (tsconfigOption) { - options.tsconfig = tsconfigOption - } + options.tsconfig = tsconfigOption ?? 'auto' return new ResolverFactory(options) } diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index 3a807a9..646bfc2 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -205,3 +205,49 @@ import '@blocks/panel' await project.cleanup() } }) + +test('collectStyleImports keeps hash-prefixed specifiers intact', async () => { + const project = await createProject('knighted-module-graph-imports-hash-') + try { + await project.writeFile( + 'package.json', + JSON.stringify( + { + name: 'hash-imports', + type: 'module', + imports: { + '#ui/*': './src/ui/*', + }, + }, + null, + 2, + ), + ) + await project.writeFile('src/ui/button.scss', '.button { color: hotpink; }') + await project.writeFile( + 'src/ui/button.js', + `import './button.scss' +export const Button = () => null +`, + ) + await project.writeFile( + 'src/entry.ts', + `import { Button } from '#ui/button.js' +void Button +`, + ) + + const styles = await collectStyleImports(project.file('src/entry.ts'), { + cwd: project.root, + styleExtensions: ['.scss'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('src/ui/button.scss')]), + ) + } finally { + await project.cleanup() + } +}) diff --git a/packages/playwright/README.md b/packages/playwright/README.md new file mode 100644 index 0000000..4eda58a --- /dev/null +++ b/packages/playwright/README.md @@ -0,0 +1,8 @@ +# @knighted/css Playwright fixtures + +This package builds the demo surface that Playwright pokes during CI. It now renders two scenarios side by side: + +- **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. + +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). diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 6c73471..c224150 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -15,7 +15,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.14", + "@knighted/css": "1.0.0-rc.15", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/package.json b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/package.json new file mode 100644 index 0000000..f341e21 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/package.json @@ -0,0 +1,8 @@ +{ + "name": "@hash-imports/demo", + "private": true, + "type": "module", + "imports": { + "#workspace/ui/*": "./src/workspace-bridge/*" + } +} diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/constants.ts b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/constants.ts new file mode 100644 index 0000000..190b984 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/constants.ts @@ -0,0 +1,3 @@ +export const HASH_IMPORTS_SECTION_ID = 'hash-imports-workspace' +export const HASH_IMPORTS_CARD_TEST_ID = 'hash-imports-card' +export const HASH_IMPORTS_BADGE_TEST_ID = 'hash-imports-badge' diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts new file mode 100644 index 0000000..669d240 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts @@ -0,0 +1,22 @@ +import { HASH_IMPORTS_SECTION_ID } from '../../../constants.js' +import { createWorkspaceCard } from '#workspace/ui/workspace-card.js' +import { knightedCss as workspaceCardCss } from '#workspace/ui/workspace-card.js?knighted-css' + +export function renderHashImportsWorkspaceDemo(root: HTMLElement): void { + const mount = root ?? document.body + const section = document.createElement('section') + section.dataset.testid = HASH_IMPORTS_SECTION_ID + section.className = 'hash-imports-workspace-section' + section.setAttribute('aria-label', 'Hash imports workspace fixture') + + const intro = document.createElement('p') + intro.className = 'hash-imports-card__copy' + intro.textContent = + '#workspace/ui/* specifiers resolve automatically because the loader passes tsconfig: auto to oxc-resolver. The npm workspace wiring mirrors how downstream apps map UI packages via package.json#imports without custom resolver code.' + + const style = document.createElement('style') + style.textContent = workspaceCardCss + section.append(style, intro, createWorkspaceCard()) + + mount.appendChild(section) +} diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/workspace-card.ts b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/workspace-card.ts new file mode 100644 index 0000000..1b87585 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/workspace-card.ts @@ -0,0 +1 @@ +export * from '../../../../packages/workspace-ui/src/workspace-card.js' diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json new file mode 100644 index 0000000..5dca4ef --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/playwright/src/hash-imports-workspace/constants.ts b/packages/playwright/src/hash-imports-workspace/constants.ts new file mode 100644 index 0000000..190b984 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/constants.ts @@ -0,0 +1,3 @@ +export const HASH_IMPORTS_SECTION_ID = 'hash-imports-workspace' +export const HASH_IMPORTS_CARD_TEST_ID = 'hash-imports-card' +export const HASH_IMPORTS_BADGE_TEST_ID = 'hash-imports-badge' diff --git a/packages/playwright/src/hash-imports-workspace/package.json b/packages/playwright/src/hash-imports-workspace/package.json new file mode 100644 index 0000000..cae4727 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/package.json @@ -0,0 +1,9 @@ +{ + "name": "@hash-imports/workspace-root", + "private": true, + "type": "module", + "workspaces": [ + "apps/*", + "packages/*" + ] +} diff --git a/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/package.json b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/package.json new file mode 100644 index 0000000..6d24fd6 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/package.json @@ -0,0 +1,5 @@ +{ + "name": "@hash-imports/workspace-ui", + "private": true, + "type": "module" +} diff --git a/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.scss b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.scss new file mode 100644 index 0000000..7ac7f15 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.scss @@ -0,0 +1,39 @@ +.hash-imports-card { + background-image: linear-gradient(135deg, #fdf2f8 0%, #bfdbfe 60%, #d8b4fe 100%); + border: 2px solid #1d4ed8; + border-radius: 20px; + color: #0f172a; + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1.75rem; + position: relative; +} + +.hash-imports-card__badge { + align-self: flex-end; + background-color: #111827; + border-radius: 999px; + color: #fef9c3; + font-size: 0.7rem; + font-weight: 600; + letter-spacing: 0.08em; + padding: 0.25rem 0.85rem; + text-transform: uppercase; +} + +.hash-imports-card__title { + font-size: 1.1rem; + font-weight: 600; + line-height: 1.3; +} + +.hash-imports-card__description { + font-size: 0.95rem; + line-height: 1.5; + max-width: 46ch; +} + +.hash-imports-card__copy { + color: #1e1b4b; +} diff --git a/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.ts b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.ts new file mode 100644 index 0000000..106f62c --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/src/workspace-card.ts @@ -0,0 +1,41 @@ +import './workspace-card.scss' + +import { + HASH_IMPORTS_BADGE_TEST_ID, + HASH_IMPORTS_CARD_TEST_ID, +} from '../../../constants.js' + +export type WorkspaceCardCopy = { + title: string + description: string + badge: string +} + +export const workspaceCardCopy: WorkspaceCardCopy = { + title: 'Hash-prefixed imports stay zero-config', + description: + 'This card renders with styles resolved via package.json#imports. The demo lives inside an npm workspace so the loader discovers tsconfig files and # specifiers without extra configuration.', + badge: 'workspace ready', +} + +export function createWorkspaceCard(): HTMLElement { + const card = document.createElement('article') + card.className = 'hash-imports-card' + card.dataset.testid = HASH_IMPORTS_CARD_TEST_ID + + const badge = document.createElement('span') + badge.className = 'hash-imports-card__badge' + badge.dataset.testid = HASH_IMPORTS_BADGE_TEST_ID + badge.textContent = workspaceCardCopy.badge + + const title = document.createElement('h2') + title.className = 'hash-imports-card__title hash-imports-card__copy' + title.textContent = workspaceCardCopy.title + + const description = document.createElement('p') + description.className = 'hash-imports-card__description hash-imports-card__copy' + description.textContent = workspaceCardCopy.description + + card.append(badge, title, description) + return card +} diff --git a/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/tsconfig.json b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/tsconfig.json new file mode 100644 index 0000000..5dca4ef --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/packages/workspace-ui/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "composite": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["src"] +} diff --git a/packages/playwright/src/hash-imports-workspace/tsconfig.json b/packages/playwright/src/hash-imports-workspace/tsconfig.json new file mode 100644 index 0000000..cc98ad7 --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./apps/hash-import-demo/tsconfig.json" }, + { "path": "./packages/workspace-ui/tsconfig.json" } + ] +} diff --git a/packages/playwright/src/index.ts b/packages/playwright/src/index.ts index 3456dab..45301fd 100644 --- a/packages/playwright/src/index.ts +++ b/packages/playwright/src/index.ts @@ -1,8 +1,10 @@ import { renderLitReactDemo } from './lit-react/index.js' +import { renderHashImportsWorkspaceDemo } from './hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.js' function render() { const root = document.getElementById('app') ?? document.body renderLitReactDemo(root) + renderHashImportsWorkspaceDemo(root) return root } diff --git a/packages/playwright/test/hash-imports.spec.ts b/packages/playwright/test/hash-imports.spec.ts new file mode 100644 index 0000000..b78b5f6 --- /dev/null +++ b/packages/playwright/test/hash-imports.spec.ts @@ -0,0 +1,47 @@ +import { expect, test } from '@playwright/test' + +import { + HASH_IMPORTS_BADGE_TEST_ID, + HASH_IMPORTS_CARD_TEST_ID, + HASH_IMPORTS_SECTION_ID, +} from '../src/hash-imports-workspace/constants.js' + +test.describe('hash imports workspace demo', () => { + test.beforeEach(async ({ page }) => { + page.on('console', msg => { + if (msg.type() === 'error') { + console.error(`[browser:${msg.type()}] ${msg.text()}`) + } + }) + page.on('pageerror', error => { + console.error(`[pageerror] ${error.message}`) + }) + await page.goto('/') + }) + + test('applies stylesheet resolved from # imports', async ({ page }) => { + const section = page.getByTestId(HASH_IMPORTS_SECTION_ID) + await expect(section).toBeVisible() + + const card = page.getByTestId(HASH_IMPORTS_CARD_TEST_ID) + await expect(card).toBeVisible() + + const metrics = await card.evaluate(node => { + const style = getComputedStyle(node as HTMLElement) + return { + backgroundImage: style.getPropertyValue('background-image').trim(), + borderColor: style.getPropertyValue('border-color').trim(), + } + }) + + expect(metrics.backgroundImage).toContain('linear-gradient') + expect(metrics.borderColor).toBe('rgb(29, 78, 216)') + }) + + test('badge copy references workspace context', async ({ page }) => { + const badge = page.getByTestId(HASH_IMPORTS_BADGE_TEST_ID) + await expect(badge).toBeVisible() + const text = await badge.textContent() + expect(text?.toLowerCase()).toContain('workspace') + }) +})