diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index d800b05..86052ec 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -21,8 +21,6 @@ jobs: node-version: '24.11.1' - name: Install Dependencies run: npm ci - - name: Setup JSX WASM binding - run: npm run setup:jsx-wasm - name: Install Playwright Browsers run: npx playwright install --with-deps chromium webkit - name: Run Playwright diff --git a/.gitignore b/.gitignore index 03f9f2a..825b09e 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ node_modules/ dist/ dist-webpack/ +dist-auto-stable/ coverage/ .c8/ .duel-cache/ @@ -11,3 +12,4 @@ playwright-report/ test-results/ blob-report/ .knighted-css/ +.knighted-css-auto/ diff --git a/docs/loader.md b/docs/loader.md index ff8b5e6..a302090 100644 --- a/docs/loader.md +++ b/docs/loader.md @@ -40,6 +40,23 @@ export default { > [!TIP] > Sass-only aliases such as `pkg:#button` never hit Node resolution. Add a small shim resolver (see [docs/sass-import-aliases.md](./sass-import-aliases.md)) when you need to rewrite those specifiers before the loader runs. +### Deterministic selectors (`autoStable`) + +Pass `autoStable` to duplicate every matching class selector with a deterministic namespace (default `knighted-`). This runs without PostCSS and works for both plain CSS and CSS Modules: + +```js +{ + loader: '@knighted/css/loader', + options: { + autoStable: true, // or { namespace: 'myapp', include: /button|card/, exclude: /legacy/ } + }, +} +``` + +- Plain CSS: `.foo {}` becomes `.foo, .knighted-foo {}`. +- CSS Modules: exports and generated class strings include both the hashed class and the stable class so you can reference either at runtime. +- `autoStable` forces a LightningCSS pass; use `include`/`exclude` to scope which class tokens are duplicated. + ### 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 43a1389..64f67cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -368,7 +368,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.7.1.tgz", "integrity": "sha512-o1uhUASyo921r2XtHYOHy7gdkGLge8ghBEQHMWmyJFoXlpU58kIrhhN3w26lpQb6dspetweapMn2CSNwQ8I4wg==", - "devOptional": true, "license": "MIT", "dependencies": { "@emnapi/wasi-threads": "1.1.0", @@ -379,7 +378,6 @@ "version": "1.7.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz", "integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==", - "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -389,7 +387,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz", "integrity": "sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ==", - "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -1184,11 +1181,12 @@ } }, "node_modules/@knighted/jsx": { - "version": "1.6.1", - "resolved": "https://registry.npmjs.org/@knighted/jsx/-/jsx-1.6.1.tgz", - "integrity": "sha512-sQpx+b/6PrbriH7oK2DRdJYppYq1HIV2W9EVea8w5sWhmDARl+uIqvmG4jOTpQcFOTziRtKlZ0Jh2ybcYuL5dA==", + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@knighted/jsx/-/jsx-1.7.2.tgz", + "integrity": "sha512-zlSt7+JNKlsECsyS5e/dZpyC5HMlOVOnh5FlijFjjQ5czwQ0pLEvLzOAs67l2lELZNm745lCriNebjvaURK7ag==", "license": "MIT", "dependencies": { + "@napi-rs/wasm-runtime": "^1.1.0", "magic-string": "^0.30.21", "oxc-parser": "^0.105.0", "property-information": "^7.1.0", @@ -1198,7 +1196,7 @@ "jsx": "dist/cli/init.js" }, "engines": { - "node": ">=22.17.0" + "node": ">=22.21.1" }, "optionalDependencies": { "@oxc-parser/binding-darwin-arm64": "^0.105.0", @@ -1326,7 +1324,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.0.tgz", "integrity": "sha512-Fq6DJW+Bb5jaWE69/qOE0D1TUN9+6uWhCeZpdnSBk14pjLcCWR7Q8n49PTSPHazM37JqrsdpEthXy2xn6jWWiA==", - "devOptional": true, "license": "MIT", "dependencies": { "@emnapi/core": "^1.7.1", @@ -2638,7 +2635,6 @@ "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", - "devOptional": true, "license": "MIT", "dependencies": { "tslib": "^2.4.0" @@ -10414,7 +10410,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "devOptional": true, "license": "0BSD", "peer": true }, @@ -11337,7 +11332,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.10", + "version": "1.1.0-rc.0", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11372,8 +11367,8 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.10", - "@knighted/jsx": "^1.6.1", + "@knighted/css": "1.1.0-rc.0", + "@knighted/jsx": "^1.7.2", "lit": "^3.2.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/package.json b/package.json index 3912cd9..e803ac1 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "test": "npm run test -w @knighted/css", "pretest": "npm run build", "test:e2e": "npm run test -w @knighted/css-playwright-fixture", - "pretest:e2e": "npm run setup:jsx-wasm && npm run build", + "pretest:e2e": "npm run build", "lint": "oxlint packages", "prettier": "prettier --write .", "prettier:check": "prettier --check .", @@ -25,8 +25,7 @@ "check-types": "npm run check-types -w @knighted/css && npm run check-types -w @knighted/css-playwright-fixture", "clean:deps": "find . -name node_modules -type d -prune -exec rm -rf {} +", "clean:dist": "find . -name dist -type d -prune -exec rm -rf {} +", - "clean": "npm run clean:deps && npm run clean:dist", - "setup:jsx-wasm": "npx @knighted/jsx init" + "clean": "npm run clean:deps && npm run clean:dist" }, "devDependencies": { "@emnapi/core": "^1.2.0", diff --git a/packages/css/README.md b/packages/css/README.md index d8cf03b..5b66d09 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -27,6 +27,7 @@ I needed a single source of truth for UI components that could drop into both li - 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. +- Deterministic selector duplication via `autoStable`: duplicate matching class selectors with a stable namespace (default `knighted-`) in both plain CSS and CSS Modules exports. - Pluggable resolver/filter hooks for custom module resolution (e.g., Rspack/Vite/webpack aliases) or selective inclusion. - First-class loader (`@knighted/css/loader`) so bundlers can import compiled CSS alongside their modules via `?knighted-css`. - Built-in type generation CLI (`knighted-css-generate-types`) that emits `.knighted-css.*` selector manifests so TypeScript gets literal tokens in lockstep with the loader exports. @@ -68,6 +69,13 @@ type CssOptions = { extensions?: string[] // customize file extensions to scan cwd?: string // working directory (defaults to process.cwd()) filter?: (filePath: string) => boolean + autoStable?: + | boolean + | { + namespace?: string + include?: RegExp + exclude?: RegExp + } lightningcss?: boolean | LightningTransformOptions specificityBoost?: { visitor?: LightningTransformOptions['visitor'] diff --git a/packages/css/package.json b/packages/css/package.json index f1ea37b..719021f 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.10", + "version": "1.1.0-rc.0", "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/autoStableSelectors.ts b/packages/css/src/autoStableSelectors.ts new file mode 100644 index 0000000..dc066ba --- /dev/null +++ b/packages/css/src/autoStableSelectors.ts @@ -0,0 +1,172 @@ +import type { LightningVisitor } from './helpers.js' +import { serializeSelector } from './helpers.js' +import { stableClass } from './stableSelectors.js' + +export interface AutoStableConfig { + namespace?: string + include?: RegExp + exclude?: RegExp +} + +export type AutoStableOption = boolean | AutoStableConfig +export type AutoStableVisitor = LightningVisitor + +type SelectorNode = { + type: string + value?: string + name?: string + kind?: string + selectors?: Selector | Selector[] | null + [key: string]: unknown +} + +type Selector = SelectorNode[] +type TransformResult = { selector: Selector; changed: boolean } + +type RuleWithSelectors = { + selectors?: unknown + value?: { + selectors?: unknown + } +} + +function isSelectorList(value: unknown): value is Selector[] { + return Array.isArray(value) +} + +function isRuleWithSelectors(rule: unknown): rule is RuleWithSelectors { + return typeof rule === 'object' && rule !== null +} + +function hasSelectorList( + rule: RuleWithSelectors, +): rule is RuleWithSelectors & { selectors: Selector[] } { + return isSelectorList(rule.selectors) +} + +function hasValueSelectorList( + rule: RuleWithSelectors, +): rule is RuleWithSelectors & { value: { selectors: Selector[] } } { + return isSelectorList(rule.value?.selectors) +} + +function getSelectors(rule: RuleWithSelectors | undefined): Selector[] | undefined { + if (rule && hasSelectorList(rule)) return rule.selectors + if (rule && hasValueSelectorList(rule)) return rule.value.selectors + return undefined +} + +function setSelectors(rule: T, selectors: Selector[]): T { + if (hasSelectorList(rule)) { + rule.selectors = selectors + return rule + } + if (hasValueSelectorList(rule)) { + rule.value.selectors = selectors + return rule + } + return rule +} + +export function normalizeAutoStableOption(option?: AutoStableOption) { + if (!option) return undefined + if (option === true) return {} + return option +} + +export function buildAutoStableVisitor(option?: AutoStableOption) { + const config = normalizeAutoStableOption(option) + if (!config) return undefined + + const visitor: LightningVisitor = { + Rule: { + style(rule) { + if (!isRuleWithSelectors(rule)) return rule + const baseSelectors = getSelectors(rule) + if (!baseSelectors) return rule + const seen = new Set(baseSelectors.map(sel => serializeSelector(sel))) + const augmented: typeof baseSelectors = [...baseSelectors] + + for (const selector of baseSelectors) { + const { selector: stableSelector, changed } = transformSelector( + selector, + config, + ) + if (!changed) continue + const key = serializeSelector(stableSelector) + if (seen.has(key)) continue + seen.add(key) + augmented.push(stableSelector) + } + + return setSelectors(rule, augmented) + }, + }, + } + + return visitor +} + +function transformSelector( + selector: Selector, + config: AutoStableConfig, +): TransformResult { + let changed = false + const next = selector.map(node => transformNode(node, config, () => (changed = true))) + return { selector: next, changed } +} + +function transformNode( + node: SelectorNode, + config: AutoStableConfig, + markChanged: () => void, +) { + if (!node || typeof node !== 'object') return node + + // Respect :global(...) scopes by leaving them untouched. + if (node.type === 'pseudo-class' && node.kind === 'global') { + return node + } + + if (node.type === 'class') { + const value = node.value ?? node.name ?? '' + if (!shouldTransform(value, config)) { + return node + } + const stable = stableClass(value, { namespace: config.namespace }) + if (!stable || stable === value) { + return node + } + markChanged() + return { ...node, value: stable, name: stable } + } + + if (hasSelectors(node)) { + const nestedSelectors = node.selectors.map(sel => { + const nested = transformSelector(sel, config) + if (nested.changed) { + markChanged() + } + return nested.selector + }) + return { ...node, selectors: nestedSelectors } + } + + return node +} + +function hasSelectors( + node: SelectorNode, +): node is SelectorNode & { selectors: Selector[] } { + return Array.isArray(node.selectors) +} + +function shouldTransform(token: string, config: AutoStableConfig) { + if (config.exclude && config.exclude.test(token)) { + return false + } + if (config.include && !config.include.test(token)) { + return false + } + return true +} diff --git a/packages/css/src/css.ts b/packages/css/src/css.ts index 159399a..e8536b9 100644 --- a/packages/css/src/css.ts +++ b/packages/css/src/css.ts @@ -9,13 +9,23 @@ import { import { applyStringSpecificityBoost, buildSpecificityVisitor, + escapeRegex, type SpecificitySelector, type SpecificityStrategy, } from './helpers.js' +import { + buildAutoStableVisitor, + normalizeAutoStableOption, + type AutoStableOption, + type AutoStableVisitor, +} from './autoStableSelectors.js' +import { stableClass } from './stableSelectors.js' + import { collectStyleImports } from './moduleGraph.js' import type { ModuleGraphOptions } from './moduleGraph.js' import { createSassImporter } from './sassInternals.js' import type { CssResolver } from './types.js' +export type { AutoStableOption } from './autoStableSelectors.js' export type { CssResolver } from './types.js' export type { ModuleGraphOptions } from './moduleGraph.js' @@ -28,13 +38,56 @@ type LightningCssConfig = type PeerLoader = (name: string) => Promise +type StrictLightningVisitor = Exclude< + LightningTransformOptions['visitor'], + undefined +> + +const isVisitor = ( + value: LightningTransformOptions['visitor'] | undefined, +): value is StrictLightningVisitor => Boolean(value) + +function appendStableSelectorsFromExports( + css: string, + exportsMap: Record, + config: AutoStableOption, +): string { + let output = css + for (const [token, value] of Object.entries(exportsMap)) { + const hashed = Array.isArray(value) + ? value.join(' ') + : typeof value === 'object' && value !== null && 'name' in value + ? (value as { name: string }).name + : String(value) + + const hashedClasses = hashed.split(/\s+/).filter(Boolean) + if (hashedClasses.length === 0) continue + + const stable = stableClass(token, { + namespace: + typeof config === 'object' && config?.namespace ? config.namespace : undefined, + }) + + const stableAlreadyPresent = output.includes(`.${stable}`) + const hashedAlreadyIncludesStable = hashedClasses.includes(stable) + if (stableAlreadyPresent || hashedAlreadyIncludesStable) continue + + for (const hashedClass of hashedClasses) { + const rx = new RegExp(`\\.${escapeRegex(hashedClass)}(?![\\w-])`, 'g') + output = output.replace(rx, `.${hashedClass}, .${stable}`) + } + } + return output +} + export interface CssOptions { extensions?: string[] cwd?: string filter?: (filePath: string) => boolean lightningcss?: LightningCssConfig + autoStable?: AutoStableOption specificityBoost?: { - visitor?: LightningTransformOptions['visitor'] + visitor?: StrictLightningVisitor strategy?: SpecificityStrategy match?: SpecificitySelector[] } @@ -59,6 +112,7 @@ export interface VanillaCompileResult { export interface CssResult { css: string files: string[] + exports?: Record } export async function css(entry: string, options: CssOptions = {}): Promise { @@ -102,29 +156,59 @@ export async function cssWithMeta( let output = chunks.join('\n') - if (options.lightningcss) { - const lightningOptions = normalizeLightningOptions(options.lightningcss) + const autoStableConfig = normalizeAutoStableOption(options.autoStable) + const shouldForceLightning = Boolean(autoStableConfig) + const shouldRunLightning = Boolean(options.lightningcss || shouldForceLightning) + + let lightningExports: Record | undefined + + if (shouldRunLightning) { + const lightningOptions = normalizeLightningOptions(options.lightningcss ?? {}) const boostVisitor = buildSpecificityVisitor(options.specificityBoost) - const combinedVisitor = - boostVisitor && lightningOptions.visitor - ? composeVisitors([boostVisitor, lightningOptions.visitor]) - : (boostVisitor ?? lightningOptions.visitor) - if (combinedVisitor) { - lightningOptions.visitor = combinedVisitor + const shouldUseVisitor = Boolean(autoStableConfig) && !lightningOptions.cssModules + const autoStableVisitor: AutoStableVisitor | undefined = + shouldUseVisitor && autoStableConfig + ? buildAutoStableVisitor(autoStableConfig) + : undefined + + const composedVisitors = [ + boostVisitor, + autoStableVisitor, + lightningOptions.visitor, + ].filter(isVisitor) + + if (composedVisitors.length === 1) { + lightningOptions.visitor = composedVisitors[0] + } else if (composedVisitors.length > 1) { + lightningOptions.visitor = composeVisitors(composedVisitors) } - const { code } = lightningTransform({ + + const result = lightningTransform({ ...lightningOptions, filename: lightningOptions.filename ?? 'extracted.css', code: Buffer.from(output), - }) - output = code.toString() + }) as ReturnType & { + exports?: Record + } + + output = result.code.toString() + if (autoStableConfig && lightningOptions.cssModules && result.exports) { + output = appendStableSelectorsFromExports(output, result.exports, autoStableConfig) + } + if (result.exports) { + lightningExports = result.exports + } } if (options.specificityBoost?.strategy && !options.specificityBoost.visitor) { output = applyStringSpecificityBoost(output, options.specificityBoost) } - return { css: output, files: files.map(file => file.path) } + return { + css: output, + files: files.map(file => file.path), + exports: lightningExports, + } } async function resolveEntry( diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index c05b569..3a9f204 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -40,6 +40,7 @@ interface GenerateTypesInternalOptions { include: string[] cacheDir: string stableNamespace?: string + autoStable?: boolean tsconfig?: TsconfigResolutionContext } @@ -55,6 +56,7 @@ export interface GenerateTypesOptions { include?: string[] outDir?: string stableNamespace?: string + autoStable?: boolean } const DEFAULT_SKIP_DIRS = new Set([ @@ -125,6 +127,7 @@ export async function generateTypes( include, cacheDir, stableNamespace: options.stableNamespace, + autoStable: options.autoStable, tsconfig, } @@ -172,9 +175,15 @@ async function generateDeclarations( let selectorMap = selectorCache.get(cacheKey) if (!selectorMap) { try { + const shouldUseCssModules = resolvedPath.endsWith('.module.css') const { css } = await activeCssWithMeta(resolvedPath, { cwd: options.rootDir, peerResolver, + autoStable: options.autoStable ? { namespace: resolvedNamespace } : undefined, + lightningcss: + options.autoStable && shouldUseCssModules + ? { cssModules: true } + : undefined, }) selectorMap = buildStableSelectorsLiteral({ css, @@ -616,6 +625,7 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise include: parsed.include, outDir: parsed.outDir, stableNamespace: parsed.stableNamespace, + autoStable: parsed.autoStable, }) reportCliResult(result) } catch (error) { @@ -630,6 +640,7 @@ export interface ParsedCliArgs { include?: string[] outDir?: string stableNamespace?: string + autoStable?: boolean help?: boolean } @@ -638,11 +649,16 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { const include: string[] = [] let outDir: string | undefined let stableNamespace: string | undefined + let autoStable = false for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] if (arg === '--help' || arg === '-h') { - return { rootDir, include, outDir, stableNamespace, help: true } + return { rootDir, include, outDir, stableNamespace, autoStable, help: true } + } + if (arg === '--auto-stable') { + autoStable = true + continue } if (arg === '--root' || arg === '-r') { const value = argv[++i] @@ -682,7 +698,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { include.push(arg) } - return { rootDir, include, outDir, stableNamespace } + return { rootDir, include, outDir, stableNamespace, autoStable } } function printHelp(): void { @@ -693,6 +709,7 @@ Options: -i, --include Additional directories/files to scan (repeatable) --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 -h, --help Show this help message `) } diff --git a/packages/css/src/loader.ts b/packages/css/src/loader.ts index 47c4dfe..e951602 100644 --- a/packages/css/src/loader.ts +++ b/packages/css/src/loader.ts @@ -6,8 +6,14 @@ import type { PitchLoaderDefinitionFunction, } from 'webpack' -import { cssWithMeta, compileVanillaModule, type CssOptions } from './css.js' +import { + cssWithMeta, + compileVanillaModule, + type CssOptions, + type CssResult, +} from './css.js' import { detectModuleDefaultExport, type ModuleDefaultSignal } from './moduleInfo.js' +import { normalizeAutoStableOption } from './autoStableSelectors.js' import { buildSanitizedQuery, hasCombinedQuery, @@ -19,6 +25,7 @@ import { } from './loaderInternals.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' +import { stableClass } from './stableSelectors.js' type KnightedCssCombinedExtras = Readonly> @@ -30,6 +37,14 @@ export type KnightedCssCombinedModule< knightedCss: string } +type CssModuleExportValue = + | string + | string[] + | { + name?: string + composes?: Array<{ name?: string } | string> + } + export interface KnightedCssVanillaOptions { transformToEsm?: boolean } @@ -51,7 +66,20 @@ const loader: LoaderDefinitionFunction = async functio } = resolveLoaderOptions(this) const resolvedNamespace = resolveStableNamespace(optionNamespace) const typesRequested = hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG) - const css = await extractCss(this, cssOptions) + const isStyleModule = this.resourcePath.endsWith('.css.ts') + const cssOptionsForExtract = isStyleModule + ? { ...cssOptions, autoStable: undefined } + : cssOptions + const cssMeta = await extractCss(this, cssOptionsForExtract) + const activeAutoStable = normalizeAutoStableOption(cssOptionsForExtract.autoStable) + const cssModuleExports = activeAutoStable + ? mergeCssModuleExports(cssMeta.exports, { + namespace: activeAutoStable.namespace ?? resolvedNamespace, + include: activeAutoStable.include, + exclude: activeAutoStable.exclude, + }) + : undefined + const css = cssMeta.css const stableSelectorsLiteral = typesRequested ? buildStableSelectorsLiteral({ css, @@ -61,10 +89,13 @@ const loader: LoaderDefinitionFunction = async functio target: 'js', }) : undefined + const emitCssModuleDefault = + isCssLikeResource(this.resourcePath) && Boolean(cssModuleExports) const injection = buildInjection(css, { stableSelectorsLiteral: stableSelectorsLiteral?.literal, + cssModuleExports, + emitCssModuleDefault, }) - const isStyleModule = this.resourcePath.endsWith('.css.ts') if (isStyleModule) { const { source: compiledSource } = await compileVanillaModule( this.resourcePath, @@ -128,24 +159,34 @@ export const pitch: PitchLoaderDefinitionFunction = : detectModuleDefaultExport(this.resourcePath) return Promise.all([extractCss(this, cssOptions), defaultSignalPromise]).then( - ([css, defaultSignal]) => { + ([cssMeta, defaultSignal]) => { const emitDefault = shouldEmitCombinedDefault({ request, skipSyntheticDefault, detection: defaultSignal, }) + const activeAutoStable = normalizeAutoStableOption(cssOptions.autoStable) + const cssModuleExports = activeAutoStable + ? mergeCssModuleExports(cssMeta.exports, { + namespace: activeAutoStable.namespace ?? resolvedNamespace, + include: activeAutoStable.include, + exclude: activeAutoStable.exclude, + }) + : undefined const stableSelectorsLiteral = typesRequested ? buildStableSelectorsLiteral({ - css, + css: cssMeta.css, namespace: resolvedNamespace, resourcePath: this.resourcePath, emitWarning: message => emitKnightedWarning(this, message), target: 'js', }) : undefined - return createCombinedModule(request, css, { + return createCombinedModule(request, cssMeta.css, { emitDefault, stableSelectorsLiteral: stableSelectorsLiteral?.literal, + cssModuleExports, + emitCssModuleDefault: false, }) }, ) @@ -177,13 +218,13 @@ function resolveLoaderOptions(ctx: LoaderContext): { async function extractCss( ctx: LoaderContext, options: CssOptions, -): Promise { - const { css, files } = await cssWithMeta(ctx.resourcePath, options) - const uniqueFiles = new Set([ctx.resourcePath, ...files]) +): Promise { + const result = await cssWithMeta(ctx.resourcePath, options) + const uniqueFiles = new Set([ctx.resourcePath, ...result.files]) for (const file of uniqueFiles) { ctx.addDependency(file) } - return css + return result } function toSourceString(source: string | Buffer): string { @@ -192,15 +233,82 @@ function toSourceString(source: string | Buffer): string { function buildInjection( css: string, - extras?: { stableSelectorsLiteral?: string }, + extras?: { + stableSelectorsLiteral?: string + cssModuleExports?: Record + emitCssModuleDefault?: boolean + }, ): string { const lines = [`\n\nexport const ${DEFAULT_EXPORT_NAME} = ${JSON.stringify(css)};\n`] if (extras?.stableSelectorsLiteral) { lines.push(extras.stableSelectorsLiteral) } + if (extras?.cssModuleExports) { + lines.push( + `export const knightedCssModules = ${JSON.stringify(extras.cssModuleExports)};`, + ) + if (extras.emitCssModuleDefault) { + lines.push('export default knightedCssModules;') + } + } return lines.join('') } +function isCssLikeResource(resourcePath: string): boolean { + return ( + /\.(css|scss|sass|less)(\?.*)?$/i.test(resourcePath) && + !resourcePath.endsWith('.css.ts') + ) +} + +function mergeCssModuleExports( + exportsMap: CssResult['exports'], + options: { namespace?: string; include?: RegExp; exclude?: RegExp }, +): Record | undefined { + if (!exportsMap) return undefined + + const output: Record = {} + for (const [token, value] of Object.entries(exportsMap)) { + const hashedParts = toClassParts(value) + + if (options.exclude && options.exclude.test(token)) { + output[token] = hashedParts.join(' ') + continue + } + if (options.include && !options.include.test(token)) { + output[token] = hashedParts.join(' ') + continue + } + + const stable = stableClass(token, { namespace: options.namespace }) + if (stable && !hashedParts.includes(stable)) { + hashedParts.push(stable) + } + output[token] = hashedParts.join(' ') + } + + return output +} + +function toClassParts(value: CssModuleExportValue | undefined): string[] { + if (!value) return [] + if (Array.isArray(value)) { + return value.flatMap(part => part.split(/\s+/).filter(Boolean)) + } + if (typeof value === 'object') { + const parts = [ + value.name, + ...(value.composes ?? []).map(entry => + typeof entry === 'string' ? entry : entry?.name, + ), + ] + .filter(Boolean) + .map(String) + return parts.flatMap(part => part.split(/\s+/).filter(Boolean)) + } + return value.split(/\s+/).filter(Boolean) +} + function buildProxyRequest(ctx: LoaderContext): string { const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery) const rawRequest = getRawRequest(ctx) @@ -298,6 +406,8 @@ function isRelativeSpecifier(specifier: string): boolean { interface CombinedModuleOptions { emitDefault?: boolean stableSelectorsLiteral?: string + cssModuleExports?: Record + emitCssModuleDefault?: boolean } function createCombinedModule( @@ -323,7 +433,11 @@ typeof __knightedModule.default !== 'undefined' } lines.push( - buildInjection(css, { stableSelectorsLiteral: options?.stableSelectorsLiteral }), + buildInjection(css, { + stableSelectorsLiteral: options?.stableSelectorsLiteral, + cssModuleExports: options?.cssModuleExports, + emitCssModuleDefault: options?.emitCssModuleDefault, + }), ) return lines.join('\n') } diff --git a/packages/css/test/__snapshots__/generateTypes.snap.json b/packages/css/test/__snapshots__/generateTypes.snap.json index 42616f5..42f380e 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 -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 -h, --help Show this help message" } diff --git a/packages/css/test/autoStableSelectors.test.ts b/packages/css/test/autoStableSelectors.test.ts new file mode 100644 index 0000000..ef22672 --- /dev/null +++ b/packages/css/test/autoStableSelectors.test.ts @@ -0,0 +1,138 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { transform as lightningTransform } from 'lightningcss' + +import { buildAutoStableVisitor } from '../src/autoStableSelectors.js' + +type StyleRuleParam = { selectors?: unknown; value?: { selectors?: unknown } } | null +type StyleRuleFn = (rule: StyleRuleParam) => unknown +type SelectorNode = { value?: string; name?: string } + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const fixturesDir = path.resolve(__dirname, 'fixtures', 'auto-stable', 'plain') + +function runLightning( + cssSource: string, + visitor: ReturnType, +) { + const { code } = lightningTransform({ + filename: 'fixture.css', + code: Buffer.from(cssSource), + visitor: visitor ?? undefined, + }) + return code.toString() +} + +function getStyle( + visitor: ReturnType, +): StyleRuleFn | undefined { + const rule = visitor?.Rule + if (rule && typeof rule === 'object' && 'style' in rule) { + const maybe = (rule as { style?: unknown }).style + if (typeof maybe === 'function') return maybe as StyleRuleFn + } + return undefined +} + +function selectorNames(selectors: unknown): string[][] { + if (!Array.isArray(selectors)) return [] + return selectors.map(sel => + Array.isArray(sel) + ? sel.map(node => { + const cast = node as SelectorNode + return cast.value ?? cast.name ?? '' + }) + : [], + ) +} + +test('duplicates class selectors with default namespace', async () => { + const cssSource = await readFile(path.join(fixturesDir, 'simple.css'), 'utf8') + const visitor = buildAutoStableVisitor(true) + const output = runLightning(cssSource, visitor) + + assert.match(output, /\.foo,\s*\.knighted-foo\s*\{[^}]*\}/) +}) + +test('respects :global scope and nested pseudos', () => { + const source = `:global(.skip) .foo:hover .bar { color: red; }` + const visitor = buildAutoStableVisitor({ include: /foo|bar/ }) + const output = runLightning(source, visitor) + + assert.match( + output, + /:global\(\.skip\)\s+\.foo:hover\s+\.bar,\s*:global\(\.skip\)\s+\.knighted-foo:hover\s+\.knighted-bar/, + ) +}) + +test('skips @keyframes and applies include/exclude filters', () => { + const source = `@keyframes spin { from { opacity: 0; } to { opacity: 1; } }\n.foo { color: red; }\n.bar { color: blue; }` + const visitor = buildAutoStableVisitor({ include: /foo/, exclude: /bar/ }) + const output = runLightning(source, visitor) + + assert.match(output, /@keyframes spin/) + assert.doesNotMatch(output, /knighted-spin/) + assert.match(output, /\.knighted-foo/) + assert.doesNotMatch(output, /knighted-bar/) +}) + +test('duplicates nested :is/:where/:has selectors with multiple classes', () => { + const source = `.foo.bar:hover:is(.baz, .qux .zap:has(.zip)) { color: red; }` + const visitor = buildAutoStableVisitor(true) + const output = runLightning(source, visitor) + + assert.match(output, /\.foo\.bar:hover:is\(\.baz, \.qux \.zap:has\(\.zip\)\)/) + assert.match( + output, + /\.knighted-foo\.knighted-bar:hover:is\(\.knighted-baz, \.knighted-qux \.knighted-zap:has\(\.knighted-zip\)\)/, + ) +}) + +test('honors custom namespace and avoids duplicates when namespace empty', () => { + const source = `.foo { color: red; }` + const custom = buildAutoStableVisitor({ namespace: 'custom' }) + const none = buildAutoStableVisitor({ namespace: '' }) + + const customOut = runLightning(source, custom) + assert.match(customOut, /\.custom-foo/) + + const noneOut = runLightning(source, none) + const selectors = noneOut.match(/\.foo/g) ?? [] + assert.equal(selectors.length, 1, 'no duplicate when stable equals original') +}) + +test('does not add duplicate when stable selector already present', () => { + const source = `.foo, .knighted-foo { color: red; }` + const visitor = buildAutoStableVisitor(true) + const output = runLightning(source, visitor) + const matches = output.match(/\.knighted-foo/g) ?? [] + assert.equal(matches.length, 1, 'stable class should not be appended twice') +}) + +test('handles lightningcss rule objects with value.selectors', () => { + const visitor = buildAutoStableVisitor(true) + const rule: StyleRuleParam = { + value: { + selectors: [[{ type: 'class', value: 'foo' }]], + }, + } + const style = getStyle(visitor) + const mutated = style ? style(rule) : undefined + const selectors = + mutated && typeof mutated === 'object' && 'value' in mutated + ? (mutated as { value?: { selectors?: unknown } }).value?.selectors + : undefined + const serialized = selectorNames(selectors) + assert.equal(Array.isArray(selectors) ? selectors.length : 0, 2) + assert.ok(serialized.some(parts => parts?.includes('knighted-foo'))) +}) + +test('returns early when rule is not an object', () => { + const visitor = buildAutoStableVisitor(true) + const style = getStyle(visitor) + const result = style ? style(null as unknown as StyleRuleParam) : undefined + assert.equal(result, null) +}) diff --git a/packages/css/test/css.autoStable.test.ts b/packages/css/test/css.autoStable.test.ts new file mode 100644 index 0000000..adb8e61 --- /dev/null +++ b/packages/css/test/css.autoStable.test.ts @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import test from 'node:test' + +import { cssWithMeta } from '../src/css.js' +import type { LightningVisitor } from '../src/helpers.js' +import type { SelectorComponent } from 'lightningcss' + +const fixturesDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'auto-stable', + 'plain', +) +const modulesDir = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + 'fixtures', + 'auto-stable', + 'modules', +) + +function runCss(entry: string, options: Parameters[1]) { + return cssWithMeta(entry, { cwd: fixturesDir, ...options }) +} + +test('autoStable forces lightningcss even when disabled', async () => { + const entry = path.join(fixturesDir, 'simple.css') + const result = await runCss(entry, { autoStable: true, lightningcss: false }) + assert.match(result.css, /\.foo,\s*\.knighted-foo/) +}) + +test('autoStable composes with specificity boost and user visitor', async () => { + const entry = path.join(fixturesDir, 'simple.css') + const isSelectorList = ( + selectors: unknown, + ): selectors is Array> => + Array.isArray(selectors) && + selectors.every( + sel => + Array.isArray(sel) && + sel.every( + part => + typeof part === 'object' && + part !== null && + 'type' in part && + typeof part.type === 'string', + ), + ) + const userVisitor: LightningVisitor = { + Rule: { + style(rule) { + if (!rule || !isSelectorList(rule.value?.selectors)) { + return rule + } + const selectors = rule.value.selectors + const classComponent = { type: 'class', name: 'user' } satisfies SelectorComponent + const augmented = selectors.map(sel => [...sel, { ...classComponent }]) + return { ...rule, value: { ...rule.value, selectors: augmented } } + }, + }, + } + const result = await runCss(entry, { + autoStable: true, + lightningcss: { + visitor: userVisitor, + }, + specificityBoost: { strategy: { type: 'append-where', token: 'boost' } }, + }) + + /** + * LightningCSS may reorder visistor composition; ensure both the specificity boost and + * autoStable duplication ran by checking for both tokens anywhere in the selector text. + */ + assert.match(result.css, /\.foo[^}]*:where\(\.boost\)/) + assert.match(result.css, /\.knighted-foo[^}]*:where\(\.boost\)/) +}) + +test('autoStable captures cssModules exports and selector duplication', async () => { + const entry = path.join(modulesDir, 'button.module.css') + const result = await cssWithMeta(entry, { + cwd: modulesDir, + autoStable: true, + lightningcss: { cssModules: true }, + }) + + const buttonExport = result.exports?.button + const composed = result.exports?.primary + + const toStringValue = (value: unknown): string => { + if (typeof value === 'string') return value + if (Array.isArray(value)) return value.join(' ') + if (value && typeof value === 'object' && 'name' in value) { + const entry = value as { name?: string; composes?: Array<{ name?: string }> } + const names = [entry.name, ...(entry.composes ?? []).map(c => c?.name)].filter( + Boolean, + ) + return names.join(' ') + } + return '' + } + + /** + * LightningCSS returns the hashed class in the exports; stable selectors are injected in CSS and + * appended to exports later in the loader pipeline. Ensure the runtime CSS has stable selectors and + * that composed exports include their composed hashed class names. + */ + assert.match(result.css, /\.knighted-button/) + assert.match(result.css, /\.knighted-primary/) + + const composedNames = toStringValue(composed).split(/\s+/) + const buttonName = toStringValue(buttonExport) + assert.ok(buttonName && composedNames.includes(buttonName)) +}) diff --git a/packages/css/test/fixtures/auto-stable/modules/button.module.css b/packages/css/test/fixtures/auto-stable/modules/button.module.css new file mode 100644 index 0000000..7f20b81 --- /dev/null +++ b/packages/css/test/fixtures/auto-stable/modules/button.module.css @@ -0,0 +1,9 @@ +/* CSS Modules composition is intentional for testing; editors may flag "composes" as non-standard */ +.button { + color: blue; +} + +.primary { + composes: button; + color: green; +} diff --git a/packages/css/test/fixtures/auto-stable/plain/simple.css b/packages/css/test/fixtures/auto-stable/plain/simple.css new file mode 100644 index 0000000..a15c877 --- /dev/null +++ b/packages/css/test/fixtures/auto-stable/plain/simple.css @@ -0,0 +1,3 @@ +.foo { + color: red; +} diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index a45c67b..985609f 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -219,6 +219,37 @@ test('generateTypes emits declarations and reuses cache', async () => { } }) +test('generateTypes autoStable emits selectors for CSS Modules', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-auto-stable-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const cssPath = path.join(srcDir, 'styles.module.css') + await fs.writeFile(cssPath, '.card { color: red; }\n') + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import selectors from './styles.module.css.knighted-css'\n" + + 'console.log(selectors.card)\n', + ) + + const outDir = path.join(root, '.knighted-css-test') + const result = await generateTypes({ + rootDir: root, + include: ['src'], + outDir, + autoStable: true, + }) + assert.ok(result.selectorModulesWritten >= 1) + assert.equal(result.warnings.length, 0) + + const selectorModulePath = path.join(srcDir, 'styles.module.css.knighted-css.ts') + const selectorModule = await fs.readFile(selectorModulePath, 'utf8') + assert.match(selectorModule, /"card": "knighted-card"/) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('generateTypes resolves tsconfig baseUrl specifiers', async () => { const project = await setupBaseUrlFixture() try { @@ -622,10 +653,12 @@ test('generateTypes internals support selector module helpers', async () => { 'storybook', '--out-dir', '.knighted-css', + '--auto-stable', ]) as ParsedCliArgs assert.equal(parsed.rootDir, path.resolve('/tmp/project')) assert.deepEqual(parsed.include, ['src']) assert.equal(parsed.stableNamespace, 'storybook') + assert.equal(parsed.autoStable, true) assert.throws(() => parseCliArgs(['--root']), /Missing value/) assert.throws(() => parseCliArgs(['--include']), /Missing value/) diff --git a/packages/css/test/loaderAutoStable.test.ts b/packages/css/test/loaderAutoStable.test.ts new file mode 100644 index 0000000..ec3f5f6 --- /dev/null +++ b/packages/css/test/loaderAutoStable.test.ts @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' +import { fileURLToPath } from 'node:url' +import type { LoaderContext } from 'webpack' + +import loader, { type KnightedCssLoaderOptions } from '../src/loader.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + +type MockCtx = Partial> & { added: Set } + +function createMockCtx( + overrides: Partial> = {}, +): MockCtx { + const added = new Set() + return { + resourcePath: + overrides.resourcePath ?? + path.resolve(__dirname, 'fixtures/auto-stable/modules/button.module.css'), + rootContext: overrides.rootContext ?? path.resolve(__dirname, 'fixtures'), + addDependency: overrides.addDependency ?? ((file: string) => added.add(file)), + getOptions: + overrides.getOptions ?? + (() => + ({ + autoStable: true, + lightningcss: { cssModules: true }, + }) as KnightedCssLoaderOptions), + async: overrides.async, + callback: overrides.callback, + resourceQuery: overrides.resourceQuery, + context: overrides.context, + utils: overrides.utils, + added, + ...overrides, + } +} + +async function runLoader(ctx: MockCtx, source = 'export const noop = true') { + return String(await loader.call(ctx as LoaderContext, source)) +} + +test('appends stable classes to CSS Modules exports and injects default', async () => { + const ctx = createMockCtx() + const output = await runLoader(ctx) + const map = extractModulesMap(output) + const classes = map.button?.split(/\s+/) ?? [] + assert.ok( + classes.some(cls => cls.startsWith('knighted-button')), + 'should include stable class', + ) + assert.ok( + classes.some(cls => cls !== 'knighted-button'), + 'should retain hashed class', + ) + assert.match(output, /export default knightedCssModules;/) + assert.match(output, /\.knighted-button/, 'should duplicate selectors in CSS output') +}) + +test('respects include/exclude filters for exports', async () => { + const ctx = createMockCtx({ + getOptions: () => ({ + autoStable: { include: /primary/, exclude: /button/ }, + lightningcss: { cssModules: true }, + }), + }) + const output = await runLoader(ctx) + const map = extractModulesMap(output) + assert.match(map.primary ?? '', /knighted-primary/) + assert.ok(!String(map.button ?? '').includes('knighted-button')) +}) + +function extractModulesMap(output: string): Record { + const match = /knightedCssModules\s*=\s*(\{[\s\S]*?\})/.exec(output) + if (!match) return {} + try { + return JSON.parse(match[1]) as Record + } catch { + return {} + } +} diff --git a/packages/playwright/auto-stable.html b/packages/playwright/auto-stable.html new file mode 100644 index 0000000..ad9f0d0 --- /dev/null +++ b/packages/playwright/auto-stable.html @@ -0,0 +1,25 @@ + + + + + Auto Stable Demo + + + + +
+ + + diff --git a/packages/playwright/package.json b/packages/playwright/package.json index d7380e9..edd03c7 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -4,20 +4,24 @@ "private": true, "type": "module", "scripts": { - "build": "npm run build:rspack && npm run build:webpack && npm run build:ssr", - "types": "knighted-css-generate-types --root . --include src", - "build:rspack": "npx rspack --config rspack.config.js", - "build:webpack": "npx webpack --config webpack.config.js", - "build:ssr": "npx tsx scripts/render-ssr-preview.ts", + "build": "npm run build:rspack && npm run build:auto-stable && npm run build:webpack && npm run build:ssr", + "types": "npm run types:base && npm run types:auto-stable", + "types:base": "knighted-css-generate-types --root . --include src --out-dir .knighted-css", + "types:auto-stable": "knighted-css-generate-types --root . --include src/auto-stable --auto-stable --out-dir .knighted-css-auto", + "build:rspack": "rspack --config rspack.config.js", + "build:auto-stable": "rspack --config rspack.auto-stable.config.js", + "build:webpack": "webpack --config webpack.config.js", + "build:ssr": "tsx scripts/render-ssr-preview.ts", "check-types": "tsc --noEmit", - "preview": "npm run build && npx http-server . -p 4174", - "serve": "npx http-server dist -p 4174", - "test": "npx playwright test", + "preview": "npm run build && http-server . -p 4174", + "preview:auto-stable": "npm run build:auto-stable && http-server . -p 4175", + "serve": "http-server dist -p 4174", + "test": "playwright test", "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.0.10", - "@knighted/jsx": "^1.6.1", + "@knighted/css": "1.1.0-rc.0", + "@knighted/jsx": "^1.7.2", "lit": "^3.2.1", "react": "^19.0.0", "react-dom": "^19.0.0" diff --git a/packages/playwright/rspack.auto-stable.config.js b/packages/playwright/rspack.auto-stable.config.js new file mode 100644 index 0000000..3014b3e --- /dev/null +++ b/packages/playwright/rspack.auto-stable.config.js @@ -0,0 +1,89 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { ProvidePlugin } from '@rspack/core' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +export default { + mode: 'development', + context: __dirname, + entry: './src/auto-stable/index.ts', + output: { + path: path.resolve(__dirname, 'dist-auto-stable'), + filename: 'auto-stable-bundle.js', + cssFilename: 'auto-stable.css', + library: { + type: 'umd', + name: 'AutoStableApp', + }, + }, + resolve: { + extensions: ['.tsx', '.ts', '.js', '.css'], + extensionAlias: { + '.js': ['.js', '.ts', '.tsx'], + }, + }, + experiments: { + css: true, + }, + module: { + rules: [ + { + test: /\.module\.css$/, + type: 'css/module', + }, + { + test: /\.[jt]sx?$/, + resourceQuery: /knighted-css/, + use: [ + { + loader: '@knighted/css/loader', + options: { + lightningcss: { minify: true }, + autoStable: true, + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + }, + }, + }, + }, + ], + }, + { + test: /\.tsx?$/, + use: [ + { + loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + }, + }, + }, + }, + ], + }, + ], + }, + plugins: [ + new ProvidePlugin({ + React: 'react', + }), + ], +} diff --git a/packages/playwright/rspack.config.js b/packages/playwright/rspack.config.js index 1fc3b75..d9cd72e 100644 --- a/packages/playwright/rspack.config.js +++ b/packages/playwright/rspack.config.js @@ -87,6 +87,9 @@ export default { use: [ { loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, }, { loader: 'builtin:swc-loader', diff --git a/packages/playwright/src/auto-stable/constants.ts b/packages/playwright/src/auto-stable/constants.ts new file mode 100644 index 0000000..df745a7 --- /dev/null +++ b/packages/playwright/src/auto-stable/constants.ts @@ -0,0 +1,5 @@ +export const AUTO_STABLE_HOST_TAG = 'knighted-auto-stable-host' +export const AUTO_STABLE_HOST_TEST_ID = 'auto-stable-host' +export const AUTO_STABLE_LIGHT_TEST_ID = 'auto-stable-light' +export const AUTO_STABLE_SHADOW_TEST_ID = 'auto-stable-shadow' +export const AUTO_STABLE_TOKEN_TEST_ID = 'auto-stable-token' diff --git a/packages/playwright/src/auto-stable/index.ts b/packages/playwright/src/auto-stable/index.ts new file mode 100644 index 0000000..f1deb36 --- /dev/null +++ b/packages/playwright/src/auto-stable/index.ts @@ -0,0 +1,27 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot } from 'react-dom/client' + +import { LightDomCard } from './light-dom-card.js' +import { ensureAutoStableHostDefined } from './lit-host.js' +import { AUTO_STABLE_HOST_TAG, AUTO_STABLE_HOST_TEST_ID } from './constants.js' + +export function renderAutoStableDemo(): HTMLElement { + const root = document.getElementById('auto-stable-app') ?? document.body + + const lightMount = document.createElement('section') + lightMount.setAttribute('data-section', 'auto-stable-light') + root.appendChild(lightMount) + + createRoot(lightMount).render(reactJsx`<${LightDomCard} />`) + + ensureAutoStableHostDefined() + const host = document.createElement(AUTO_STABLE_HOST_TAG) + host.setAttribute('data-testid', AUTO_STABLE_HOST_TEST_ID) + root.appendChild(host) + + return root +} + +if (typeof document !== 'undefined') { + renderAutoStableDemo() +} diff --git a/packages/playwright/src/auto-stable/light-dom-card.tsx b/packages/playwright/src/auto-stable/light-dom-card.tsx new file mode 100644 index 0000000..0ecac51 --- /dev/null +++ b/packages/playwright/src/auto-stable/light-dom-card.tsx @@ -0,0 +1,17 @@ +import * as styles from './styles.module.css' +import { AUTO_STABLE_LIGHT_TEST_ID } from './constants.js' + +export function LightDomCard() { + return ( +
+
+ Light DOM +

CSS Modules hashing

+

+ The Light DOM component consumes shared class names that get hashed by the CSS + Modules pipeline. +

+
+
+ ) +} diff --git a/packages/playwright/src/auto-stable/lit-host.ts b/packages/playwright/src/auto-stable/lit-host.ts new file mode 100644 index 0000000..a9d1e86 --- /dev/null +++ b/packages/playwright/src/auto-stable/lit-host.ts @@ -0,0 +1,63 @@ +import { reactJsx } from '@knighted/jsx/react' +import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit' + +import * as shadowTree from './shadow-tree.js?knighted-css&combined&named-only' +import { AUTO_STABLE_HOST_TAG } from './constants.js' + +const { ShadowTree, knightedCss: shadowTreeCss } = + asKnightedCssCombinedModule(shadowTree) +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 AutoStableHost 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} />`) + } + + protected updated(changed: PropertyValues): void { + super.updated(changed) + } + + render() { + return html`
` + } +} + +export function ensureAutoStableHostDefined(): void { + if (!customElements.get(AUTO_STABLE_HOST_TAG)) { + customElements.define(AUTO_STABLE_HOST_TAG, AutoStableHost) + } +} diff --git a/packages/playwright/src/auto-stable/shadow-tree.tsx b/packages/playwright/src/auto-stable/shadow-tree.tsx new file mode 100644 index 0000000..ccb739b --- /dev/null +++ b/packages/playwright/src/auto-stable/shadow-tree.tsx @@ -0,0 +1,21 @@ +import './styles.module.css' +import stableSelectors from './styles.module.css.knighted-css.js' +import { AUTO_STABLE_SHADOW_TEST_ID, AUTO_STABLE_TOKEN_TEST_ID } from './constants.js' + +export function ShadowTree() { + return ( +
+
+ Shadow DOM +

Auto-stable selectors

+

+ The Lit host renders a React tree that uses stable selectors generated + automatically from the shared CSS Modules file. +

+ + {stableSelectors.card} + +
+
+ ) +} diff --git a/packages/playwright/src/auto-stable/styles.module.css b/packages/playwright/src/auto-stable/styles.module.css new file mode 100644 index 0000000..c6d4084 --- /dev/null +++ b/packages/playwright/src/auto-stable/styles.module.css @@ -0,0 +1,52 @@ +.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; +} + +.token { + justify-self: start; + padding: 0.3rem 0.75rem; + border-radius: 0.75rem; + background: rgba(15, 23, 42, 0.65); + border: 1px solid rgba(148, 163, 184, 0.4); + color: #e2e8f0; + font-family: + 'SFMono-Regular', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + 'Liberation Mono', 'Courier New', monospace; + font-size: 0.75rem; +} diff --git a/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts b/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts new file mode 100644 index 0000000..0998ed6 --- /dev/null +++ b/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts @@ -0,0 +1,16 @@ +// Generated by @knighted/css/generate-types +// Do not edit. + +export const stableSelectors = { + badge: 'knighted-badge', + card: 'knighted-card', + copy: 'knighted-copy', + stack: 'knighted-stack', + title: 'knighted-title', + token: 'knighted-token', +} as const + +export type KnightedCssStableSelectors = typeof stableSelectors +export type KnightedCssStableSelectorToken = keyof typeof stableSelectors + +export default stableSelectors diff --git a/packages/playwright/test/auto-stable.spec.ts b/packages/playwright/test/auto-stable.spec.ts new file mode 100644 index 0000000..b3ac2b5 --- /dev/null +++ b/packages/playwright/test/auto-stable.spec.ts @@ -0,0 +1,87 @@ +import { expect, test } from '@playwright/test' + +import { + AUTO_STABLE_HOST_TEST_ID, + AUTO_STABLE_LIGHT_TEST_ID, + AUTO_STABLE_SHADOW_TEST_ID, + AUTO_STABLE_TOKEN_TEST_ID, +} from '../src/auto-stable/constants.js' + +test.describe('Auto-stable 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('/auto-stable.html') + }) + + test('light DOM card uses CSS module styles', async ({ page }) => { + const card = page.getByTestId(AUTO_STABLE_LIGHT_TEST_ID) + await expect(card).toBeVisible() + + const metrics = await card.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(), + } + }) + + expect(metrics.className).not.toBe('') + expect(metrics.background).toBe('rgb(15, 23, 42)') + expect(metrics.color).toBe('rgb(226, 232, 240)') + }) + + test('shadow DOM card uses auto-stable selectors', async ({ page }) => { + const host = page.getByTestId(AUTO_STABLE_HOST_TEST_ID) + await expect(host).toBeVisible() + + const cardHandle = await page.waitForFunction( + ({ hostId, shadowId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(`[data-testid="${shadowId}"]`) + }, + { hostId: AUTO_STABLE_HOST_TEST_ID, shadowId: AUTO_STABLE_SHADOW_TEST_ID }, + ) + + const card = cardHandle.asElement() + if (!card) throw new Error('Shadow DOM card was not rendered') + + const metrics = await card.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + return { + className: el.className, + background: style.getPropertyValue('background-color').trim(), + } + }) + + await cardHandle.dispose() + + expect(metrics.className.split(' ')).toContain('knighted-card') + expect(metrics.background).toBe('rgb(15, 23, 42)') + + const tokenHandle = await page.waitForFunction( + ({ hostId, tokenId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(`[data-testid="${tokenId}"]`) + }, + { hostId: AUTO_STABLE_HOST_TEST_ID, tokenId: AUTO_STABLE_TOKEN_TEST_ID }, + ) + + const token = tokenHandle.asElement() + if (!token) throw new Error('Stable selector token was not rendered') + + const tokenText = await token.textContent() + await tokenHandle.dispose() + + expect(tokenText?.trim()).toBe('knighted-card') + }) +}) diff --git a/packages/playwright/webpack.config.js b/packages/playwright/webpack.config.js index add4793..c44c8ea 100644 --- a/packages/playwright/webpack.config.js +++ b/packages/playwright/webpack.config.js @@ -1,6 +1,7 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' +import webpack from 'webpack' import { VanillaExtractPlugin } from '@vanilla-extract/webpack-plugin' const __filename = fileURLToPath(import.meta.url) @@ -84,6 +85,9 @@ export default { }, { loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, }, ], }, @@ -101,6 +105,11 @@ export default { }, ], }, - plugins: [new VanillaExtractPlugin()], + plugins: [ + new VanillaExtractPlugin(), + new webpack.ProvidePlugin({ + React: 'react', + }), + ], devtool: 'source-map', }