diff --git a/.gitignore b/.gitignore index 87d6015..28d5ffd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ node_modules/ dist/ dist-webpack/ dist-auto-stable/ +dist-hashed/ dist-bridge/ dist-bridge-webpack/ coverage/ @@ -15,4 +16,5 @@ test-results/ blob-report/ .knighted-css/ .knighted-css-auto/ +.knighted-css-hashed/ packages/playwright/src/**/*.knighted-css.ts diff --git a/docs/roadmap.md b/docs/roadmap.md index 64fad49..cadf7b8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -10,6 +10,7 @@ - Remove the triple-slash references from `types.d.ts` for v2.0, replacing them with standard ESM import/export wiring. - Ensure the new pipeline preserves the current downstream behavior for 1.x users via a documented migration path. +- Add a CLI `--exclude` option to skip directories/files during type generation scans. ## Lightning CSS Dependency Strategy diff --git a/docs/type-generation.md b/docs/type-generation.md index 0da1402..ec3b933 100644 --- a/docs/type-generation.md +++ b/docs/type-generation.md @@ -27,6 +27,7 @@ Wire it into `postinstall` or your build so new selectors land automatically. - `--out-dir` – directory for the selector module manifest cache (defaults to `/.knighted-css`). - `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime. - `--auto-stable` – enable auto-stable selector generation during extraction (mirrors the loader’s auto-stable behavior). +- `--hashed` – emit proxy modules that export `selectors` backed by loader-bridge hashed class names (mutually exclusive with `--auto-stable`). - `--resolver` – path or package name exporting a `CssResolver` (default export or named `resolver`). ### Relationship to the loader @@ -53,6 +54,32 @@ stableSelectors.card // "knighted-card" knightedCss // compiled CSS string ``` +## Hashed selector proxies + +Use `--hashed` when you want `.knighted-css` proxy modules to export `selectors` backed by +CSS Modules hashing instead of stable selector strings. This keeps the module and selector +types while preserving hashed class names at runtime. + +> [!IMPORTANT] +> `--hashed` requires the bundler to route `?knighted-css` imports through +> `@knighted/css/loader-bridge`, so the proxy can read `knightedCss` and +> `knightedCssModules` from the bridge output. + +Example CLI: + +```sh +knighted-css-generate-types --root . --include src --hashed +``` + +Example usage: + +```ts +import Button, { knightedCss, selectors } from './button.knighted-css.js' + +selectors.card // hashed class name from CSS Modules +knightedCss // compiled CSS string +``` + Because the generated module lives next to the source stylesheet, TypeScript’s normal resolution logic applies—no custom `paths` entries required. Use the manifest in conjunction with runtime helpers such as `mergeStableClass` or `stableClassName` to keep hashed class names in sync. ## Rspack watch hook diff --git a/package-lock.json b/package-lock.json index 03b0934..3fde126 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11437,7 +11437,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.1.0-rc.6", + "version": "1.1.0-rc.8", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11709,7 +11709,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.1.0-rc.6", + "@knighted/css": "1.1.0-rc.8", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/README.md b/packages/css/README.md index 6a726a9..62cc476 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -121,6 +121,22 @@ When the `.knighted-css` import targets a JavaScript/TypeScript module, the gene import Button, { knightedCss, stableSelectors } from './button.knighted-css.js' ``` +Need hashed class names instead of stable selectors? Run the CLI with `--hashed` to emit proxy modules that export `selectors` backed by `knightedCssModules` from the loader-bridge: + +```sh +knighted-css-generate-types --root . --include src --hashed +``` + +```ts +import Button, { knightedCss, selectors } from './button.knighted-css.js' + +selectors.card // hashed CSS Modules class name +``` + +> [!IMPORTANT] +> `--hashed` requires wiring `@knighted/css/loader-bridge` to handle `?knighted-css` queries so +> the generated proxies can read `knightedCss` and `knightedCssModules` at build time. + Refer to [docs/type-generation.md](../../docs/type-generation.md) for CLI options and workflow tips. ### Combined + runtime selectors diff --git a/packages/css/package.json b/packages/css/package.json index 40f6bc1..c1bfe20 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.1.0-rc.7", + "version": "1.1.0-rc.8", "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/generateTypes.ts b/packages/css/src/generateTypes.ts index ce6287c..e7147c4 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -48,6 +48,7 @@ interface GenerateTypesInternalOptions { cacheDir: string stableNamespace?: string autoStable?: boolean + hashed?: boolean tsconfig?: TsconfigResolutionContext resolver?: CssResolver } @@ -65,6 +66,7 @@ export interface GenerateTypesOptions { outDir?: string stableNamespace?: string autoStable?: boolean + hashed?: boolean resolver?: CssResolver } @@ -148,6 +150,7 @@ export async function generateTypes( cacheDir, stableNamespace: options.stableNamespace, autoStable: options.autoStable, + hashed: options.hashed, tsconfig, resolver: options.resolver, } @@ -224,12 +227,14 @@ async function generateDeclarations( : undefined, resolver: options.resolver, }) - selectorMap = buildStableSelectorsLiteral({ - css, - namespace: resolvedNamespace, - resourcePath: resolvedPath, - emitWarning: message => warnings.push(message), - }).selectorMap + selectorMap = options.hashed + ? collectSelectorTokensFromCss(css) + : buildStableSelectorsLiteral({ + css, + namespace: resolvedNamespace, + resourcePath: resolvedPath, + emitWarning: message => warnings.push(message), + }).selectorMap } catch (error) { warnings.push( `Failed to extract CSS for ${relativeToRoot(resolvedPath, options.rootDir)}: ${formatErrorMessage(error)}`, @@ -261,7 +266,9 @@ async function generateDeclarations( selectorMap, previousSelectorManifest, nextSelectorManifest, + selectorSource, proxyInfo ?? undefined, + options.hashed ?? false, ) if (moduleWrite) { selectorModuleWrites += 1 @@ -501,9 +508,15 @@ function buildSelectorModulePath(resolvedPath: string): string { function formatSelectorModuleSource( selectors: Map, proxyInfo?: SelectorModuleProxyInfo, + options: { + hashed?: boolean + selectorSource?: string + resolvedPath?: string + } = {}, ): string { const header = '// Generated by @knighted/css/generate-types\n// Do not edit.' const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b)) + const isHashed = options.hashed === true const lines = entries.map( ([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`, ) @@ -513,22 +526,67 @@ function formatSelectorModuleSource( ${lines.join('\n')} } as const` : '{} as const' + const typeLines = entries.map( + ([token]) => ` readonly ${JSON.stringify(token)}: string`, + ) + const typeLiteral = + typeLines.length > 0 + ? `{ +${typeLines.join('\n')} +}` + : 'Record' const proxyLines: string[] = [] + const reexportLines: string[] = [] + const hashedSpecifier = + options.selectorSource && options.resolvedPath + ? buildProxyModuleSpecifier(options.resolvedPath, options.selectorSource) + : undefined + if (proxyInfo) { - proxyLines.push(`export * from '${proxyInfo.moduleSpecifier}'`) + reexportLines.push(`export * from '${proxyInfo.moduleSpecifier}'`) if (proxyInfo.includeDefault) { - proxyLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`) + reexportLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`) + } + } + + if (isHashed) { + const sourceSpecifier = proxyInfo?.moduleSpecifier ?? hashedSpecifier + if (sourceSpecifier) { + proxyLines.push( + `import { knightedCss as __knightedCss, knightedCssModules as __knightedCssModules } from '${sourceSpecifier}?knighted-css'`, + ) + proxyLines.push('export const knightedCss = __knightedCss') + proxyLines.push('export const knightedCssModules = __knightedCssModules') } + } else if (proxyInfo) { proxyLines.push( `export { knightedCss } from '${proxyInfo.moduleSpecifier}?knighted-css'`, ) } - const defaultExport = proxyInfo ? '' : '\nexport default stableSelectors' - const stableBlock = `export const stableSelectors = ${literal} -export type KnightedCssStableSelectors = typeof stableSelectors -export type KnightedCssStableSelectorToken = keyof typeof stableSelectors${defaultExport}` - const sections = [header, proxyLines.join('\n'), stableBlock].filter(Boolean) + const exportName = isHashed ? 'selectors' : 'stableSelectors' + const typeName = isHashed ? 'KnightedCssSelectors' : 'KnightedCssStableSelectors' + const tokenTypeName = isHashed + ? 'KnightedCssSelectorToken' + : 'KnightedCssStableSelectorToken' + const defaultExport = proxyInfo ? '' : `\nexport default ${exportName}` + + const selectorBlock = isHashed + ? `export const ${exportName} = __knightedCssModules as ${typeLiteral} + +export type ${typeName} = typeof ${exportName} +export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}` + : `export const ${exportName} = ${literal} + +export type ${typeName} = typeof ${exportName} +export type ${tokenTypeName} = keyof typeof ${exportName}${defaultExport}` + + const sections = [ + header, + proxyLines.join('\n'), + reexportLines.join('\n'), + selectorBlock, + ].filter(Boolean) return `${sections.join('\n\n')} ` } @@ -591,11 +649,17 @@ async function ensureSelectorModule( selectors: Map, previousManifest: SelectorModuleManifest, nextManifest: SelectorModuleManifest, + selectorSource: string, proxyInfo?: SelectorModuleProxyInfo, + hashed?: boolean, ): Promise { const manifestKey = buildSelectorModuleManifestKey(resolvedPath) const targetPath = buildSelectorModulePath(resolvedPath) - const source = formatSelectorModuleSource(selectors, proxyInfo) + const source = formatSelectorModuleSource(selectors, proxyInfo, { + hashed, + selectorSource, + resolvedPath, + }) const hash = hashContent(source) const previousEntry = previousManifest[manifestKey] const needsWrite = previousEntry?.hash !== hash || !(await fileExists(targetPath)) @@ -772,6 +836,23 @@ function isStyleResource(filePath: string): boolean { return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext)) } +function collectSelectorTokensFromCss(css: string): Map { + const tokens = new Set() + const pattern = /\.([A-Za-z_-][A-Za-z0-9_-]*)\b/g + let match: RegExpExecArray | null + while ((match = pattern.exec(css)) !== null) { + const token = match[1] + if (token) { + tokens.add(token) + } + } + const map = new Map() + for (const token of tokens) { + map.set(token, token) + } + return map +} + async function resolveProxyInfo( manifestKey: string, selectorSource: string, @@ -890,6 +971,7 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise outDir: parsed.outDir, stableNamespace: parsed.stableNamespace, autoStable: parsed.autoStable, + hashed: parsed.hashed, resolver, }) reportCliResult(result) @@ -906,6 +988,7 @@ export interface ParsedCliArgs { outDir?: string stableNamespace?: string autoStable?: boolean + hashed?: boolean resolver?: string help?: boolean } @@ -916,6 +999,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { let outDir: string | undefined let stableNamespace: string | undefined let autoStable = false + let hashed = false let resolver: string | undefined for (let i = 0; i < argv.length; i += 1) { @@ -927,6 +1011,10 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { autoStable = true continue } + if (arg === '--hashed') { + hashed = true + continue + } if (arg === '--root' || arg === '-r') { const value = argv[++i] if (!value) { @@ -973,7 +1061,11 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { include.push(arg) } - return { rootDir, include, outDir, stableNamespace, autoStable, resolver } + if (autoStable && hashed) { + throw new Error('Cannot combine --auto-stable with --hashed') + } + + return { rootDir, include, outDir, stableNamespace, autoStable, hashed, resolver } } function printHelp(): void { @@ -985,6 +1077,7 @@ Options: --out-dir Directory to store selector module manifest cache --stable-namespace Stable namespace prefix for generated selector maps --auto-stable Enable autoStable when extracting CSS for selectors + --hashed Emit selectors backed by loader-bridge hashed modules --resolver Path or package name exporting a CssResolver -h, --help Show this help message `) diff --git a/packages/css/src/loaderBridge.ts b/packages/css/src/loaderBridge.ts index ad153e3..e61add5 100644 --- a/packages/css/src/loaderBridge.ts +++ b/packages/css/src/loaderBridge.ts @@ -14,6 +14,7 @@ import { shouldEmitCombinedDefault, TYPES_QUERY_FLAG, } from './loaderInternals.js' +import { analyzeModule } from './lexer.js' import { collectTransitiveStyleImports } from './styleGraph.js' export interface KnightedCssBridgeLoaderOptions { @@ -125,6 +126,9 @@ export const pitch: PitchLoaderDefinitionFunction { const cssRequests = await collectBridgeStyleRequests(this, source) + const includeDefault = source + ? await resolveDefaultExportSignal(source, this.resourcePath) + : undefined callback( null, createBridgeModule({ @@ -134,6 +138,7 @@ export const pitch: PitchLoaderDefinitionFunction { + try { + const analysis = await analyzeModule(source, resourcePath) + if (analysis.defaultSignal === 'has-default') { + return true + } + if (analysis.defaultSignal === 'no-default') { + return false + } + } catch { + // fall through to regex checks + } + const hasDefaultExport = + /\bexport\s+default\b/.test(source) || + /\bexport\s*\{[^}]*\bdefault\b[^}]*\}/.test(source) + return hasDefaultExport ? true : false +} + function buildBridgeCssRequest(specifier: string): string { if (specifier.includes('knighted-css')) { return specifier @@ -389,6 +415,7 @@ interface BridgeModuleOptions { emitDefault: boolean emitCssModules: boolean cssRequests?: string[] + includeDefault?: boolean } function createBridgeModule(options: BridgeModuleOptions): string { @@ -405,11 +432,14 @@ function createBridgeModule(options: BridgeModuleOptions): string { ? `__knightedStyle${index}.knightedCssModules` : 'undefined', ) + const shouldIncludeDefault = options.includeDefault !== false const lines = [ `import * as __knightedLocals from ${localsLiteral};`, `import * as __knightedUpstream from ${upstreamLiteral};`, ...cssImports, - `const __knightedDefault =\ntypeof __knightedUpstream.default !== 'undefined'\n ? __knightedUpstream.default\n : __knightedUpstream;`, + shouldIncludeDefault + ? `const __knightedDefault =\n Object.prototype.hasOwnProperty.call(__knightedUpstream, 'default')\n ? __knightedUpstream['default']\n : __knightedUpstream;` + : 'const __knightedDefault = __knightedUpstream;', `const __knightedResolveCss = ${resolveCssText.toString()};`, `const __knightedResolveCssModules = ${resolveCssModules.toString()};`, `const __knightedUpstreamLocals =\n __knightedResolveCssModules(__knightedUpstream, __knightedUpstream);`, diff --git a/packages/css/test/__snapshots__/generateTypes.snap.json b/packages/css/test/__snapshots__/generateTypes.snap.json index 9473456..623a2d0 100644 --- a/packages/css/test/__snapshots__/generateTypes.snap.json +++ b/packages/css/test/__snapshots__/generateTypes.snap.json @@ -1,4 +1,4 @@ { "cli-generation-summary": "[log]\n[knighted-css] Selector modules updated: wrote 1, removed 0.\n[knighted-css] Manifest: /selector-modules.json\n[knighted-css] Selector modules are up to date.\n[knighted-css] Manifest: /selector-modules.json\n[warn]", - "cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root Project root directory (default: cwd)\n -i, --include Additional directories/files to scan (repeatable)\n --out-dir Directory to store selector module manifest cache\n --stable-namespace Stable namespace prefix for generated selector maps\n --auto-stable Enable autoStable when extracting CSS for selectors\n --resolver Path or package name exporting a CssResolver\n -h, --help Show this help message" + "cli-help-output": "Usage: knighted-css-generate-types [options]\n\nOptions:\n -r, --root Project root directory (default: cwd)\n -i, --include Additional directories/files to scan (repeatable)\n --out-dir Directory to store selector module manifest cache\n --stable-namespace Stable namespace prefix for generated selector maps\n --auto-stable Enable autoStable when extracting CSS for selectors\n --hashed Emit selectors backed by loader-bridge hashed modules\n --resolver Path or package name exporting a CssResolver\n -h, --help Show this help message" } diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index b40f8f1..302b55e 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -283,6 +283,39 @@ test('generateTypes emits declarations and reuses cache', async () => { } }) +test('generateTypes hashed emits selector proxies for modules', async () => { + const project = await setupFixtureProject() + try { + const outDir = path.join(project.root, '.knighted-css-test') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + hashed: true, + }) + assert.ok(result.selectorModulesWritten >= 1) + assert.equal(result.warnings.length, 0) + + const selectorModulePath = path.join( + project.root, + 'src', + 'fixture', + 'entry.knighted-css.ts', + ) + const selectorModule = await fs.readFile(selectorModulePath, 'utf8') + assert.ok( + selectorModule.includes( + "import { knightedCss as __knightedCss, knightedCssModules as __knightedCssModules } from './entry.js?knighted-css'", + ), + ) + assert.ok(selectorModule.includes('export const selectors')) + assert.ok(selectorModule.includes('export const knightedCssModules')) + assert.ok(!selectorModule.includes('stableSelectors')) + } finally { + await project.cleanup() + } +}) + test('generateTypes autoStable emits selectors for CSS Modules', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-auto-stable-')) try { @@ -890,6 +923,21 @@ test('generateTypes internals cover edge cases', async () => { }) assert.ok(populated.includes('"demo": ".knighted-demo"')) + const hashedSource = formatSelectorModuleSource( + new Map([['demo', '.knighted-demo']]), + { + moduleSpecifier: './entry.js', + includeDefault: true, + }, + { + hashed: true, + selectorSource: './entry.js', + resolvedPath: path.join(tempRoot, 'entry.js'), + }, + ) + assert.ok(hashedSource.includes('export const selectors')) + assert.ok(hashedSource.includes('export const knightedCssModules')) + const removed = await removeStaleSelectorModules( { demo: { file: path.join(tempRoot, 'missing.ts'), hash: 'missing' } }, {}, @@ -1069,6 +1117,25 @@ test('generateTypes internals support selector module helpers', async () => { assert.match(proxySource, /export \{ knightedCss \} from '\.\/demo\.js\?knighted-css'/) assert.ok(!proxySource.includes('export default stableSelectors')) + const hashedProxySource = formatSelectorModuleSource( + selectorMap, + { + moduleSpecifier: './demo.js', + includeDefault: false, + }, + { + hashed: true, + selectorSource: './demo.js', + resolvedPath: '/tmp/project/src/demo.js', + }, + ) + assert.match(hashedProxySource, /export \* from '\.\/demo\.js'/) + assert.match( + hashedProxySource, + /import \{ knightedCss as __knightedCss, knightedCssModules as __knightedCssModules \} from '\.\/demo\.js\?knighted-css'/, + ) + assert.match(hashedProxySource, /export const selectors/) + const manifestKey = buildSelectorModuleManifestKey(path.join('src', 'entry.js')) assert.ok(manifestKey.includes('entry.js')) const modulePath = buildSelectorModulePath('/tmp/project/src/entry.js') @@ -1168,14 +1235,27 @@ test('generateTypes internals support selector module helpers', async () => { assert.deepEqual(parsed.include, ['src']) assert.equal(parsed.stableNamespace, 'storybook') assert.equal(parsed.autoStable, true) + assert.equal(parsed.hashed, false) assert.equal(parsed.resolver, './resolver.mjs') + const hashedParsed = parseCliArgs([ + '--root', + '/tmp/project', + '--hashed', + 'src', + ]) as ParsedCliArgs + assert.equal(hashedParsed.rootDir, path.resolve('/tmp/project')) + assert.deepEqual(hashedParsed.include, ['src']) + assert.equal(hashedParsed.autoStable, false) + assert.equal(hashedParsed.hashed, true) + assert.throws(() => parseCliArgs(['--root']), /Missing value/) assert.throws(() => parseCliArgs(['--include']), /Missing value/) assert.throws(() => parseCliArgs(['--out-dir']), /Missing value/) assert.throws(() => parseCliArgs(['--stable-namespace']), /Missing value/) assert.throws(() => parseCliArgs(['--resolver']), /Missing value/) assert.throws(() => parseCliArgs(['--wat']), /Unknown flag/) + assert.throws(() => parseCliArgs(['--auto-stable', '--hashed']), /Cannot combine/) const helpParsed = parseCliArgs(['--help']) assert.equal(helpParsed.help, true) diff --git a/packages/playwright/hashed.html b/packages/playwright/hashed.html new file mode 100644 index 0000000..fb1389d --- /dev/null +++ b/packages/playwright/hashed.html @@ -0,0 +1,25 @@ + + + + + Hashed selectors demo + + + + +
+ + + diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 4f13f4b..8827f2e 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -4,12 +4,14 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:rspack && npm run build:auto-stable && npm run build:webpack && npm run build:ssr && npm run build:bridge:rspack && npm run build:bridge:webpack", - "types": "npm run types:base && npm run types:auto-stable", - "types:base": "knighted-css-generate-types --root . --include src --out-dir .knighted-css", + "build": "npm run build:rspack && npm run build:auto-stable && npm run build:hashed && npm run build:webpack && npm run build:ssr && npm run build:bridge:rspack && npm run build:bridge:webpack", + "types": "npm run types:base && npm run types:auto-stable && npm run types:hashed", + "types:base": "knighted-css-generate-types --root . --include src/bridge --include src/hash-imports-workspace --include src/index.ts --include src/lit-react --include src/native-attr --include src/ssr --include src/webpack-react --out-dir .knighted-css", "types:auto-stable": "knighted-css-generate-types --root . --include src/auto-stable --auto-stable --out-dir .knighted-css-auto", + "types:hashed": "knighted-css-generate-types --root . --include src/hashed --hashed --out-dir .knighted-css-hashed", "build:rspack": "rspack --config rspack.config.js", "build:auto-stable": "rspack --config rspack.auto-stable.config.js", + "build:hashed": "rspack --config rspack.hashed.config.js", "build:webpack": "webpack --config webpack.config.js", "build:ssr": "tsx scripts/render-ssr-preview.ts", "build:bridge:rspack": "rspack --config rspack.bridge.config.js", @@ -18,6 +20,7 @@ "check-types": "tsc --noEmit", "prebuild": "npm run build -w @knighted/css", "preview": "npm run build && http-server . -p 4174", + "preview:hashed": "npm run build:hashed && http-server . -p 4178 -o /hashed.html", "preview:auto-stable": "npm run build:auto-stable && http-server . -p 4175", "preview:bridge": "npm run build:bridge:rspack && http-server . -p 4176 -o /bridge.html", "preview:bridge-webpack": "npm run build:bridge:webpack && http-server . -p 4177 -o /bridge-webpack.html", @@ -28,7 +31,7 @@ "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.1.0-rc.7", + "@knighted/css": "1.1.0-rc.8", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/rspack.hashed.config.js b/packages/playwright/rspack.hashed.config.js new file mode 100644 index 0000000..6cccb2e --- /dev/null +++ b/packages/playwright/rspack.hashed.config.js @@ -0,0 +1,159 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CssExtractRspackPlugin, ProvidePlugin } from '@rspack/core' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default { + mode: 'development', + context: __dirname, + entry: './src/hashed/index.ts', + output: { + path: path.resolve(__dirname, 'dist-hashed'), + filename: 'hashed-bundle.js', + }, + resolve: { + extensions: ['.tsx', '.ts', '.js', '.css'], + extensionAlias: { + '.js': ['.js', '.ts', '.tsx'], + }, + }, + module: { + rules: [ + { + test: /\.css$/, + exclude: /\.module\.css$/, + oneOf: [ + { + resourceQuery: /knighted-css/, + type: 'javascript/auto', + use: [ + { + loader: '@knighted/css/loader-bridge', + }, + { + loader: 'css-loader', + options: { + exportType: 'string', + modules: false, + }, + }, + ], + }, + { + use: [ + { + loader: CssExtractRspackPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: false, + }, + }, + ], + }, + ], + }, + { + test: /\.module\.css$/, + oneOf: [ + { + resourceQuery: /knighted-css/, + type: 'javascript/auto', + use: [ + { + loader: '@knighted/css/loader-bridge', + }, + { + loader: 'css-loader', + options: { + exportType: 'string', + modules: { + namedExport: true, + }, + }, + }, + ], + }, + { + use: [ + { + loader: CssExtractRspackPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: { + namedExport: false, + }, + }, + }, + ], + }, + ], + }, + { + test: /\.[jt]sx?$/, + resourceQuery: /knighted-css/, + exclude: /\.css\.ts$/, + use: [ + { + loader: '@knighted/css/loader-bridge', + }, + { + loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }, + }, + ], + }, + { + test: /\.tsx?$/, + exclude: /node_modules/, + use: [ + { + loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }, + }, + ], + }, + ], + }, + plugins: [ + new CssExtractRspackPlugin({ + filename: 'hashed.css', + }), + new ProvidePlugin({ + React: 'react', + }), + ], +} diff --git a/packages/playwright/src/hashed/card.tsx b/packages/playwright/src/hashed/card.tsx new file mode 100644 index 0000000..58824dc --- /dev/null +++ b/packages/playwright/src/hashed/card.tsx @@ -0,0 +1,21 @@ +import styles from './styles.module.css' + +type HashedCardProps = { + label: string + testId: string +} + +export function HashedCard({ label, testId }: HashedCardProps) { + return ( +
+
+ {label} +

Hashed selectors

+

+ The same component renders in light and shadow DOM using hashed CSS module class + names. +

+
+
+ ) +} diff --git a/packages/playwright/src/hashed/constants.ts b/packages/playwright/src/hashed/constants.ts new file mode 100644 index 0000000..1864fc3 --- /dev/null +++ b/packages/playwright/src/hashed/constants.ts @@ -0,0 +1,4 @@ +export const HASHED_HOST_TAG = 'knighted-hashed-host' +export const HASHED_HOST_TEST_ID = 'hashed-host' +export const HASHED_LIGHT_TEST_ID = 'hashed-light' +export const HASHED_SHADOW_TEST_ID = 'hashed-shadow' diff --git a/packages/playwright/src/hashed/index.ts b/packages/playwright/src/hashed/index.ts new file mode 100644 index 0000000..0aac626 --- /dev/null +++ b/packages/playwright/src/hashed/index.ts @@ -0,0 +1,33 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot } from 'react-dom/client' + +import { HashedCard } from './card.js' +import { ensureHashedHostDefined } from './lit-host.js' +import { + HASHED_HOST_TAG, + HASHED_HOST_TEST_ID, + HASHED_LIGHT_TEST_ID, +} from './constants.js' + +export function renderHashedDemo(): HTMLElement { + const root = document.getElementById('hashed-app') ?? document.body + + const lightMount = document.createElement('section') + lightMount.setAttribute('data-section', 'hashed-light') + root.appendChild(lightMount) + + createRoot(lightMount).render( + reactJsx`<${HashedCard} label="Light DOM" testId=${HASHED_LIGHT_TEST_ID} />`, + ) + + ensureHashedHostDefined() + const host = document.createElement(HASHED_HOST_TAG) + host.setAttribute('data-testid', HASHED_HOST_TEST_ID) + root.appendChild(host) + + return root +} + +if (typeof document !== 'undefined') { + renderHashedDemo() +} diff --git a/packages/playwright/src/hashed/lit-host.ts b/packages/playwright/src/hashed/lit-host.ts new file mode 100644 index 0000000..cdec58a --- /dev/null +++ b/packages/playwright/src/hashed/lit-host.ts @@ -0,0 +1,61 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS } from 'lit' + +import { + ShadowTree, + knightedCss as shadowTreeCss, + selectors, +} from './shadow-tree.knighted-css.js' +import { HASHED_HOST_TAG } from './constants.js' + +const hostShell = css` + :host { + display: block; + padding: 1.5rem; + border-radius: 1.5rem; + background: #0b1120; + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); + } +` + +export class HashedHost extends LitElement { + static styles = [hostShell, unsafeCSS(shadowTreeCss)] + #reactRoot?: Root + + firstUpdated(): void { + this.#mountReact() + } + + disconnectedCallback(): void { + this.#reactRoot?.unmount() + super.disconnectedCallback() + } + + #mountReact(): void { + if (!this.#reactRoot) { + const outlet = this.renderRoot.querySelector( + '[data-react-root]', + ) as HTMLDivElement | null + if (!outlet) return + this.#reactRoot = createRoot(outlet) + } + this.#renderReactTree() + } + + #renderReactTree(): void { + if (!this.#reactRoot) return + this.#reactRoot.render(reactJsx`<${ShadowTree} />`) + } + + render() { + return html`
+ ` + } +} + +export function ensureHashedHostDefined(): void { + if (!customElements.get(HASHED_HOST_TAG)) { + customElements.define(HASHED_HOST_TAG, HashedHost) + } +} diff --git a/packages/playwright/src/hashed/shadow-tree.tsx b/packages/playwright/src/hashed/shadow-tree.tsx new file mode 100644 index 0000000..d49b30d --- /dev/null +++ b/packages/playwright/src/hashed/shadow-tree.tsx @@ -0,0 +1,6 @@ +import { HashedCard } from './card.js' +import { HASHED_SHADOW_TEST_ID } from './constants.js' + +export function ShadowTree() { + return +} diff --git a/packages/playwright/src/hashed/styles.module.css b/packages/playwright/src/hashed/styles.module.css new file mode 100644 index 0000000..78ea6fc --- /dev/null +++ b/packages/playwright/src/hashed/styles.module.css @@ -0,0 +1,39 @@ +.card { + display: grid; + gap: 0.75rem; + padding: 1.5rem; + border-radius: 1.25rem; + background: #0f172a; + color: #e2e8f0; + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.3); +} + +.stack { + display: grid; + gap: 0.5rem; +} + +.badge { + justify-self: start; + padding: 0.2rem 0.65rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 600; + background: #38bdf8; + color: #0f172a; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #f8fafc; +} + +.copy { + margin: 0; + color: #cbd5f5; + line-height: 1.5; +} diff --git a/packages/playwright/test/hashed.spec.ts b/packages/playwright/test/hashed.spec.ts new file mode 100644 index 0000000..c85a09b --- /dev/null +++ b/packages/playwright/test/hashed.spec.ts @@ -0,0 +1,69 @@ +import { expect, test } from '@playwright/test' + +import { + HASHED_HOST_TEST_ID, + HASHED_LIGHT_TEST_ID, + HASHED_SHADOW_TEST_ID, +} from '../src/hashed/constants.js' + +test.describe('Hashed selectors 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('/hashed.html') + }) + + test('light and shadow DOM styles match', async ({ page }) => { + const lightCard = page.getByTestId(HASHED_LIGHT_TEST_ID) + await expect(lightCard).toBeVisible() + + const lightMetrics = await lightCard.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + return { + className: el.className, + background: style.getPropertyValue('background-color').trim(), + color: style.getPropertyValue('color').trim(), + borderRadius: style.getPropertyValue('border-top-left-radius').trim(), + } + }) + + const host = page.getByTestId(HASHED_HOST_TEST_ID) + await expect(host).toBeVisible() + + const shadowHandle = await page.waitForFunction( + ({ hostId, shadowId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(`[data-testid="${shadowId}"]`) + }, + { hostId: HASHED_HOST_TEST_ID, shadowId: HASHED_SHADOW_TEST_ID }, + ) + + const shadowCard = shadowHandle.asElement() + if (!shadowCard) throw new Error('Shadow DOM card was not rendered') + + const shadowMetrics = await shadowCard.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + return { + className: el.className, + background: style.getPropertyValue('background-color').trim(), + color: style.getPropertyValue('color').trim(), + borderRadius: style.getPropertyValue('border-top-left-radius').trim(), + } + }) + + await shadowHandle.dispose() + + expect(shadowMetrics.className).not.toBe('') + expect(shadowMetrics.background).toBe(lightMetrics.background) + expect(shadowMetrics.color).toBe(lightMetrics.color) + expect(shadowMetrics.borderRadius).toBe(lightMetrics.borderRadius) + }) +})