diff --git a/.github/workflows/playwright.yml b/.github/workflows/playwright.yml index 86052ec..07979d4 100644 --- a/.github/workflows/playwright.yml +++ b/.github/workflows/playwright.yml @@ -35,3 +35,28 @@ jobs: packages/playwright/playwright-report if-no-files-found: ignore retention-days: 7 + e2e-windows: + runs-on: windows-latest + steps: + - name: Checkout + uses: actions/checkout@v4.2.2 + - name: Setup Node + uses: actions/setup-node@v4.3.0 + with: + node-version: '24.11.1' + - name: Install Dependencies + run: npm ci + - name: Install Playwright Browsers + run: npx playwright install --with-deps chromium webkit + - name: Run Playwright + run: npm run test:e2e + - name: Upload Playwright artifacts + if: always() + uses: actions/upload-artifact@v4.4.3 + with: + name: playwright-artifacts-windows + path: | + packages/playwright/test-results + packages/playwright/playwright-report + if-no-files-found: ignore + retention-days: 7 diff --git a/.gitignore b/.gitignore index 28d5ffd..197a75a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,6 @@ *.tgz node_modules/ -dist/ -dist-webpack/ -dist-auto-stable/ -dist-hashed/ -dist-bridge/ -dist-bridge-webpack/ +dist*/ coverage/ .c8/ .duel-cache/ @@ -14,7 +9,6 @@ coverage/ playwright-report/ test-results/ blob-report/ -.knighted-css/ -.knighted-css-auto/ -.knighted-css-hashed/ +.knighted-css*/ packages/playwright/src/**/*.knighted-css.ts +packages/playwright/src/mode/**/*.d.ts diff --git a/docs/loader.md b/docs/loader.md index 5b6554d..53092db 100644 --- a/docs/loader.md +++ b/docs/loader.md @@ -1,4 +1,4 @@ -# Loader hook (`?knighted-css`) +# Loader hook `@knighted/css/loader` lets bundlers attach compiled CSS strings to any module by appending the `?knighted-css` query when importing. The loader mirrors the module graph, compiles every CSS dialect it discovers (CSS, Sass, Less, vanilla-extract, etc.), and exposes the concatenated result as `knightedCss`. @@ -34,6 +34,66 @@ export default { } ``` +### Choosing a type generation mode + +`knighted-css-generate-types` supports two modes. Both are fully supported and tested; the right choice depends on how explicit you want the imports to be versus how much resolver automation you want to lean on: + +- `--mode module` (double-extension imports): use `.knighted-css` sidecar modules such as + `import stableSelectors from './button.css.knighted-css.js'`. This keeps resolution explicit and tends to be the most stable under large, complex builds. +- `--mode declaration` (idiomatic imports): emit `.d.ts` sidecars next to the original JS/TS module + and keep clean imports like `import { knightedCss } from './button.js'`. This is cleaner at call sites, but it adds resolver work at build time and depends on the resolver plugin to stay in sync. + +If you want the simplest, most transparent build behavior, start with `--mode module`. +If you want cleaner imports and are comfortable with resolver automation, choose `--mode declaration` and enable strict sidecars + a manifest for safety. + +### Resolver plugin (declaration mode) + +When you use `knighted-css-generate-types --mode declaration`, TypeScript expects the +augmented exports to be present on the original JS/TS module. Use the resolver plugin +to automatically append `?knighted-css` for any module import that has a generated +sidecar `.d.ts` file. + +```js +// rspack.config.js +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +export default { + resolve: { + plugins: [knightedCssResolverPlugin()], + }, +} +``` + +```js +// webpack.config.js +const { knightedCssResolverPlugin } = require('@knighted/css/plugin') + +module.exports = { + resolve: { + plugins: [knightedCssResolverPlugin()], + }, +} +``` + +If you use declaration mode, consider enabling strict sidecar detection with a manifest. This +ensures only `.d.ts` files generated by `knighted-css-generate-types` trigger rewrites: + +```js +import path from 'node:path' +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +export default { + resolve: { + plugins: [ + knightedCssResolverPlugin({ + strictSidecar: true, + manifestPath: path.resolve('.knighted-css/knighted-manifest.json'), + }), + ], + }, +} +``` + > [!NOTE] > The loader shares the same auto-configured `oxc-resolver` as the standalone `css()` API, so hash-prefixed specifiers declared under `package.json#imports` (for example, `#ui/button`) resolve without additional options. diff --git a/docs/type-generation.md b/docs/type-generation.md index 8873cfd..3432aad 100644 --- a/docs/type-generation.md +++ b/docs/type-generation.md @@ -29,6 +29,17 @@ Wire it into `postinstall` or your build so new selectors land automatically. - `--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`). +- `--mode` – `module` (default) emits `.knighted-css.ts` proxy modules. `declaration` emits `.d.ts` module augmentations next to the referenced JS/TS modules, so you can keep standard imports like `import { knightedCss } from './button.js'` while the generator still discovers them via `.knighted-css` specifiers. +- `--manifest` – optional path to write a sidecar manifest for declaration mode (recommended when you want strict resolver behavior). + +### Mode quick reference + +| Mode | Import style | Generated files | Bundler resolver plugin | Best for | +| ------------------ | ---------------------------------- | ------------------------------------- | --------------------------------- | -------------------------------------------------- | +| `module` (default) | Double-extension (`.knighted-css`) | `.knighted-css.*` proxy modules | Not required | Maximum transparency and stability in large builds | +| `declaration` | Plain JS/TS imports | `.d.ts` augmentations next to modules | Required (append `?knighted-css`) | Cleaner imports when you accept resolver overhead | + +If you use declaration mode, prefer enabling strict sidecars + a manifest so the resolver only rewrites imports that the CLI generated. ### Relationship to the loader @@ -54,6 +65,53 @@ stableSelectors.card // "knighted-card" knightedCss // compiled CSS string ``` +## Declaration mode (augment existing modules) + +Declaration mode emits `.d.ts` files instead of `.knighted-css.ts` proxies, so you can import directly from the module: + +```sh +knighted-css-generate-types --root . --include src --mode declaration +``` + +```ts +import Button, { knightedCss, stableSelectors } from './button.js' +``` + +> [!IMPORTANT] +> Declaration mode requires a resolver plugin to append `?knighted-css` (and `&combined` when applicable) +> at build time so runtime exports match the generated types. + +### Sidecar manifests + strict resolver mode + +Declaration mode emits `.d.ts` files with a `// @knighted-css` marker. If you want the resolver plugin +to only opt into those explicit sidecars (and avoid accidentally matching unrelated `.d.ts` files), +enable strict mode and pass a manifest created by the CLI: + +```sh +knighted-css-generate-types --root . --include src --mode declaration \ + --manifest .knighted-css/knighted-manifest.json +``` + +```js +import path from 'node:path' +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +export default { + resolve: { + plugins: [ + knightedCssResolverPlugin({ + strictSidecar: true, + manifestPath: path.resolve('.knighted-css/knighted-manifest.json'), + }), + ], + }, +} +``` + +The manifest maps each source module to its generated `.d.ts` path. When `strictSidecar` is enabled, +the plugin only rewrites imports if the sidecar exists **and** includes the marker. That keeps +resolution deterministic even when other tooling generates `.d.ts` files alongside your modules. + ## Hashed selector proxies Use `--hashed` when you want `.knighted-css` proxy modules to export `selectors` backed by diff --git a/package-lock.json b/package-lock.json index a2e2160..6309d5f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11437,7 +11437,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.1.1", + "version": "1.2.0-rc.0", "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.1", + "@knighted/css": "1.2.0-rc.0", "@knighted/jsx": "^1.7.5", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/README.md b/packages/css/README.md index 8e154f3..824380f 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -30,7 +30,7 @@ I needed a single source of truth for UI components that could drop into both li - 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. +- Built-in type generation CLI (`knighted-css-generate-types`) that emits `.knighted-css.*` selector manifests (module mode) or declaration augmentations (declaration mode) so TypeScript stays in lockstep with loader exports. ## Requirements @@ -107,7 +107,7 @@ See [docs/loader.md](../../docs/loader.md) for the full configuration, combined ### Type generation hook (`*.knighted-css*`) -Run `knighted-css-generate-types` so every specifier that ends with `.knighted-css` produces a sibling manifest containing literal selector tokens: +Run `knighted-css-generate-types` so every specifier that ends with `.knighted-css` produces a sibling manifest containing literal selector tokens (module mode, the default): ```ts import stableSelectors from './button.module.scss.knighted-css.js' @@ -142,6 +142,53 @@ selectors.card // hashed CSS Modules class name > include class names that are not exported by the module (e.g. sprinkles output), while the > runtime `selectors` map only includes exported locals from the loader bridge. +Prefer module-level imports without the double extension? Use declaration mode to emit `.d.ts` augmentations next to JS/TS modules that import styles: + +```sh +knighted-css-generate-types --root . --include src --mode declaration +``` + +```ts +import Button, { knightedCss, stableSelectors } from './button.js' +``` + +See [docs/type-generation.md](../../docs/type-generation.md#mode-quick-reference) for a quick comparison of module vs declaration mode tradeoffs. + +> [!IMPORTANT] +> Declaration mode requires a resolver plugin to append `?knighted-css` (and `&combined` when applicable) +> at build time so runtime exports match the generated types. + +Install the resolver plugin via `@knighted/css/plugin` and wire it into your bundler resolver: + +```js +// rspack.config.js +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +export default { + resolve: { + plugins: [knightedCssResolverPlugin()], + }, +} +``` + +If you want the resolver to only match sidecars generated by the CLI, enable strict mode and provide a manifest (written by `knighted-css-generate-types --manifest`): + +```js +import path from 'node:path' +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +export default { + resolve: { + plugins: [ + knightedCssResolverPlugin({ + strictSidecar: true, + manifestPath: path.resolve('.knighted-css/knighted-manifest.json'), + }), + ], + }, +} +``` + 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 1ecf065..0e82898 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.1.1", + "version": "1.2.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", @@ -22,6 +22,9 @@ "generate-types": [ "./dist/generateTypes.d.ts" ], + "plugin": [ + "./dist/plugin.d.ts" + ], "*": [ "./types.d.ts" ] @@ -57,6 +60,11 @@ "import": "./dist/generateTypes.js", "default": "./dist/generateTypes.js" }, + "./plugin": { + "types": "./dist/plugin.d.ts", + "import": "./dist/plugin.js", + "require": "./dist/cjs/plugin.cjs" + }, "./stableSelectors": { "types": "./dist/stableSelectors.d.ts", "import": "./dist/stableSelectors.js", @@ -87,7 +95,6 @@ }, "scripts": { "build": "duel && node ./scripts/copy-types-stub.js", - "pretest": "npm run build", "check-types": "tsc --noEmit --project tsconfig.json && tsc --noEmit --project tsconfig.tests.json", "test": "c8 --reporter=text --reporter=text-summary --reporter=lcov --include \"src/**/*.ts\" tsx --test test/**/*.test.ts", "prepack": "npm run build" diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index e7147c4..2f32344 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -28,6 +28,8 @@ interface ManifestEntry { type SelectorModuleManifest = Record +type SidecarManifest = Record + interface TsconfigResolutionContext { absoluteBaseUrl?: string matchPath?: MatchPath @@ -36,8 +38,11 @@ interface TsconfigResolutionContext { interface SelectorModuleProxyInfo { moduleSpecifier: string includeDefault: boolean + exportedNames?: Set } +export type GenerateTypesMode = 'module' | 'declaration' + type CssWithMetaFn = typeof cssWithMeta let activeCssWithMeta: CssWithMetaFn = cssWithMeta @@ -51,6 +56,8 @@ interface GenerateTypesInternalOptions { hashed?: boolean tsconfig?: TsconfigResolutionContext resolver?: CssResolver + mode: GenerateTypesMode + manifestPath?: string } export interface GenerateTypesResult { @@ -58,6 +65,7 @@ export interface GenerateTypesResult { selectorModulesRemoved: number warnings: string[] manifestPath: string + sidecarManifestPath?: string } export interface GenerateTypesOptions { @@ -68,6 +76,8 @@ export interface GenerateTypesOptions { autoStable?: boolean hashed?: boolean resolver?: CssResolver + mode?: GenerateTypesMode + manifestPath?: string } const DEFAULT_SKIP_DIRS = new Set([ @@ -122,6 +132,7 @@ function getImportMetaUrl(): string | undefined { const SELECTOR_REFERENCE = '.knighted-css' const SELECTOR_MODULE_SUFFIX = '.knighted-css.ts' +const DECLARATION_SUFFIX = '.d.ts' const STYLE_EXTENSIONS = DEFAULT_EXTENSIONS.map(ext => ext.toLowerCase()) const SCRIPT_EXTENSIONS = Array.from(SUPPORTED_EXTENSIONS) const RESOLUTION_EXTENSIONS = Array.from( @@ -134,6 +145,9 @@ const EXTENSION_FALLBACKS: Record = { '.jsx': ['.tsx', '.jsx'], } +const DECLARATION_MODE_WARNING = + 'Declaration mode requires a resolver plugin to append ?knighted-css (and &combined when applicable) so runtime exports match the generated types.' + export async function generateTypes( options: GenerateTypesOptions = {}, ): Promise { @@ -141,6 +155,10 @@ export async function generateTypes( const include = normalizeIncludeOptions(options.include, rootDir) const cacheDir = path.resolve(options.outDir ?? path.join(rootDir, '.knighted-css')) const tsconfig = loadTsconfigResolutionContext(rootDir) + const mode = options.mode ?? 'module' + const manifestPath = options.manifestPath + ? path.resolve(rootDir, options.manifestPath) + : undefined await fs.mkdir(cacheDir, { recursive: true }) @@ -153,6 +171,8 @@ export async function generateTypes( hashed: options.hashed, tsconfig, resolver: options.resolver, + mode, + manifestPath, } return generateDeclarations(internalOptions) @@ -179,102 +199,218 @@ async function generateDeclarations( const selectorModulesManifestPath = path.join(options.cacheDir, 'selector-modules.json') const previousSelectorManifest = await readManifest(selectorModulesManifestPath) const nextSelectorManifest: SelectorModuleManifest = {} + const sidecarManifest: SidecarManifest = {} const selectorCache = new Map>() const processedSelectors = new Set() const proxyInfoCache = new Map() const warnings: string[] = [] let selectorModuleWrites = 0 - for (const filePath of files) { - const matches = await findSpecifierImports(filePath) - for (const match of matches) { - const cleaned = match.specifier.trim() - const inlineFree = stripInlineLoader(cleaned) - const { resource } = splitResourceAndQuery(inlineFree) - const selectorSource = extractSelectorSourceSpecifier(resource) - if (!selectorSource) { + if (options.mode === 'declaration') { + warnings.push(DECLARATION_MODE_WARNING) + } + + if (options.mode === 'declaration') { + for (const filePath of files) { + if (!isScriptResource(filePath)) { continue } - const resolvedNamespace = resolveStableNamespace(options.stableNamespace) - const resolvedPath = await resolveImportPath( - selectorSource, - match.importer, - options.rootDir, - options.tsconfig, - options.resolver, - resolverFactory, - RESOLUTION_EXTENSIONS, - ) - if (!resolvedPath) { + if (!isWithinRoot(filePath, options.rootDir)) { warnings.push( - `Unable to resolve ${selectorSource} referenced by ${relativeToRoot(match.importer, options.rootDir)}.`, + `Skipping declaration output for ${relativeToRoot(filePath, options.rootDir)} because it is outside the project root.`, ) continue } + const manifestKey = buildSelectorModuleManifestKey(filePath) + if (processedSelectors.has(manifestKey)) { + continue + } + + const hasStyles = await hasStyleImports(filePath, { + rootDir: options.rootDir, + tsconfig: options.tsconfig, + resolver: options.resolver, + resolverFactory, + }) + if (!hasStyles) { + processedSelectors.add(manifestKey) + continue + } - const cacheKey = `${resolvedPath}::${resolvedNamespace}` + const resolvedNamespace = resolveStableNamespace(options.stableNamespace) + const cacheKey = `${filePath}::${resolvedNamespace}::declaration` let selectorMap = selectorCache.get(cacheKey) if (!selectorMap) { try { - const shouldUseCssModules = resolvedPath.endsWith('.module.css') - const { css } = await activeCssWithMeta(resolvedPath, { + let cssResult = await activeCssWithMeta(filePath, { cwd: options.rootDir, peerResolver, autoStable: options.autoStable ? { namespace: resolvedNamespace } : undefined, - lightningcss: - options.autoStable && shouldUseCssModules - ? { cssModules: true } - : undefined, resolver: options.resolver, }) + + if (cssResult.files.length === 0 || cssResult.css.trim().length === 0) { + processedSelectors.add(manifestKey) + continue + } + + if ( + options.autoStable && + cssResult.files.some(file => isCssModuleResource(file)) + ) { + cssResult = await activeCssWithMeta(filePath, { + cwd: options.rootDir, + peerResolver, + autoStable: options.autoStable + ? { namespace: resolvedNamespace } + : undefined, + lightningcss: { cssModules: true }, + resolver: options.resolver, + }) + } + selectorMap = options.hashed - ? collectSelectorTokensFromCss(css) + ? collectSelectorTokensFromCss(cssResult.css) : buildStableSelectorsLiteral({ - css, + css: cssResult.css, namespace: resolvedNamespace, - resourcePath: resolvedPath, + resourcePath: filePath, emitWarning: message => warnings.push(message), }).selectorMap } catch (error) { warnings.push( - `Failed to extract CSS for ${relativeToRoot(resolvedPath, options.rootDir)}: ${formatErrorMessage(error)}`, + `Failed to extract CSS for ${relativeToRoot(filePath, options.rootDir)}: ${formatErrorMessage(error)}`, ) + processedSelectors.add(manifestKey) continue } selectorCache.set(cacheKey, selectorMap) } - if (!isWithinRoot(resolvedPath, options.rootDir)) { - warnings.push( - `Skipping selector module for ${relativeToRoot(resolvedPath, options.rootDir)} because it is outside the project root.`, - ) + if (!selectorMap || selectorMap.size === 0) { + processedSelectors.add(manifestKey) continue } - const manifestKey = buildSelectorModuleManifestKey(resolvedPath) - if (processedSelectors.has(manifestKey)) { - continue - } - const proxyInfo = await resolveProxyInfo( + const proxyInfo = await resolveDeclarationProxyInfo( manifestKey, - selectorSource, - resolvedPath, + filePath, proxyInfoCache, ) - const moduleWrite = await ensureSelectorModule( - resolvedPath, + if (!proxyInfo) { + processedSelectors.add(manifestKey) + continue + } + const moduleWrite = await ensureDeclarationModule( + filePath, selectorMap, previousSelectorManifest, nextSelectorManifest, - selectorSource, - proxyInfo ?? undefined, + proxyInfo, options.hashed ?? false, ) + if (options.manifestPath) { + sidecarManifest[manifestKey] = { file: buildDeclarationPath(filePath) } + } if (moduleWrite) { selectorModuleWrites += 1 } processedSelectors.add(manifestKey) } + } else { + for (const filePath of files) { + const matches = await findSpecifierImports(filePath) + for (const match of matches) { + const cleaned = match.specifier.trim() + const inlineFree = stripInlineLoader(cleaned) + const { resource } = splitResourceAndQuery(inlineFree) + const selectorSource = extractSelectorSourceSpecifier(resource) + if (!selectorSource) { + continue + } + const resolvedNamespace = resolveStableNamespace(options.stableNamespace) + const resolvedPath = await resolveImportPath( + selectorSource, + match.importer, + options.rootDir, + options.tsconfig, + options.resolver, + resolverFactory, + RESOLUTION_EXTENSIONS, + ) + if (!resolvedPath) { + warnings.push( + `Unable to resolve ${selectorSource} referenced by ${relativeToRoot(match.importer, options.rootDir)}.`, + ) + continue + } + + const cacheKey = `${resolvedPath}::${resolvedNamespace}` + 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, + resolver: options.resolver, + }) + 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)}`, + ) + continue + } + selectorCache.set(cacheKey, selectorMap) + } + + if (!isWithinRoot(resolvedPath, options.rootDir)) { + warnings.push( + `Skipping selector module for ${relativeToRoot(resolvedPath, options.rootDir)} because it is outside the project root.`, + ) + continue + } + + const manifestKey = buildSelectorModuleManifestKey(resolvedPath) + if (processedSelectors.has(manifestKey)) { + continue + } + const proxyInfo = await resolveProxyInfo( + manifestKey, + selectorSource, + resolvedPath, + proxyInfoCache, + ) + const moduleWrite = await ensureSelectorModule( + resolvedPath, + selectorMap, + previousSelectorManifest, + nextSelectorManifest, + selectorSource, + proxyInfo ?? undefined, + options.hashed ?? false, + ) + if (moduleWrite) { + selectorModuleWrites += 1 + } + processedSelectors.add(manifestKey) + } + } } const selectorModulesRemoved = await removeStaleSelectorModules( @@ -282,12 +418,17 @@ async function generateDeclarations( nextSelectorManifest, ) await writeManifest(selectorModulesManifestPath, nextSelectorManifest) + if (options.manifestPath && options.mode === 'declaration') { + await writeSidecarManifest(options.manifestPath, sidecarManifest) + } return { selectorModulesWritten: selectorModuleWrites, selectorModulesRemoved, warnings, manifestPath: selectorModulesManifestPath, + sidecarManifestPath: + options.mode === 'declaration' ? options.manifestPath : undefined, } } @@ -505,6 +646,61 @@ function buildSelectorModulePath(resolvedPath: string): string { return `${base}${SELECTOR_MODULE_SUFFIX}` } +function buildDeclarationModuleSpecifier(resolvedPath: string): string { + const ext = path.extname(resolvedPath).toLowerCase() + const baseName = path.basename(resolvedPath, ext) + const mappedExt = + ext === '.mjs' || ext === '.mts' + ? '.mjs' + : ext === '.cjs' || ext === '.cts' + ? '.cjs' + : '.js' + return `./${baseName}${mappedExt}` +} + +function buildDeclarationPath(resolvedPath: string): string { + if (resolvedPath.endsWith(DECLARATION_SUFFIX)) { + return resolvedPath + } + return `${resolvedPath}${DECLARATION_SUFFIX}` +} + +function formatSelectorTypeLiteral(selectors: Map): string { + const entries = Array.from(selectors.keys()).sort((a, b) => a.localeCompare(b)) + const typeLines = entries.map(token => ` readonly ${JSON.stringify(token)}: string`) + return typeLines.length > 0 + ? `{ +${typeLines.join('\n')} + }` + : 'Record' +} + +function formatDeclarationSource( + selectors: Map, + proxyInfo: SelectorModuleProxyInfo, + options: { + hashed?: boolean + } = {}, +): string { + const header = '// Generated by @knighted/css/generate-types\n// Do not edit.' + const isHashed = options.hashed === true + const marker = isHashed ? '// @knighted-css:hashed' : '// @knighted-css' + const exportName = isHashed ? 'selectors' : 'stableSelectors' + const typeLiteral = formatSelectorTypeLiteral(selectors) + const shouldEmit = (name: string) => !proxyInfo.exportedNames?.has(name) + const lines = [ + header, + marker, + '', + `declare module '${proxyInfo.moduleSpecifier}' {`, + shouldEmit('knightedCss') ? ' export const knightedCss: string' : '', + shouldEmit(exportName) ? ` export const ${exportName}: ${typeLiteral}` : '', + '}', + 'export {}', + ].filter(Boolean) + return `${lines.join('\n')}\n` +} + function formatSelectorModuleSource( selectors: Map, proxyInfo?: SelectorModuleProxyInfo, @@ -611,6 +807,14 @@ async function writeManifest( await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8') } +async function writeSidecarManifest( + manifestPath: string, + manifest: SidecarManifest, +): Promise { + await fs.mkdir(path.dirname(manifestPath), { recursive: true }) + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8') +} + async function removeStaleSelectorModules( previous: SelectorModuleManifest, next: SelectorModuleManifest, @@ -670,6 +874,27 @@ async function ensureSelectorModule( return needsWrite } +async function ensureDeclarationModule( + resolvedPath: string, + selectors: Map, + previousManifest: SelectorModuleManifest, + nextManifest: SelectorModuleManifest, + proxyInfo: SelectorModuleProxyInfo, + hashed?: boolean, +): Promise { + const manifestKey = buildSelectorModuleManifestKey(resolvedPath) + const targetPath = buildDeclarationPath(resolvedPath) + const source = formatDeclarationSource(selectors, proxyInfo, { hashed }) + const hash = hashContent(source) + const previousEntry = previousManifest[manifestKey] + const needsWrite = previousEntry?.hash !== hash || !(await fileExists(targetPath)) + if (needsWrite) { + await fs.writeFile(targetPath, source, 'utf8') + } + nextManifest[manifestKey] = { file: targetPath, hash } + return needsWrite +} + async function fileExists(target: string): Promise { try { await fs.access(target) @@ -836,6 +1061,81 @@ function isStyleResource(filePath: string): boolean { return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext)) } +function isCssModuleResource(filePath: string): boolean { + return /\.module\.(css|scss|sass|less)$/i.test(filePath) +} + +function isScriptResource(filePath: string): boolean { + const normalized = filePath.toLowerCase() + if (normalized.endsWith('.d.ts')) { + return false + } + return SCRIPT_EXTENSIONS.some(ext => normalized.endsWith(ext)) +} + +async function hasStyleImports( + filePath: string, + options: { + rootDir: string + tsconfig?: TsconfigResolutionContext + resolver?: CssResolver + resolverFactory?: ReturnType + }, +): Promise { + let source: string + try { + source = await fs.readFile(filePath, 'utf8') + } catch { + return false + } + + const candidates = new Set() + try { + const analysis = await analyzeModule(source, filePath) + for (const specifier of analysis.imports) { + if (specifier) { + candidates.add(specifier) + } + } + } catch { + // fall back to regex scanning below + } + + const importRegex = /(import|require)\s*(?:\(|[^'"`]*)(['"])([^'"`]+)\2/g + let match: RegExpExecArray | null + while ((match = importRegex.exec(source)) !== null) { + const specifier = match[3] + if (specifier) { + candidates.add(specifier) + } + } + + for (const specifier of candidates) { + const cleaned = stripInlineLoader(specifier.trim()) + const { resource } = splitResourceAndQuery(cleaned) + if (!resource) { + continue + } + if (isStyleResource(resource)) { + return true + } + const resolved = await resolveImportPath( + resource, + filePath, + options.rootDir, + options.tsconfig, + options.resolver, + options.resolverFactory, + RESOLUTION_EXTENSIONS, + ) + if (resolved && isStyleResource(resolved)) { + return true + } + } + + return false +} + function collectSelectorTokensFromCss(css: string): Map { const tokens = new Set() const pattern = /\.([A-Za-z_-][A-Za-z0-9_-]*)\b/g @@ -875,6 +1175,26 @@ async function resolveProxyInfo( return proxyInfo } +async function resolveDeclarationProxyInfo( + manifestKey: string, + resolvedPath: string, + cache: Map, +): Promise { + const cached = cache.get(manifestKey) + if (cached !== undefined) { + return cached + } + const defaultSignal = await getDefaultExportSignal(resolvedPath) + const exportedNames = await getNamedExports(resolvedPath) + const proxyInfo = { + moduleSpecifier: buildDeclarationModuleSpecifier(resolvedPath), + includeDefault: defaultSignal === 'has-default', + exportedNames, + } + cache.set(manifestKey, proxyInfo) + return proxyInfo +} + function buildProxyModuleSpecifier(resolvedPath: string, selectorSource: string): string { const resolvedExt = path.extname(resolvedPath) const baseName = path.basename(resolvedPath, resolvedExt) @@ -893,6 +1213,16 @@ async function getDefaultExportSignal(filePath: string): Promise> { + try { + const source = await fs.readFile(filePath, 'utf8') + const analysis = await analyzeModule(source, filePath) + return new Set(analysis.exports ?? []) + } catch { + return new Set() + } +} + function createProjectPeerResolver(rootDir: string) { const resolver = getProjectRequire(rootDir) return async (name: string) => { @@ -973,6 +1303,8 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise autoStable: parsed.autoStable, hashed: parsed.hashed, resolver, + mode: parsed.mode, + manifestPath: parsed.manifestPath, }) reportCliResult(result) } catch (error) { @@ -990,6 +1322,8 @@ export interface ParsedCliArgs { autoStable?: boolean hashed?: boolean resolver?: string + mode: GenerateTypesMode + manifestPath?: string help?: boolean } @@ -1001,11 +1335,21 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { let autoStable = false let hashed = false let resolver: string | undefined + let mode: GenerateTypesMode = 'module' + let manifestPath: string | undefined for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] if (arg === '--help' || arg === '-h') { - return { rootDir, include, outDir, stableNamespace, autoStable, help: true } + return { + rootDir, + include, + outDir, + stableNamespace, + autoStable, + mode, + help: true, + } } if (arg === '--auto-stable') { autoStable = true @@ -1039,6 +1383,14 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { outDir = value continue } + if (arg === '--manifest') { + const value = argv[++i] + if (!value) { + throw new Error('Missing value for --manifest') + } + manifestPath = value + continue + } if (arg === '--stable-namespace') { const value = argv[++i] if (!value) { @@ -1055,6 +1407,17 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { resolver = value continue } + if (arg === '--mode') { + const value = argv[++i] + if (!value) { + throw new Error('Missing value for --mode') + } + if (value !== 'module' && value !== 'declaration') { + throw new Error(`Unknown mode: ${value}`) + } + mode = value + continue + } if (arg.startsWith('-')) { throw new Error(`Unknown flag: ${arg}`) } @@ -1064,22 +1427,37 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { if (autoStable && hashed) { throw new Error('Cannot combine --auto-stable with --hashed') } + if (manifestPath && mode !== 'declaration') { + throw new Error('Cannot use --manifest unless --mode is declaration') + } - return { rootDir, include, outDir, stableNamespace, autoStable, hashed, resolver } + return { + rootDir, + include, + outDir, + stableNamespace, + autoStable, + hashed, + resolver, + mode, + manifestPath, + } } function printHelp(): void { console.log(`Usage: knighted-css-generate-types [options] Options: - -r, --root Project root directory (default: cwd) - -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 - --hashed Emit selectors backed by loader-bridge hashed modules - --resolver Path or package name exporting a CssResolver - -h, --help Show this help message + -r, --root Project root directory (default: cwd) + -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 + --hashed Emit selectors backed by loader-bridge hashed modules + --resolver Path or package name exporting a CssResolver + --mode Emit selector modules (module) or declaration files (declaration) + --manifest Write a sidecar manifest (declaration mode only) + -h, --help Show this help message `) } @@ -1092,6 +1470,9 @@ function reportCliResult(result: GenerateTypesResult): void { ) } console.log(`[knighted-css] Manifest: ${result.manifestPath}`) + if (result.sidecarManifestPath) { + console.log(`[knighted-css] Sidecar manifest: ${result.sidecarManifestPath}`) + } for (const warning of result.warnings) { console.warn(`[knighted-css] ${warning}`) } @@ -1125,22 +1506,30 @@ export const __generateTypesInternals = { setImportMetaUrlProvider, isNonRelativeSpecifier, isStyleResource, + isCssModuleResource, resolveProxyInfo, + resolveDeclarationProxyInfo, resolveWithExtensionFallback, resolveIndexFallback, createProjectPeerResolver, getProjectRequire, loadTsconfigResolutionContext, resolveWithTsconfigPaths, + hasStyleImports, loadResolverModule, parseCliArgs, printHelp, reportCliResult, buildSelectorModuleManifestKey, buildSelectorModulePath, + buildDeclarationModuleSpecifier, formatSelectorModuleSource, + buildDeclarationPath, + formatDeclarationSource, + ensureDeclarationModule, ensureSelectorModule, removeStaleSelectorModules, readManifest, writeManifest, + writeSidecarManifest, } diff --git a/packages/css/src/lexer.ts b/packages/css/src/lexer.ts index 9e2be7f..a641079 100644 --- a/packages/css/src/lexer.ts +++ b/packages/css/src/lexer.ts @@ -21,6 +21,7 @@ interface AnalyzeOptions { interface ModuleAnalysis { imports: string[] defaultSignal: DefaultExportSignal + exports: string[] } const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']) @@ -44,6 +45,7 @@ export async function analyzeModule( return { imports: normalizeEsImports(imports, sourceText), defaultSignal: classifyDefault(exports), + exports: normalizeEsExports(exports), } } catch { // fall through to oxc fallback @@ -81,6 +83,13 @@ function classifyDefault( return 'no-default' } +function normalizeEsExports(exports: readonly { n: string | undefined }[]): string[] { + const names = exports + .map(entry => entry.n) + .filter((name): name is string => Boolean(name) && name !== 'default') + return Array.from(new Set(names)).sort() +} + function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { const ext = path.extname(filePath).toLowerCase() const attempts: Array<{ path: string; sourceType: 'module' | 'unambiguous' }> = [ @@ -104,10 +113,11 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { } if (!program) { - return { imports: [], defaultSignal: 'unknown' } + return { imports: [], defaultSignal: 'unknown', exports: [] } } const imports: string[] = [] + const exportedNames = new Set() let defaultSignal: DefaultExportSignal = 'unknown' const addSpecifier = (raw?: string | null) => { if (!raw) { @@ -124,9 +134,25 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { addSpecifier(node.source?.value) }, ExportNamedDeclaration(node: ExportNamedDeclaration) { + if (node.exportKind === 'type') { + return + } if (node.source) { addSpecifier(node.source.value) } + if (node.declaration) { + addExportedDeclarationNames(node.declaration, name => { + exportedNames.add(name) + }) + } + if (node.specifiers) { + for (const specifier of node.specifiers) { + const exportedName = getExportedName(specifier) + if (exportedName && exportedName !== 'default') { + exportedNames.add(exportedName) + } + } + } if (hasDefaultSpecifier(node)) { defaultSignal = 'has-default' } else if (defaultSignal === 'unknown' && hasAnySpecifier(node)) { @@ -135,6 +161,10 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { }, ExportAllDeclaration(node: ExportAllDeclaration) { addSpecifier(node.source?.value) + const exportedName = getExportedName(node.exported) + if (exportedName && exportedName !== 'default') { + exportedNames.add(exportedName) + } if (node.exported && isExportedAsDefault(node.exported)) { defaultSignal = 'has-default' } @@ -172,7 +202,52 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { visitor.visit(program) - return { imports, defaultSignal } + return { imports, defaultSignal, exports: Array.from(exportedNames).sort() } +} + +function addExportedDeclarationNames( + declaration: ExportNamedDeclaration['declaration'], + add: (name: string) => void, +): void { + if (!declaration) { + return + } + switch (declaration.type) { + case 'VariableDeclaration': + for (const declarator of declaration.declarations ?? []) { + if (declarator.id?.type === 'Identifier' && declarator.id.name) { + add(declarator.id.name) + } + } + break + case 'FunctionDeclaration': + case 'ClassDeclaration': + if (declaration.id?.name) { + add(declaration.id.name) + } + break + default: + break + } +} + +function getExportedName( + exported: + | { name?: string; value?: string } + | { type?: string; name?: string; value?: string } + | null + | undefined, +): string | undefined { + if (!exported) { + return undefined + } + if (typeof exported.name === 'string') { + return exported.name + } + if (typeof exported.value === 'string') { + return exported.value + } + return undefined } function normalizeSpecifier(raw: string): string { diff --git a/packages/css/src/plugin.ts b/packages/css/src/plugin.ts new file mode 100644 index 0000000..e328de1 --- /dev/null +++ b/packages/css/src/plugin.ts @@ -0,0 +1,726 @@ +import fs from 'node:fs/promises' +import path from 'node:path' + +import { createResolverFactory, resolveWithFactory } from './moduleResolution.js' + +const KNIGHTED_CSS_QUERY = 'knighted-css' + +const SCRIPT_EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts', '.mjs', '.cjs'] + +export interface KnightedCssResolverPluginOptions { + rootDir?: string + tsconfig?: string | Record + conditions?: string[] + extensions?: string[] + debug?: boolean + combinedPaths?: Array + strictSidecar?: boolean + manifestPath?: string +} + +interface ResolveRequest { + request?: string + path?: string + context?: { + issuer?: string + path?: string + } + __knightedCssAugmented?: boolean + __knightedCssResolve?: boolean +} + +interface ResolveContext { + log?: (message: string) => void +} + +interface ResolverHook { + tapAsync( + name: string, + callback: ( + request: ResolveRequest, + resolveContext: ResolveContext, + callback: (error?: Error | null, result?: unknown) => void, + ) => void, + ): void +} + +interface ResolverLike { + getHook(name: string): ResolverHook + doResolve( + hook: ResolverHook, + request: ResolveRequest, + message: string, + resolveContext: ResolveContext, + callback: (error?: Error | null, result?: unknown) => void, + ): void +} + +interface ResolverFactoryHook { + tap(name: string, callback: (resolver: ResolverLike) => void): void +} + +interface ResolverFactoryLike { + hooks?: { + resolver?: { + for(name: string): ResolverFactoryHook + } + } +} + +interface CompilerLike { + resolverFactory?: ResolverFactoryLike + getResolver?: (type: string) => unknown + inputFileSystem?: FileSystemLike + hooks?: { + normalModuleFactory?: NormalModuleFactoryHook + invalid?: { tap(name: string, callback: (fileName?: string) => void): void } + watchRun?: { tap(name: string, callback: () => void): void } + done?: { tap(name: string, callback: () => void): void } + } +} + +interface NormalModuleFactoryHook { + tap(name: string, callback: (factory: NormalModuleFactoryLike) => void): void +} + +interface NormalModuleFactoryLike { + hooks?: { + beforeResolve?: NormalModuleFactoryBeforeResolveHook + } +} + +interface NormalModuleFactoryBeforeResolveHook { + tapAsync( + name: string, + callback: ( + data: NormalModuleResolveData, + callback: (error?: Error | null, result?: unknown) => void, + ) => void, + ): void +} + +interface NormalModuleResolveData { + request?: string + context?: string + contextInfo?: { + issuer?: string + } +} + +type FileSystemLike = { + readFile?: ( + path: string, + callback: (error: NodeJS.ErrnoException | null, data?: Buffer) => void, + ) => void + stat?: (path: string, callback: (error?: NodeJS.ErrnoException | null) => void) => void +} + +function splitResourceAndQuery(specifier: string): { resource: string; query: string } { + const hashOffset = specifier.startsWith('#') ? 1 : 0 + const hashIndex = specifier.indexOf('#', hashOffset) + const trimmed = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier + const queryIndex = trimmed.indexOf('?') + if (queryIndex < 0) { + return { resource: trimmed, query: '' } + } + return { resource: trimmed.slice(0, queryIndex), query: trimmed.slice(queryIndex) } +} + +function hasKnightedCssQuery(query: string): boolean { + return /(?:^|[&?])knighted-css(?:=|&|$)/.test(query) +} + +function hasCombinedQuery(query: string): boolean { + return /(?:^|[&?])combined(?:=|&|$)/.test(query) +} + +function appendQueryFlag(query: string, flag: string): string { + if (!query) { + return `?${flag}` + } + return `${query}&${flag}` +} + +function stripExtension(filePath: string): string { + const ext = path.extname(filePath) + return ext ? filePath.slice(0, -ext.length) : filePath +} + +function isWithinRoot(filePath: string, rootDir: string): boolean { + const relative = path.relative(rootDir, filePath) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +function isScriptResource(filePath: string): boolean { + const normalized = filePath.toLowerCase() + if (normalized.endsWith('.d.ts')) { + return false + } + return SCRIPT_EXTENSIONS.some(ext => normalized.endsWith(ext)) +} + +function getImporterPath(request: ResolveRequest, fallback: string): string { + return request.context?.issuer || request.context?.path || request.path || fallback +} + +function buildSidecarPath(resolvedPath: string): string { + return `${resolvedPath}.d.ts` +} + +function isNodeModulesPath(filePath: string): boolean { + return filePath.split(path.sep).includes('node_modules') +} + +export class KnightedCssResolverPlugin { + private readonly rootDir: string + private readonly resolverFactory + private readonly extensions: string[] + private readonly debug: boolean + private readonly combinedPaths: Array + private readonly strictSidecar: boolean + private readonly manifestPath?: string + private readonly sidecarCache = new Map< + string, + { path: string; hasMarker: boolean } | null + >() + private readonly diagnostics = { + rewrites: 0, + cacheHits: 0, + markerMisses: 0, + manifestMisses: 0, + } + private fileSystem?: FileSystemLike + private inputFileSystem?: FileSystemLike + private manifestCache?: Map + private compilerResolver?: ResolverLike + + constructor(options: KnightedCssResolverPluginOptions = {}) { + this.rootDir = path.resolve(options.rootDir ?? process.cwd()) + this.extensions = options.extensions ?? SCRIPT_EXTENSIONS + this.debug = Boolean(options.debug) + this.combinedPaths = options.combinedPaths ?? [] + this.strictSidecar = + options.strictSidecar === undefined + ? Boolean(options.manifestPath) + : options.strictSidecar + this.manifestPath = options.manifestPath + this.resolverFactory = createResolverFactory( + this.rootDir, + this.extensions, + SCRIPT_EXTENSIONS, + { + conditions: options.conditions, + tsconfig: options.tsconfig, + }, + ) + } + + apply(target: ResolverLike | CompilerLike) { + if (this.isResolver(target)) { + this.applyToResolver(target) + return + } + + this.applyToCompiler(target) + } + + private isResolver(target: ResolverLike | CompilerLike): target is ResolverLike { + return typeof (target as ResolverLike).getHook === 'function' + } + + private applyToResolver(resolver: ResolverLike) { + if ('fileSystem' in resolver) { + this.fileSystem = ( + resolver as ResolverLike & { fileSystem?: FileSystemLike } + ).fileSystem + } + this.compilerResolver = resolver + if (this.debug) { + // eslint-disable-next-line no-console + console.log('knighted-css: resolver plugin enabled') + } + const handler = ( + request: ResolveRequest, + ctx: ResolveContext, + callback: (error?: Error | null, result?: unknown) => void, + ) => { + void this.handleResolve(resolver, request, ctx, callback) + } + resolver.getHook('before-resolve').tapAsync('KnightedCssResolverPlugin', handler) + resolver.getHook('resolve').tapAsync('KnightedCssResolverPlugin', handler) + } + + private applyToCompiler(compiler: CompilerLike) { + this.inputFileSystem = compiler.inputFileSystem + compiler.hooks?.invalid?.tap('KnightedCssResolverPlugin', () => { + this.sidecarCache.clear() + this.manifestCache = undefined + this.resetDiagnostics() + }) + compiler.hooks?.watchRun?.tap('KnightedCssResolverPlugin', () => { + this.sidecarCache.clear() + this.manifestCache = undefined + this.resetDiagnostics() + }) + compiler.hooks?.done?.tap('KnightedCssResolverPlugin', () => { + this.flushDiagnostics() + }) + const resolver = compiler.getResolver?.('normal') + if (resolver && this.isResolver(resolver as ResolverLike)) { + this.compilerResolver = resolver as ResolverLike + this.applyToResolver(resolver as ResolverLike) + return + } + + const resolverHook = compiler.resolverFactory?.hooks?.resolver?.for('normal') + if (!resolverHook) { + const normalModuleFactory = compiler.hooks?.normalModuleFactory + if (!normalModuleFactory) { + return + } + + normalModuleFactory.tap('KnightedCssResolverPlugin', factory => { + const beforeResolve = factory.hooks?.beforeResolve + if (!beforeResolve) { + return + } + + beforeResolve.tapAsync( + 'KnightedCssResolverPlugin', + (data: NormalModuleResolveData, callback) => { + void this.handleModuleFactoryResolve(data, callback) + }, + ) + }) + + return + } + + resolverHook.tap('KnightedCssResolverPlugin', resolver => + this.applyToResolver(resolver), + ) + } + + private log(resolveContext: ResolveContext, message: string) { + if (!this.debug) { + return + } + + if (resolveContext.log) { + resolveContext.log(message) + return + } + + // eslint-disable-next-line no-console + console.log(message) + } + + private logWithoutContext(message: string) { + if (!this.debug) { + return + } + + // eslint-disable-next-line no-console + console.log(message) + } + + private resetDiagnostics(): void { + this.diagnostics.rewrites = 0 + this.diagnostics.cacheHits = 0 + this.diagnostics.markerMisses = 0 + this.diagnostics.manifestMisses = 0 + } + + private flushDiagnostics(): void { + if (!this.debug) { + return + } + const { rewrites, cacheHits, markerMisses, manifestMisses } = this.diagnostics + if (rewrites + cacheHits + markerMisses + manifestMisses === 0) { + return + } + // eslint-disable-next-line no-console + console.log( + `knighted-css: summary rewrites=${rewrites} cacheHits=${cacheHits} manifestMisses=${manifestMisses} markerMisses=${markerMisses}`, + ) + this.resetDiagnostics() + } + + private async readFileFromFs(filePath: string): Promise { + const fsHandle = this.fileSystem ?? this.inputFileSystem + if (fsHandle?.readFile) { + return new Promise(resolve => { + fsHandle.readFile?.(filePath, (error, data) => { + if (error || !data) { + resolve(null) + return + } + resolve(data) + }) + }) + } + + try { + const data = await fs.readFile(filePath) + return data + } catch { + return null + } + } + + private async fileExistsFromFs(filePath: string): Promise { + const fsHandle = this.fileSystem ?? this.inputFileSystem + if (fsHandle?.stat) { + return new Promise(resolve => { + fsHandle.stat?.(filePath, error => resolve(!error)) + }) + } + + try { + await fs.access(filePath) + return true + } catch { + return false + } + } + + private buildSidecarCandidates(resolvedPath: string): string[] { + if (resolvedPath.endsWith('.d.ts')) { + return [resolvedPath] + } + const candidates = new Set() + candidates.add(`${resolvedPath}.d.ts`) + candidates.add(`${stripExtension(resolvedPath)}.d.ts`) + return Array.from(candidates) + } + + private async loadManifest(): Promise | null> { + if (!this.manifestPath) { + return null + } + if (this.manifestCache) { + return this.manifestCache + } + const data = await this.readFileFromFs(this.manifestPath) + if (!data) { + return null + } + try { + const parsed = JSON.parse(data.toString('utf8')) as Record< + string, + { file?: string } + > + const map = new Map() + for (const [key, entry] of Object.entries(parsed)) { + if (entry?.file) { + map.set(key, entry.file) + } + } + this.manifestCache = map + return map + } catch { + return null + } + } + + private async resolveSidecarInfo( + resolvedPath: string, + logger?: (message: string) => void, + ): Promise<{ path: string; hasMarker: boolean } | null> { + const cached = this.sidecarCache.get(resolvedPath) + if (cached !== undefined) { + this.diagnostics.cacheHits += 1 + return cached + } + + const manifest = await this.loadManifest() + const manifestKey = resolvedPath.split(path.sep).join('/') + const manifestPath = manifest?.get(manifestKey) + if (this.strictSidecar && this.manifestPath && !manifestPath) { + this.diagnostics.manifestMisses += 1 + logger?.(`knighted-css: skip ${resolvedPath} (manifest miss)`) + this.sidecarCache.set(resolvedPath, null) + return null + } + const candidates = manifestPath + ? [manifestPath] + : this.buildSidecarCandidates(resolvedPath) + + for (const candidate of candidates) { + if (!(await this.fileExistsFromFs(candidate))) { + continue + } + if (!this.strictSidecar) { + const info = { path: candidate, hasMarker: true } + this.sidecarCache.set(resolvedPath, info) + return info + } + const head = await this.readFileFromFs(candidate) + const snippet = head ? head.toString('utf8', 0, 128) : '' + const hasMarker = snippet.includes('@knighted-css') + if (hasMarker) { + const info = { path: candidate, hasMarker: true } + this.sidecarCache.set(resolvedPath, info) + return info + } + this.diagnostics.markerMisses += 1 + logger?.( + `knighted-css: skip ${resolvedPath} (sidecar missing marker at ${candidate})`, + ) + } + + this.sidecarCache.set(resolvedPath, null) + return null + } + + private async resolveWithCompiler( + resolver: ResolverLike, + specifier: string, + importer: string, + ): Promise { + return new Promise(resolve => { + const request: ResolveRequest = { + request: specifier, + path: importer, + context: { issuer: importer }, + __knightedCssResolve: true, + } + resolver.doResolve( + resolver.getHook('resolve'), + request, + 'knighted-css: resolve candidate', + {}, + (error, result) => { + if (error || !result || typeof result !== 'object') { + resolve(undefined) + return + } + const resolved = (result as { path?: string }).path + if (typeof resolved === 'string') { + resolve(resolved) + return + } + const resource = (result as { resource?: string }).resource + resolve(typeof resource === 'string' ? resource : undefined) + }, + ) + }) + } + + private async resolveResource( + resolver: ResolverLike | undefined, + resource: string, + importer: string, + ): Promise { + if (resolver) { + return this.resolveWithCompiler(resolver, resource, importer) + } + return resolveWithFactory(this.resolverFactory, resource, importer, this.extensions) + } + + private async handleModuleFactoryResolve( + data: NormalModuleResolveData, + callback: (error?: Error | null, result?: unknown) => void, + ): Promise { + if (!data?.request || typeof data.request !== 'string') { + callback(null, true) + return + } + + this.logWithoutContext(`knighted-css: inspect ${data.request}`) + + const { resource, query } = splitResourceAndQuery(data.request) + if ( + !resource || + hasKnightedCssQuery(query) || + hasCombinedQuery(query) || + (data.contextInfo?.issuer && + hasKnightedCssQuery(splitResourceAndQuery(data.contextInfo.issuer).query)) || + (data.contextInfo?.issuer && + hasCombinedQuery(splitResourceAndQuery(data.contextInfo.issuer).query)) + ) { + this.logWithoutContext(`knighted-css: skip ${data.request} (already tagged)`) + callback(null, true) + return + } + + const importer = data.contextInfo?.issuer || data.context || this.rootDir + if (!isWithinRoot(importer, this.rootDir)) { + this.logWithoutContext(`knighted-css: skip ${importer} (outside root)`) + callback(null, true) + return + } + const resolved = await this.resolveResource(this.compilerResolver, resource, importer) + + if (!resolved || !isScriptResource(resolved)) { + this.logWithoutContext(`knighted-css: skip ${resource} (unresolved or non-script)`) + callback(null, true) + return + } + + this.logWithoutContext(`knighted-css: resolved ${resource} -> ${resolved}`) + + if (isNodeModulesPath(resolved)) { + this.logWithoutContext(`knighted-css: skip ${resolved} (node_modules)`) + callback(null, true) + return + } + + const sidecarInfo = await this.resolveSidecarInfo(resolved, message => + this.logWithoutContext(message), + ) + if (!sidecarInfo) { + this.logWithoutContext(`knighted-css: skip ${resolved} (no sidecar)`) + callback(null, true) + return + } + + this.logWithoutContext(`knighted-css: sidecar ${sidecarInfo.path}`) + + const normalizedResolved = resolved.split(path.sep).join('/') + const shouldAppendCombined = + this.combinedPaths.length > 0 && + this.combinedPaths.some(entry => { + if (typeof entry === 'string') { + const normalizedEntry = entry.split(path.sep).join('/') + return resolved.includes(entry) || normalizedResolved.includes(normalizedEntry) + } + return entry.test(resolved) || entry.test(normalizedResolved) + }) + + const nextQuery = + shouldAppendCombined && !hasCombinedQuery(query) + ? appendQueryFlag(query, 'combined') + : query + const finalQuery = hasKnightedCssQuery(nextQuery) + ? nextQuery + : appendQueryFlag(nextQuery, KNIGHTED_CSS_QUERY) + data.request = `${resource}${finalQuery}` + this.logWithoutContext(`knighted-css: append ?${KNIGHTED_CSS_QUERY} to ${resource}`) + this.diagnostics.rewrites += 1 + callback(null, true) + } + + private async handleResolve( + resolver: ResolverLike, + request: ResolveRequest, + resolveContext: ResolveContext, + callback: (error?: Error | null, result?: unknown) => void, + ): Promise { + if (!request?.request || typeof request.request !== 'string') { + callback() + return + } + + this.log(resolveContext, `knighted-css: inspect ${request.request}`) + + if (request.__knightedCssAugmented || request.__knightedCssResolve) { + callback() + return + } + + const { resource, query } = splitResourceAndQuery(request.request) + if ( + !resource || + hasKnightedCssQuery(query) || + hasCombinedQuery(query) || + (request.context?.issuer && + hasKnightedCssQuery(splitResourceAndQuery(request.context.issuer).query)) || + (request.context?.issuer && + hasCombinedQuery(splitResourceAndQuery(request.context.issuer).query)) + ) { + this.log(resolveContext, `knighted-css: skip ${request.request} (already tagged)`) + callback() + return + } + + const importer = getImporterPath(request, this.rootDir) + if (!isWithinRoot(importer, this.rootDir)) { + this.log(resolveContext, `knighted-css: skip ${importer} (outside root)`) + callback() + return + } + const resolved = await this.resolveResource(resolver, resource, importer) + + if (!resolved || !isScriptResource(resolved)) { + this.log( + resolveContext, + `knighted-css: skip ${resource} (unresolved or non-script)`, + ) + callback() + return + } + + this.log(resolveContext, `knighted-css: resolved ${resource} -> ${resolved}`) + + if (isNodeModulesPath(resolved)) { + this.log(resolveContext, `knighted-css: skip ${resolved} (node_modules)`) + callback() + return + } + + const sidecarInfo = await this.resolveSidecarInfo(resolved, message => + this.log(resolveContext, message), + ) + if (!sidecarInfo) { + this.log(resolveContext, `knighted-css: skip ${resolved} (no sidecar)`) + callback() + return + } + + this.log(resolveContext, `knighted-css: sidecar ${sidecarInfo.path}`) + + const normalizedResolved = resolved.split(path.sep).join('/') + const shouldAppendCombined = + this.combinedPaths.length > 0 && + this.combinedPaths.some(entry => { + if (typeof entry === 'string') { + const normalizedEntry = entry.split(path.sep).join('/') + return resolved.includes(entry) || normalizedResolved.includes(normalizedEntry) + } + return entry.test(resolved) || entry.test(normalizedResolved) + }) + + const nextQuery = + shouldAppendCombined && !hasCombinedQuery(query) + ? appendQueryFlag(query, 'combined') + : query + const finalQuery = hasKnightedCssQuery(nextQuery) + ? nextQuery + : appendQueryFlag(nextQuery, KNIGHTED_CSS_QUERY) + const nextRequest = `${resource}${finalQuery}` + const augmented: ResolveRequest = { + ...request, + request: nextRequest, + __knightedCssAugmented: true, + } + + this.log(resolveContext, `knighted-css: append ?${KNIGHTED_CSS_QUERY} to ${resource}`) + this.diagnostics.rewrites += 1 + + resolver.doResolve( + resolver.getHook('resolve'), + augmented, + `knighted-css: append ?${KNIGHTED_CSS_QUERY}`, + resolveContext, + callback, + ) + } +} + +export function knightedCssResolverPlugin( + options?: KnightedCssResolverPluginOptions, +): KnightedCssResolverPlugin { + return new KnightedCssResolverPlugin(options) +} + +export const __knightedCssPluginInternals = { + splitResourceAndQuery, + hasKnightedCssQuery, + hasCombinedQuery, + appendQueryFlag, + buildSidecarPath, + isScriptResource, + isNodeModulesPath, + isWithinRoot, +} diff --git a/packages/css/test/__snapshots__/generateTypes.snap.json b/packages/css/test/__snapshots__/generateTypes.snap.json index 623a2d0..c734b36 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 --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" + "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 --mode Emit selector modules (module) or declaration files (declaration)\n --manifest Write a sidecar manifest (declaration mode only)\n -h, --help Show this help message" } diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 302b55e..cf4ead6 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -20,6 +20,15 @@ const CLI_SNAPSHOT_FILE = path.join(SNAPSHOT_DIR, 'generateTypes.snap.json') const UPDATE_SNAPSHOTS = process.env.UPDATE_SNAPSHOTS === '1' || process.env.UPDATE_SNAPSHOTS === 'true' +const { + parseCliArgs, + loadResolverModule, + resolveWithExtensionFallback, + resolveIndexFallback, + readManifest, + writeSidecarManifest, +} = __generateTypesInternals + let cachedCliSnapshots: Record | null = null async function setupFixtureProject(): Promise<{ @@ -46,6 +55,28 @@ console.log(stableSelectors.demo) } } +async function setupDeclarationFixture(): Promise<{ + root: string + cleanup: () => Promise +}> { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-declaration-')) + const root = await fs.realpath(tmpRoot) + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + await fs.writeFile( + path.join(srcDir, 'button.css'), + '.knighted-button { color: rebeccapurple; }\n', + ) + await fs.writeFile( + path.join(srcDir, 'button.tsx'), + "import './button.css'\nexport function Button() { return null }\n", + ) + return { + root, + cleanup: () => fs.rm(root, { recursive: true, force: true }), + } +} + async function setupBaseUrlFixture(): Promise<{ root: string cleanup: () => Promise @@ -283,6 +314,82 @@ test('generateTypes emits declarations and reuses cache', async () => { } }) +test('generateTypes declaration mode emits module augmentations', async () => { + const project = await setupDeclarationFixture() + try { + const outDir = path.join(project.root, '.knighted-css-declaration') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + mode: 'declaration', + }) + assert.ok(result.selectorModulesWritten >= 1) + assert.ok(result.warnings.length >= 1) + + const declarationPath = path.join(project.root, 'src', 'button.tsx.d.ts') + const declaration = await fs.readFile(declarationPath, 'utf8') + assert.ok(declaration.includes("declare module './button.js'")) + assert.ok(declaration.includes('export const knightedCss: string')) + assert.ok(declaration.includes('export const stableSelectors')) + assert.ok(declaration.includes('"button": string')) + assert.ok(!declaration.includes("export { default } from './button.js'")) + assert.ok(!declaration.includes("export * from './button.js'")) + assert.ok(declaration.includes('// @knighted-css')) + } finally { + await project.cleanup() + } +}) + +test('generateTypes declaration mode writes sidecar manifest when requested', async () => { + const project = await setupDeclarationFixture() + try { + const outDir = path.join(project.root, '.knighted-css-declaration') + const manifestPath = path.join(outDir, 'knighted-manifest.json') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + mode: 'declaration', + manifestPath, + }) + + assert.equal(result.sidecarManifestPath, manifestPath) + const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< + string, + { file?: string } + > + const key = path.join(project.root, 'src', 'button.tsx').split(path.sep).join('/') + assert.equal(manifest[key]?.file, path.join(project.root, 'src', 'button.tsx.d.ts')) + } finally { + await project.cleanup() + } +}) + +test('generateTypes declaration mode skips files without style imports', async () => { + const project = await setupDeclarationFixture() + try { + await fs.writeFile( + path.join(project.root, 'src', 'no-styles.tsx'), + 'export const noop = true\n', + ) + const outDir = path.join(project.root, '.knighted-css-declaration') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + mode: 'declaration', + }) + + const declPath = path.join(project.root, 'src', 'no-styles.tsx.d.ts') + const exists = await pathExists(declPath) + assert.equal(exists, false) + assert.ok(result.selectorModulesWritten >= 1) + } finally { + await project.cleanup() + } +}) + test('generateTypes hashed emits selector proxies for modules', async () => { const project = await setupFixtureProject() try { @@ -316,6 +423,113 @@ test('generateTypes hashed emits selector proxies for modules', async () => { } }) +test('generateTypes declaration hashed emits selector exports', async () => { + const project = await setupDeclarationFixture() + try { + const outDir = path.join(project.root, '.knighted-css-declaration') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + mode: 'declaration', + hashed: true, + }) + assert.ok(result.selectorModulesWritten >= 1) + const declarationPath = path.join(project.root, 'src', 'button.tsx.d.ts') + const declaration = await fs.readFile(declarationPath, 'utf8') + assert.ok(declaration.includes('export const selectors')) + assert.ok(!declaration.includes('stableSelectors')) + } finally { + await project.cleanup() + } +}) + +test('parseCliArgs validates flags and combinations', () => { + assert.throws(() => parseCliArgs(['--root']), /Missing value for --root/) + assert.throws(() => parseCliArgs(['--include']), /Missing value for --include/) + assert.throws(() => parseCliArgs(['--out-dir']), /Missing value for --out-dir/) + assert.throws(() => parseCliArgs(['--manifest']), /Missing value for --manifest/) + assert.throws(() => parseCliArgs(['--mode', 'unknown']), /Unknown mode: unknown/) + assert.throws(() => parseCliArgs(['--unknown']), /Unknown flag: --unknown/) + assert.throws( + () => parseCliArgs(['--auto-stable', '--hashed']), + /Cannot combine --auto-stable with --hashed/, + ) +}) + +test('loadResolverModule resolves default, named, and file URL exports', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-resolver-')) + try { + const defaultPath = path.join(root, 'default-resolver.mjs') + const namedPath = path.join(root, 'named-resolver.mjs') + const badPath = path.join(root, 'bad-resolver.mjs') + + await fs.writeFile(defaultPath, 'export default function resolver() { return [] }\n') + await fs.writeFile(namedPath, 'export const resolver = () => []\n') + await fs.writeFile(badPath, 'export const nope = 1\n') + + const defaultResolver = await loadResolverModule('./default-resolver.mjs', root) + assert.equal(typeof defaultResolver, 'function') + + const namedResolver = await loadResolverModule('./named-resolver.mjs', root) + assert.equal(typeof namedResolver, 'function') + + const fileResolver = await loadResolverModule(pathToFileURL(defaultPath).href, root) + assert.equal(typeof fileResolver, 'function') + + await assert.rejects( + () => loadResolverModule('./bad-resolver.mjs', root), + /Resolver module must export a function/, + ) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolveWithExtensionFallback and resolveIndexFallback handle fallbacks', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-resolve-')) + try { + const dir = path.join(root, 'lib') + await fs.mkdir(dir, { recursive: true }) + const indexPath = path.join(dir, 'index.ts') + await fs.writeFile(indexPath, 'export const value = 1\n') + + const resolvedIndex = await resolveIndexFallback(dir) + assert.equal(resolvedIndex, indexPath) + + const resolvedViaFallback = await resolveWithExtensionFallback(dir) + assert.equal(resolvedViaFallback, indexPath) + + const missing = path.join(root, 'missing') + const missingResolved = await resolveWithExtensionFallback(missing) + assert.equal(missingResolved, missing) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('readManifest handles invalid JSON and writeSidecarManifest writes output', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-manifest-')) + try { + const manifestPath = path.join(root, 'selector-modules.json') + await fs.writeFile(manifestPath, 'not-json') + const manifest = await readManifest(manifestPath) + assert.deepEqual(manifest, {}) + + const sidecarPath = path.join(root, 'sidecar', 'manifest.json') + await writeSidecarManifest(sidecarPath, { + '/abs/path/file.ts': { file: '/abs/path/file.ts.d.ts' }, + }) + const sidecar = JSON.parse(await fs.readFile(sidecarPath, 'utf8')) as Record< + string, + { file: string } + > + assert.equal(sidecar['/abs/path/file.ts']?.file, '/abs/path/file.ts.d.ts') + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('generateTypes autoStable emits selectors for CSS Modules', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-auto-stable-')) try { @@ -1016,6 +1230,7 @@ test('generateTypes internals cover edge cases', async () => { const parsed = parseCliArgs(['--root', tempRoot, 'src']) assert.deepEqual(parsed.include, ['src']) + assert.equal(parsed.mode, 'module') } finally { await fs.rm(tempRoot, { recursive: true, force: true }) } @@ -1227,6 +1442,10 @@ test('generateTypes internals support selector module helpers', async () => { 'storybook', '--out-dir', '.knighted-css', + '--manifest', + '.knighted-css/knighted-manifest.json', + '--mode', + 'declaration', '--auto-stable', '--resolver', './resolver.mjs', @@ -1237,6 +1456,8 @@ test('generateTypes internals support selector module helpers', async () => { assert.equal(parsed.autoStable, true) assert.equal(parsed.hashed, false) assert.equal(parsed.resolver, './resolver.mjs') + assert.equal(parsed.mode, 'declaration') + assert.equal(parsed.manifestPath, '.knighted-css/knighted-manifest.json') const hashedParsed = parseCliArgs([ '--root', @@ -1248,12 +1469,16 @@ test('generateTypes internals support selector module helpers', async () => { assert.deepEqual(hashedParsed.include, ['src']) assert.equal(hashedParsed.autoStable, false) assert.equal(hashedParsed.hashed, true) + assert.equal(hashedParsed.mode, 'module') 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(['--mode']), /Missing value/) + assert.throws(() => parseCliArgs(['--manifest']), /Missing value/) + assert.throws(() => parseCliArgs(['--mode', 'wat']), /Unknown mode/) assert.throws(() => parseCliArgs(['--wat']), /Unknown flag/) assert.throws(() => parseCliArgs(['--auto-stable', '--hashed']), /Cannot combine/) const helpParsed = parseCliArgs(['--help']) diff --git a/packages/css/test/lexer.test.ts b/packages/css/test/lexer.test.ts index dd167d3..f732f00 100644 --- a/packages/css/test/lexer.test.ts +++ b/packages/css/test/lexer.test.ts @@ -2,6 +2,9 @@ import assert from 'node:assert/strict' import path from 'node:path' import test from 'node:test' +import type { ExportSpecifier, ImportSpecifier } from 'es-module-lexer' +import { ImportType } from 'es-module-lexer' + import { analyzeModule } from '../src/lexer.ts' test('analyzeModule falls back to oxc for mixed syntax and collects normalized imports', async () => { @@ -62,3 +65,94 @@ void spread './styles/resolved.css', ]) }) + +test('analyzeModule uses es-lexer output to normalize imports and exports', async () => { + const source = `import './styles/base.css?inline#hash' +export const named = 1 +` + const importSpecifiers: ImportSpecifier[] = [ + { + n: './styles/base.css?inline#hash', + t: ImportType.Static, + s: 0, + e: 0, + ss: 0, + se: 0, + d: -1, + a: -1, + at: null, + }, + { + n: 'file:///tmp/app.css?raw', + t: ImportType.Static, + s: 0, + e: 0, + ss: 0, + se: 0, + d: -1, + a: -1, + at: null, + }, + { + n: 'https://example.com/remote.css', + t: ImportType.Static, + s: 0, + e: 0, + ss: 0, + se: 0, + d: -1, + a: -1, + at: null, + }, + { + n: '\0ignored', + t: ImportType.Static, + s: 0, + e: 0, + ss: 0, + se: 0, + d: -1, + a: -1, + at: null, + }, + ] + const exportSpecifiers: ExportSpecifier[] = [ + { + n: 'named', + ln: undefined, + s: 0, + e: 0, + ls: -1, + le: -1, + }, + ] + const result = await analyzeModule(source, 'entry.js', { + esParse: () => [importSpecifiers, exportSpecifiers, false, true], + }) + + assert.equal(result.defaultSignal, 'no-default') + assert.deepEqual(result.imports, ['./styles/base.css', 'file:///tmp/app.css']) + assert.deepEqual(result.exports, ['named']) +}) + +test('analyzeModule returns unknown default when exports are empty', async () => { + const result = await analyzeModule('const value = 1', 'entry.js', { + esParse: () => [[], [], false, false], + }) + + assert.equal(result.defaultSignal, 'unknown') + assert.deepEqual(result.imports, []) + assert.deepEqual(result.exports, []) +}) + +test('analyzeModule returns empty analysis when oxc parsing fails', async () => { + const result = await analyzeModule('export {', 'broken.js', { + esParse: () => { + throw new Error('force-oxc') + }, + }) + + assert.equal(result.defaultSignal, 'unknown') + assert.deepEqual(result.imports, []) + assert.deepEqual(result.exports, []) +}) diff --git a/packages/css/test/loaderBridge.test.ts b/packages/css/test/loaderBridge.test.ts index ac65634..4d4c531 100644 --- a/packages/css/test/loaderBridge.test.ts +++ b/packages/css/test/loaderBridge.test.ts @@ -1,9 +1,10 @@ import assert from 'node:assert/strict' import fs from 'node:fs' +import os from 'node:os' import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' -import { +import loaderBridge, { __loaderBridgeInternals, pitch, type KnightedCssBridgeLoaderOptions, @@ -94,6 +95,22 @@ test('resolveCssText ignores object string coercions', () => { assert.equal(__loaderBridgeInternals.resolveCssText(module.default, module), '') }) +test('loader passthrough returns original source', () => { + const result = (loaderBridge as unknown as (this: unknown, source: string) => string)( + 'body {}', + ) + assert.equal(result, 'body {}') +}) + +test('resolveCssText falls back to module string when primary is not string', () => { + const module = '.chip{display:inline-flex}' + const bridgeModule = module as unknown as { + default?: unknown + locals?: Record + } + assert.equal(__loaderBridgeInternals.resolveCssText(undefined, bridgeModule), module) +}) + test('resolveCssModules finds locals on default export', () => { const module = { default: { @@ -131,6 +148,44 @@ test('resolveCssModules omits default from named exports', () => { }) }) +test('resolveCssModules returns string map locals', () => { + const module = { + button: 'button_hash', + pill: 'pill_hash', + } + const bridgeModule = module as unknown as { + default?: unknown + locals?: Record + } + assert.deepEqual( + __loaderBridgeInternals.resolveCssModules(bridgeModule, bridgeModule), + { + button: 'button_hash', + pill: 'pill_hash', + }, + ) +}) + +test('resolveCssModules collects named exports locals', () => { + const module = { + default: '.ignored{}', + __esModule: true, + card: 'card_hash', + } + assert.deepEqual(__loaderBridgeInternals.resolveCssModules(undefined, module), { + card: 'card_hash', + }) +}) + +test('resolveCssModules returns undefined when locals are invalid', () => { + const module = { + default: '.ignored{}', + __esModule: true, + card: 123, + } + assert.equal(__loaderBridgeInternals.resolveCssModules(undefined, module), undefined) +}) + test('pitch returns combined module wrapper when combined flag is present', async () => { const ctx = createMockContext({ resourceQuery: '?knighted-css&combined', @@ -235,6 +290,85 @@ test('createCombinedJsBridgeModule omits default when disabled', () => { assert.ok(!/export default __knightedDefault/.test(output)) }) +test('buildProxyRequest uses raw request and strips query flags', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/dialects/basic/styles.css'), + resourceQuery: '?knighted-css&combined&types&foo=1', + _module: { + rawRequest: 'babel-loader!./styles.css?knighted-css&combined&types&foo=1', + } as unknown as LoaderContext['_module'], + }) + + const request = __loaderBridgeInternals.buildProxyRequest( + ctx as LoaderContext, + ) + assert.match(request, /babel-loader!/) + assert.match(request, /\?foo=1$/) +}) + +test('buildProxyRequest falls back to utils.contextify', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/dialects/basic/styles.css'), + resourceQuery: '?knighted-css&foo=1', + utils: { + contextify: (_context: string, req: string) => + req.replace(/.*styles/, './ctx/styles'), + } as LoaderContext['utils'], + }) + + const request = __loaderBridgeInternals.buildProxyRequest( + ctx as LoaderContext, + ) + assert.equal(request, './ctx/styles.css?foo=1') +}) + +test('buildProxyRequest falls back to relative request when contextify is missing', () => { + const context = path.resolve(__dirname, 'fixtures/dialects/basic') + const resourcePath = path.resolve(context, 'styles.css') + const ctx = createMockContext({ + resourcePath, + context, + resourceQuery: '?knighted-css', + }) + + const request = __loaderBridgeInternals.buildProxyRequest( + ctx as LoaderContext, + ) + assert.equal(request, './styles.css') +}) + +test('buildProxyRequest rebuilds raw request with contextified resource', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/dialects/basic/styles.css'), + resourceQuery: '?knighted-css&foo=1', + _module: { + rawRequest: '!!sass-loader!./styles.css?knighted-css&foo=1', + } as unknown as LoaderContext['_module'], + utils: { + contextify: (_context: string, req: string) => + req.replace(/.*styles/, './ctx/styles'), + } as LoaderContext['utils'], + }) + + const request = __loaderBridgeInternals.buildProxyRequest( + ctx as LoaderContext, + ) + assert.match(request, /sass-loader!\.\/ctx\/styles\.css\?foo=1$/) +}) + +test('createBridgeModule omits default export when includeDefault is false', () => { + const output = __loaderBridgeInternals.createBridgeModule({ + localsRequest: './card.module.css?knighted-css', + upstreamRequest: './card.module.css?knighted-css', + combined: true, + emitDefault: true, + emitCssModules: true, + includeDefault: false, + }) + + assert.match(output, /const __knightedDefault = __knightedUpstream;/) +}) + test('pitch handles combined js modules and collects css modules', async () => { const source = `import styles from './card.module.css'\nimport './other.module.scss?inline'` const ctx = createMockContext({ @@ -294,6 +428,78 @@ test('pitch combined js collects css modules from dependency graph', async () => assert.match(result, /button\.module\.scss\?knighted-css/) }) +test('pitch combined js dedupes direct requests already in graph', async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'knighted-bridge-')) + try { + const entryPath = path.join(root, 'entry.tsx') + const sharedPath = path.join(root, 'shared.css') + fs.writeFileSync(sharedPath, '.shared {}') + fs.writeFileSync(entryPath, `import './shared.css'\n`) + + const ctx = createMockContext({ + resourcePath: entryPath, + resourceQuery: '?knighted-css&combined', + }) as LoaderContext & { + fs: LoaderContext['fs'] + async: () => (error: Error | null, result?: string) => void + } + + const result = await new Promise((resolve, reject) => { + ctx.fs = { + readFile: (filePath: string, cb: (err: Error | null, data?: Buffer) => void) => + fs.readFile(filePath, cb), + } as unknown as LoaderContext['fs'] + ctx.async = () => (error, output) => { + if (error) { + reject(error) + return + } + resolve(String(output ?? '')) + } + callPitch(ctx, './entry.tsx?knighted-css&combined') + }) + + const matches = result.match(/shared\.css\?knighted-css/g) ?? [] + assert.ok(matches.length >= 1) + } finally { + fs.rmSync(root, { recursive: true, force: true }) + } +}) + +test('pitch combined js resolves upstream from loader list', async () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css&combined', + }) as LoaderContext & { + fs: LoaderContext['fs'] + async: () => (error: Error | null, result?: string) => void + loaders: Array<{ request?: string; path?: string; query?: string }> + loaderIndex: number + } + + const result = await new Promise((resolve, reject) => { + ctx.fs = { + readFile: (_filePath: string, cb: (err: Error | null, data?: Buffer) => void) => + cb(null, Buffer.from('import "./card.css"')), + } as unknown as LoaderContext['fs'] + ctx.loaders = [ + { request: 'first-loader' }, + { request: 'second-loader' }, + ] as LoaderContext['loaders'] + ctx.loaderIndex = 0 + ctx.async = () => (error, output) => { + if (error) { + reject(error) + return + } + resolve(String(output ?? '')) + } + callPitch(ctx, '') + }) + + assert.match(result, /import \* as __knightedUpstream from "!!second-loader!/) +}) + test('pitch combined js returns sync module when async callback is missing', async () => { const ctx = createMockContext({ resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), @@ -365,6 +571,126 @@ test('pitch combined js errors when no data is returned', async () => { assert.match(error.message, /Unable to read/) }) +test('pitch non-combined sync warns without emitWarning handler', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css&types', + emitWarning: undefined, + }) as unknown as LoaderContext + ;(ctx as unknown as { async?: () => undefined }).async = () => undefined + + const result = callPitch(ctx, './bridge-card.tsx?knighted-css&types') + assert.match(String(result ?? ''), /export default __knightedCss/) +}) + +test('pitch combined sync respects named-only query', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css&combined&no-default', + }) as unknown as LoaderContext + ;(ctx as unknown as { async?: () => undefined }).async = () => undefined + + const result = callPitch(ctx, './bridge-card.tsx?knighted-css&combined&no-default') + const output = String(result ?? '') + assert.ok(!/export default __knightedLocalsExport/.test(output)) +}) + +test('pitch combined sync resolves request from ctx.request', () => { + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css&combined', + }) as unknown as LoaderContext + ;(ctx as unknown as { async?: () => undefined }).async = () => undefined + ;(ctx as { request?: string }).request = + 'first-loader!second-loader!./bridge-card.tsx?knighted-css&combined' + ;(ctx as { loaderIndex?: number }).loaderIndex = 0 + + const result = callPitch(ctx, '') + assert.match(String(result ?? ''), /"!!second-loader!/) +}) + +test('pitch non-js resource falls back when graph collection fails', async () => { + const missingPath = path.join(os.tmpdir(), 'missing-style.css') + const ctx = createMockContext({ + resourcePath: missingPath, + resourceQuery: '?knighted-css', + }) as LoaderContext & { + async: () => (error: Error | null, result?: string) => void + } + + const result = await new Promise((resolve, reject) => { + ctx.async = () => (error, output) => { + if (error) { + reject(error) + return + } + resolve(String(output ?? '')) + } + callPitch(ctx, './missing-style.css?knighted-css') + }) + + assert.match(result, /export default __knightedCss/) +}) + +test('pitch non-combined js uses no-default detection for upstream', async () => { + const source = `export const value = 1\nimport './card.module.css'` + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css', + }) as LoaderContext & { + fs: LoaderContext['fs'] + async: () => (error: Error | null, result?: string) => void + } + + const result = await new Promise((resolve, reject) => { + ctx.fs = { + readFile: (_filePath: string, cb: (err: Error | null, data?: Buffer) => void) => + cb(null, Buffer.from(source)), + } as unknown as LoaderContext['fs'] + ctx.async = () => (error, output) => { + if (error) { + reject(error) + return + } + resolve(String(output ?? '')) + } + callPitch(ctx, './bridge-card.tsx?knighted-css') + }) + + assert.match(result, /const __knightedDefault = __knightedUpstream;/) +}) + +test('pitch non-combined js detects default export', async () => { + const source = `export default function Card() {}\nimport './card.css'` + const ctx = createMockContext({ + resourcePath: path.resolve(__dirname, 'fixtures/bridge/bridge-card.tsx'), + resourceQuery: '?knighted-css', + }) as LoaderContext & { + fs: LoaderContext['fs'] + async: () => (error: Error | null, result?: string) => void + } + + const result = await new Promise((resolve, reject) => { + ctx.fs = { + readFile: (_filePath: string, cb: (err: Error | null, data?: Buffer) => void) => + cb(null, Buffer.from(source)), + } as unknown as LoaderContext['fs'] + ctx.async = () => (error, output) => { + if (error) { + reject(error) + return + } + resolve(String(output ?? '')) + } + callPitch(ctx, './bridge-card.tsx?knighted-css') + }) + + assert.match( + result, + /Object\.prototype\.hasOwnProperty\.call\(__knightedUpstream, 'default'\)/, + ) +}) + test('resolveCssModules returns string maps', () => { const module = { card: 'card_hash', title: 'title_hash' } const bridgeModule = module as unknown as { diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index 3f58ab9..f98f708 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -5,7 +5,7 @@ import path from 'node:path' import test from 'node:test' import { pathToFileURL } from 'node:url' -import { collectStyleImports } from '../src/moduleGraph.ts' +import { collectStyleImports, normalizeSpecifier } from '../src/moduleGraph.ts' import type { CssResolver } from '../src/types.js' interface Project { @@ -282,6 +282,91 @@ void styles } }) +test('normalizeSpecifier strips queries, preserves file URLs, and rejects http', () => { + assert.equal( + normalizeSpecifier(' ./styles/card.css?inline#hash '), + './styles/card.css', + ) + assert.equal(normalizeSpecifier('file:///tmp/styles.css?raw'), 'file:///tmp/styles.css') + assert.equal(normalizeSpecifier('https://example.com/style.css'), '') + assert.equal(normalizeSpecifier('\0virtual'), '') + assert.equal(normalizeSpecifier('#alias/path.css?query'), '#alias/path.css') +}) + +test('collectStyleImports handles dynamic import assertions', async () => { + const project = await createProject('knighted-module-graph-dynamic-assert-') + try { + await project.writeFile('styles/with.css', '.with { color: pink; }') + await project.writeFile('styles/assert.css', '.assert { color: blue; }') + await project.writeFile( + 'entry.ts', + `await import('./styles/with.css', { with: { type: 'css' } }) +await import('./styles/assert.css', { assert: { type: 'css' } }) +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([ + project.file('styles/with.css'), + project.file('styles/assert.css'), + ]), + ) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports resolves path mappings from tsconfig file', async () => { + const project = await createProject('knighted-module-graph-tsconfig-file-') + try { + await project.writeFile('styles/mapped.css', '.mapped { color: red; }') + await project.writeFile( + 'tsconfig.json', + JSON.stringify( + { + compilerOptions: { + baseUrl: '.', + paths: { + '@mapped': ['styles/mapped.css'], + }, + }, + }, + null, + 2, + ), + ) + await project.writeFile( + 'entry.ts', + `import css from '@mapped' with { type: 'css' } +void css +`, + ) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + graphOptions: { + tsConfig: project.file('tsconfig.json'), + }, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([project.file('styles/mapped.css')]), + ) + } finally { + await project.cleanup() + } +}) + test('collectStyleImports supports dynamic import attributes with static specifiers', async () => { const project = await createProject('knighted-module-graph-attr-dynamic-') try { diff --git a/packages/css/test/plugin.test.ts b/packages/css/test/plugin.test.ts new file mode 100644 index 0000000..cc50544 --- /dev/null +++ b/packages/css/test/plugin.test.ts @@ -0,0 +1,848 @@ +import assert from 'node:assert/strict' +import fsSync from 'node:fs' +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import test from 'node:test' + +import { __knightedCssPluginInternals, knightedCssResolverPlugin } from '../src/plugin.ts' + +const { + splitResourceAndQuery, + hasKnightedCssQuery, + appendQueryFlag, + buildSidecarPath, + isScriptResource, + isNodeModulesPath, +} = __knightedCssPluginInternals + +type MockHook = { + tapAsync: ( + name: string, + callback: ( + request: { + request?: string + path?: string + context?: { issuer?: string } + __knightedCssResolve?: boolean + __knightedCssAugmented?: boolean + }, + context: { log?: (message: string) => void }, + callback: (error?: Error | null, result?: unknown) => void, + ) => void, + ) => void +} + +type ResolveHandler = ( + request: { + request?: string + path?: string + context?: { issuer?: string } + __knightedCssResolve?: boolean + __knightedCssAugmented?: boolean + }, + context: { log?: (message: string) => void }, + callback: (error?: Error | null, result?: unknown) => void, +) => void + +type MockResolver = { + getHook: (name: string) => MockHook + doResolve: ( + hook: MockHook, + request: { + request?: string + path?: string + context?: { issuer?: string } + __knightedCssResolve?: boolean + __knightedCssAugmented?: boolean + }, + message: string, + context: { log?: (message: string) => void }, + callback: (error?: Error | null, result?: unknown) => void, + ) => void + invoke: ( + name: string, + request: { + request?: string + context?: { issuer?: string } + __knightedCssAugmented?: boolean + }, + ) => Promise + lastRequest: { request?: string } | null + reset: () => void +} + +function createMockResolver(resolveMap: Map): MockResolver { + const hooks = new Map() + let lastRequest: { request?: string } | null = null + + const hookApi = (name: string): MockHook => ({ + tapAsync: (_hookName, callback) => { + const list = hooks.get(name) ?? [] + list.push(callback) + hooks.set(name, list) + }, + }) + + return { + getHook: hookApi, + doResolve: (_hook, request, _message, _context, callback) => { + if (request.__knightedCssResolve) { + const key = `${request.request ?? ''}|${request.path ?? ''}` + const resolved = resolveMap.get(key) ?? resolveMap.get(request.request ?? '') + callback(null, resolved ? { path: resolved } : undefined) + return + } + lastRequest = request + callback(null, request) + }, + async invoke(name, request) { + const callbacks = hooks.get(name) ?? [] + for (const callback of callbacks) { + await new Promise((resolve, reject) => { + callback(request, {}, error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + }, + get lastRequest() { + return lastRequest + }, + reset() { + lastRequest = null + }, + } +} + +type MockCompiler = { + getResolver: (type: string) => MockResolver | undefined + hooks: { + invalid: { tap: (name: string, callback: () => void) => void } + } + triggerInvalid: () => void +} + +type MockNormalModuleFactory = { + hooks: { + beforeResolve?: { + tapAsync: ( + name: string, + callback: ( + data: { request?: string; context?: string; contextInfo?: { issuer?: string } }, + callback: (error?: Error | null, result?: unknown) => void, + ) => void, + ) => void + } + } + invokeBeforeResolve: (data: { + request?: string + context?: string + contextInfo?: { issuer?: string } + }) => Promise +} + +type MockCompilerWithFactory = { + inputFileSystem?: { + readFile?: ( + filePath: string, + callback: (error: NodeJS.ErrnoException | null, data?: Buffer) => void, + ) => void + stat?: ( + filePath: string, + callback: (error?: NodeJS.ErrnoException | null) => void, + ) => void + } + hooks: { + normalModuleFactory: { + tap: (name: string, callback: (factory: MockNormalModuleFactory) => void) => void + } + } + factory: MockNormalModuleFactory +} + +function createMockCompiler(resolver: MockResolver): MockCompiler { + let invalidHandler: (() => void) | undefined + return { + getResolver: type => (type === 'normal' ? resolver : undefined), + hooks: { + invalid: { + tap: (_name, callback) => { + invalidHandler = callback + }, + }, + }, + triggerInvalid() { + invalidHandler?.() + }, + } +} + +function createMockNormalModuleFactory(): MockNormalModuleFactory { + let beforeResolveHandler: + | (( + data: { request?: string; context?: string; contextInfo?: { issuer?: string } }, + callback: (error?: Error | null, result?: unknown) => void, + ) => void) + | undefined + + return { + hooks: { + beforeResolve: { + tapAsync: (_name, callback) => { + beforeResolveHandler = callback + }, + }, + }, + async invokeBeforeResolve(data) { + if (!beforeResolveHandler) { + return + } + await new Promise((resolve, reject) => { + beforeResolveHandler?.(data, error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + }, + } +} + +function createMockCompilerWithFactory(): MockCompilerWithFactory { + const factory = createMockNormalModuleFactory() + return { + hooks: { + normalModuleFactory: { + tap: (_name, callback) => { + callback(factory) + }, + }, + }, + factory, + } +} + +function setResolveEntry( + map: Map, + specifier: string, + importer: string, + resolvedPath: string, +) { + map.set(`${specifier}|${importer}`, resolvedPath) + map.set(specifier, resolvedPath) +} + +test('resolver plugin internals parse and append queries', () => { + assert.deepEqual(splitResourceAndQuery('./button.js'), { + resource: './button.js', + query: '', + }) + assert.deepEqual(splitResourceAndQuery('./button.js?raw=1'), { + resource: './button.js', + query: '?raw=1', + }) + + assert.equal(hasKnightedCssQuery('?knighted-css'), true) + assert.equal(hasKnightedCssQuery('?raw=1&knighted-css'), true) + assert.equal(hasKnightedCssQuery('?raw=1'), false) + + assert.equal( + `./button.js${appendQueryFlag('', 'knighted-css')}`, + './button.js?knighted-css', + ) + assert.equal( + `./button.js${appendQueryFlag('?raw=1', 'knighted-css')}`, + './button.js?raw=1&knighted-css', + ) +}) + +test('resolver plugin internals identify script paths and sidecars', () => { + const tmpDir = path.join(path.sep, 'tmp') + const buttonTsx = path.join(tmpDir, 'button.tsx') + const buttonJs = path.join(tmpDir, 'button.js') + const buttonDts = path.join(tmpDir, 'button.d.ts') + const stylesCss = path.join(tmpDir, 'styles.css') + const nodeModulesFile = path.join(tmpDir, 'node_modules', 'pkg', 'index.js') + const srcButton = path.join(tmpDir, 'src', 'button.tsx') + + assert.equal(isScriptResource(buttonTsx), true) + assert.equal(isScriptResource(buttonJs), true) + assert.equal(isScriptResource(buttonDts), false) + assert.equal(isScriptResource(stylesCss), false) + assert.equal(isNodeModulesPath(nodeModulesFile), true) + assert.equal(isNodeModulesPath(srcButton), false) + + assert.equal(buildSidecarPath(buttonTsx), `${buttonTsx}.d.ts`) +}) + +test('resolver plugin requires marker when strictSidecar is enabled', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'button.tsx') + await fs.writeFile(importer, "import './button'\n") + await fs.writeFile(target, 'export function Button() {}\n') + await fs.writeFile(`${target}.d.ts`, 'declare module "./button.js" {}\n') + + const resolveMap = new Map() + setResolveEntry(resolveMap, './button', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './button', + context: { issuer: importer }, + }) + + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin rewrites when strictSidecar marker exists', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'card.tsx') + await fs.writeFile(importer, "import './card'\n") + await fs.writeFile(target, 'export function Card() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./card.js" {}\n', + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './card', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './card', + context: { issuer: importer }, + }) + + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin uses manifest entries for sidecar lookup', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + const typesDir = path.join(root, 'types') + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(typesDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'theme.tsx') + const sidecar = path.join(typesDir, 'theme.d.ts') + await fs.writeFile(importer, "import './theme'\n") + await fs.writeFile(target, 'export function Theme() {}\n') + await fs.writeFile(sidecar, '// @knighted-css\n\ndeclare module "./theme.js" {}\n') + + const manifestPath = path.join(root, 'knighted-manifest.json') + const manifestKey = target.split(path.sep).join('/') + const manifest = { [manifestKey]: { file: sidecar } } + await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './theme', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ + rootDir: root, + strictSidecar: true, + manifestPath, + }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './theme', + context: { issuer: importer }, + }) + + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin invalid hook clears manifest cache', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + const typesDir = path.join(root, 'types') + await fs.mkdir(srcDir, { recursive: true }) + await fs.mkdir(typesDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'panel.tsx') + const sidecar = path.join(typesDir, 'panel.d.ts') + await fs.writeFile(importer, "import './panel'\n") + await fs.writeFile(target, 'export function Panel() {}\n') + await fs.writeFile(sidecar, '// @knighted-css\n\ndeclare module "./panel.js" {}\n') + + const manifestPath = path.join(root, 'knighted-manifest.json') + const manifestKey = target.split(path.sep).join('/') + await fs.writeFile( + manifestPath, + JSON.stringify({ [manifestKey]: { file: sidecar } }, null, 2), + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './panel', importer, target) + const resolver = createMockResolver(resolveMap) + const compiler = createMockCompiler(resolver) + const plugin = knightedCssResolverPlugin({ + rootDir: root, + manifestPath, + }) + plugin.apply(compiler) + + await resolver.invoke('resolve', { + request: './panel', + context: { issuer: importer }, + }) + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + + await fs.writeFile(manifestPath, JSON.stringify({}, null, 2)) + compiler.triggerInvalid() + resolver.reset() + + await resolver.invoke('resolve', { + request: './panel', + context: { issuer: importer }, + }) + + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin prefers compiler resolver output when available', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'alias.ts') + await fs.writeFile(importer, "import '#alias/button'\n") + await fs.writeFile(target, 'export function Alias() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "#alias/button" {}\n', + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, '#alias/button', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: '#alias/button', + context: { issuer: importer }, + }) + + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin uses resolver fileSystem for sidecar reads', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'fs-card.tsx') + await fs.writeFile(importer, "import './fs-card'\n") + await fs.writeFile(target, 'export function FsCard() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./fs-card.js" {}\n', + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './fs-card', importer, target) + const resolver = createMockResolver(resolveMap) + ;(resolver as unknown as { fileSystem: object }).fileSystem = { + readFile: ( + filePath: string, + callback: (err: Error | null, data?: Buffer) => void, + ) => { + fsSync.readFile(filePath, callback) + }, + stat: (filePath: string, callback: (err?: Error | null) => void) => { + fsSync.stat(filePath, error => callback(error ?? undefined)) + }, + } + + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './fs-card', + context: { issuer: importer }, + }) + await resolver.invoke('resolve', { + request: './fs-card', + context: { issuer: importer }, + }) + + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin appends combined flag via normalModuleFactory', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'card.tsx') + await fs.writeFile(importer, "import './card'\n") + await fs.writeFile(target, 'export function Card() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./card.js" {}\n', + ) + + const compiler = createMockCompilerWithFactory() + compiler.inputFileSystem = { + readFile: (filePath, callback) => { + fsSync.readFile(filePath, callback) + }, + stat: (filePath, callback) => { + fsSync.stat(filePath, error => callback(error ?? undefined)) + }, + } + + const plugin = knightedCssResolverPlugin({ + rootDir: root, + strictSidecar: true, + combinedPaths: ['card.tsx'], + }) + plugin.apply(compiler) + + const data = { + request: './card', + context: srcDir, + contextInfo: { issuer: importer }, + } + await compiler.factory.invokeBeforeResolve(data) + + assert.ok(data.request?.includes('combined')) + assert.ok(data.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin uses resolverFactory hook and resource result', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'resource.ts') + await fs.writeFile(importer, "import './resource'\n") + await fs.writeFile(target, 'export function Resource() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./resource.js" {}\n', + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './resource', importer, target) + + const hooks = new Map() + const resolver: MockResolver = { + getHook: name => ({ + tapAsync: (_hookName, callback) => { + const list = hooks.get(name) ?? [] + list.push(callback) + hooks.set(name, list) + }, + }), + doResolve: (_hook, request, _message, _context, callback) => { + if (request.__knightedCssResolve) { + const key = `${request.request ?? ''}|${request.path ?? ''}` + const resolved = resolveMap.get(key) ?? resolveMap.get(request.request ?? '') + callback(null, resolved ? { resource: resolved } : undefined) + return + } + resolver.lastRequest = request + callback(null, request) + }, + async invoke(name, request) { + const callbacks = hooks.get(name) ?? [] + for (const callback of callbacks) { + await new Promise((resolve, reject) => { + callback(request, {}, error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + } + }, + lastRequest: null, + reset: () => { + resolver.lastRequest = null + }, + } + + let resolverHookCallback: ((resolver: MockResolver) => void) | undefined + const compiler = { + resolverFactory: { + hooks: { + resolver: { + for: (_name: string) => ({ + tap: (_tapName: string, callback: (resolver: MockResolver) => void) => { + resolverHookCallback = callback + }, + }), + }, + }, + }, + hooks: { + invalid: { tap: () => undefined }, + watchRun: { tap: () => undefined }, + done: { tap: () => undefined }, + }, + } + + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(compiler) + resolverHookCallback?.(resolver) + + await resolver.invoke('resolve', { + request: './resource', + context: { issuer: importer }, + }) + + const lastRequest = resolver.lastRequest + assert.ok(lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin skips already tagged requests', async () => { + const resolveMap = new Map() + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: process.cwd() }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './card?knighted-css', + context: { issuer: path.join(process.cwd(), 'src/entry.ts') }, + }) + + assert.equal(resolver.lastRequest, null) +}) + +test('resolver plugin skips when issuer already tagged', async () => { + const resolveMap = new Map() + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: process.cwd() }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './card', + context: { issuer: '/tmp/entry.ts?knighted-css' }, + }) + + assert.equal(resolver.lastRequest, null) +}) + +test('resolver plugin skips outside root and node_modules', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(root, 'node_modules/pkg/index.js') + + const resolveMap = new Map() + setResolveEntry(resolveMap, './pkg', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: true }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './pkg', + context: { issuer: path.join(root, 'outside.ts') }, + }) + assert.equal(resolver.lastRequest, null) + + await resolver.invoke('resolve', { + request: './pkg', + context: { issuer: importer }, + }) + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin skips non-script resources', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'styles.css') + + const resolveMap = new Map() + setResolveEntry(resolveMap, './styles.css', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './styles.css', + context: { issuer: importer }, + }) + + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin skips when manifest misses entry', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'miss.tsx') + await fs.writeFile(importer, "import './miss'\n") + await fs.writeFile(target, 'export function Miss() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./miss.js" {}\n', + ) + + const manifestPath = path.join(root, 'knighted-manifest.json') + await fs.writeFile(manifestPath, JSON.stringify({}, null, 2)) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './miss', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ + rootDir: root, + strictSidecar: true, + manifestPath, + }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './miss', + context: { issuer: importer }, + }) + + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin rewrites without marker when strictSidecar is false', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'plain.tsx') + await fs.writeFile(importer, "import './plain'\n") + await fs.writeFile(target, 'export function Plain() {}\n') + await fs.writeFile(`${target}.d.ts`, 'declare module "./plain.js" {}\n') + + const resolveMap = new Map() + setResolveEntry(resolveMap, './plain', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: root, strictSidecar: false }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './plain', + context: { issuer: importer }, + }) + + assert.ok(resolver.lastRequest?.request?.includes('knighted-css')) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin skips when combined query already present', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-plugin-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const importer = path.join(srcDir, 'entry.ts') + const target = path.join(srcDir, 'combo.tsx') + await fs.writeFile(importer, "import './combo'\n") + await fs.writeFile(target, 'export function Combo() {}\n') + await fs.writeFile( + `${target}.d.ts`, + '// @knighted-css\n\ndeclare module "./combo.js" {}\n', + ) + + const resolveMap = new Map() + setResolveEntry(resolveMap, './combo', importer, target) + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ + rootDir: root, + strictSidecar: true, + combinedPaths: ['combo.tsx'], + }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './combo?combined', + context: { issuer: importer }, + }) + + assert.equal(resolver.lastRequest, null) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + +test('resolver plugin skips augmented requests', async () => { + const resolveMap = new Map() + const resolver = createMockResolver(resolveMap) + const plugin = knightedCssResolverPlugin({ rootDir: process.cwd() }) + plugin.apply(resolver) + + await resolver.invoke('resolve', { + request: './card', + context: { issuer: '/tmp/entry.ts' }, + __knightedCssAugmented: true, + }) + + assert.equal(resolver.lastRequest, null) +}) diff --git a/packages/playwright/README.md b/packages/playwright/README.md index b97b4c7..f7017b6 100644 --- a/packages/playwright/README.md +++ b/packages/playwright/README.md @@ -5,6 +5,7 @@ This package builds the demo surface Playwright hits in CI. It now covers three - Lit + React wrapper (bundled via Rspack/Webpack): exercises vanilla CSS, Sass/Less, vanilla-extract, combined loader queries, and the attribute-import card that uses `with { type: "css" }` in a bundled flow. - Hash-imports workspace demo: minimal npm workspace under `src/hash-imports-workspace/` proving `@knighted/css/loader` and `css()` respect hash-prefixed `package.json#imports` (`#workspace/*`). - Native CSS import attributes (no bundler): plain ESM page at `/src/native-attr/index.html` that imports `./native-attr.css` with `{ type: 'css' }` and applies the stylesheet at runtime. +- Mode fixture (declaration/module variants): `/src/mode` showcases single-specifier declaration imports, hashed selectors, and strict sidecar behavior under the resolver plugin. ## How to run @@ -12,6 +13,21 @@ This package builds the demo surface Playwright hits in CI. It now covers three - Hash-imports only: `npm run test -- --project=chromium hash-imports.spec.ts`. - Local preview server: `npm run preview -w @knighted/css-playwright-fixture` (serves at http://localhost:4174 after building Rspack/webpack/SSR outputs). +## Mode fixture notes + +The mode demo (`/mode.html`) is built via `rspack.mode.config.js`. It depends on declaration sidecars generated by `knighted-css-generate-types` and a strict manifest used by the resolver plugin. + +Key pieces: + +- `npm run types:mode` generates declaration sidecars for the mode fixtures, including a strict-only path for `strict-ok-card.tsx`. +- `npm run types:mode` also writes the merged strict manifest to + `.knighted-css-mode/knighted-manifest.json` via `scripts/generate-mode-types.ts`. +- The manifest is produced by passing a manifest path to `knighted-css-generate-types` + (declaration mode only) and then merging the per-mode outputs. +- The resolver plugin is configured with `strictSidecar: true` and `manifestPath` so single-specifier imports like `./declaration-card.js` rewrite to `?knighted-css` at build time without warnings. + +If you are debugging the mode fixture, use `KNIGHTED_CSS_DEBUG_MODE=1 npm run build:mode` to see rewrite decisions in the terminal. + ## Exercising CSS import attributes - Bundled path (Lit/React attribute card): after `npm run preview -w @knighted/css-playwright-fixture`, open http://localhost:4174/ and locate the card with test id `dialect-attr-import` (rendered by `lit-react.spec.ts`). diff --git a/packages/playwright/mode.html b/packages/playwright/mode.html new file mode 100644 index 0000000..2e19605 --- /dev/null +++ b/packages/playwright/mode.html @@ -0,0 +1,18 @@ + + + + + Mode fixture + + + + +
+ + + diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 5ba9c6b..e01ce7f 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -4,14 +4,16 @@ "private": true, "type": "module", "scripts": { - "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", + "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 && npm run build:mode", + "types": "npm run types:base && npm run types:auto-stable && npm run types:hashed && npm run types:mode", "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", + "types:mode": "tsx scripts/generate-mode-types.ts", "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:mode": "rspack --config rspack.mode.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", @@ -19,19 +21,22 @@ "precheck-types": "npm run types", "check-types": "tsc --noEmit", "prebuild": "npm run build -w @knighted/css", + "prebuild:mode": "npm run types:mode", "preview": "npm run build && http-server . -p 4174", + "preview:mode": "npm run build:mode && http-server . -p 4179 -o /mode.html", "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", "prepreview:bridge": "npm run build -w @knighted/css", "prepreview:bridge-webpack": "npm run build -w @knighted/css", + "clean:knighted": "rm -rf .knighted-*", "serve": "http-server dist -p 4174", "test": "playwright test", "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.1.1", + "@knighted/css": "1.2.0-rc.0", "@knighted/jsx": "^1.7.5", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/rspack.mode.config.js b/packages/playwright/rspack.mode.config.js new file mode 100644 index 0000000..7590fd0 --- /dev/null +++ b/packages/playwright/rspack.mode.config.js @@ -0,0 +1,234 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { CssExtractRspackPlugin, ProvidePlugin } from '@rspack/core' +import { knightedCssResolverPlugin } from '@knighted/css/plugin' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const debugResolver = process.env.KNIGHTED_CSS_DEBUG_MODE === '1' + +const typesCacheDir = path.resolve(__dirname, '.knighted-css-mode') +const strictManifestPath = path.join(typesCacheDir, 'knighted-manifest.json') +const combinedHashedPath = path.join('src', 'mode', 'declaration-hashed') +const declarationHashedDir = /src[\\/]mode[\\/]declaration-hashed/ +const declarationStableDir = /src[\\/]mode[\\/]declaration-stable/ + +export default async () => ({ + mode: 'development', + context: __dirname, + entry: './src/mode/index.ts', + output: { + path: path.resolve(__dirname, 'dist-mode'), + filename: 'mode-bundle.js', + cssFilename: 'mode.css', + }, + resolve: { + extensions: ['.tsx', '.ts', '.js', '.css'], + extensionAlias: { + '.js': ['.js', '.ts', '.tsx'], + }, + }, + experiments: { + css: false, + }, + module: { + rules: [ + { + test: /\.module\.css$/, + include: declarationHashedDir, + oneOf: [ + { + resourceQuery: /knighted-css/, + type: 'javascript/auto', + use: [ + { + loader: '@knighted/css/loader-bridge', + }, + { + loader: 'css-loader', + options: { + exportType: 'string', + modules: { + namedExport: true, + }, + }, + }, + ], + }, + { + type: 'javascript/auto', + use: [ + { + loader: CssExtractRspackPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: { + namedExport: false, + }, + }, + }, + ], + }, + ], + }, + { + test: /\.[jt]sx?$/, + resourceQuery: /knighted-css/, + include: declarationHashedDir, + 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: /\.[jt]sx?$/, + resourceQuery: /knighted-css/, + include: declarationStableDir, + use: [ + { + loader: '@knighted/css/loader', + options: { + lightningcss: { minify: true, cssModules: true }, + autoStable: true, + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }, + }, + ], + }, + { + test: /\.[jt]sx?$/, + resourceQuery: /knighted-css/, + exclude: /src[\\/]mode[\\/](declaration-stable|declaration-hashed)/, + use: [ + { + loader: '@knighted/css/loader', + options: { + lightningcss: { minify: true }, + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }, + }, + ], + }, + { + test: /\.tsx?$/, + use: [ + { + loader: '@knighted/jsx/loader', + options: { + mode: 'react', + }, + }, + { + loader: 'builtin:swc-loader', + options: { + jsc: { + target: 'es2022', + parser: { + syntax: 'typescript', + tsx: true, + }, + }, + }, + }, + ], + }, + { + test: /\.module\.css$/, + exclude: declarationHashedDir, + use: [ + { + loader: CssExtractRspackPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: { + namedExport: false, + }, + }, + }, + ], + }, + { + test: /\.css$/, + exclude: /\.module\.css$/, + use: [ + { + loader: CssExtractRspackPlugin.loader, + }, + { + loader: 'css-loader', + options: { + modules: false, + }, + }, + ], + }, + { + test: /\.s[ac]ss$/, + type: 'asset/source', + }, + { + test: /\.less$/, + type: 'asset/source', + }, + ], + }, + plugins: [ + knightedCssResolverPlugin({ + debug: debugResolver, + combinedPaths: [combinedHashedPath], + strictSidecar: true, + manifestPath: strictManifestPath, + }), + new CssExtractRspackPlugin({ + filename: 'mode.css', + }), + new ProvidePlugin({ + React: 'react', + }), + ], +}) diff --git a/packages/playwright/scripts/generate-mode-types.ts b/packages/playwright/scripts/generate-mode-types.ts new file mode 100644 index 0000000..b7dab7b --- /dev/null +++ b/packages/playwright/scripts/generate-mode-types.ts @@ -0,0 +1,152 @@ +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { generateTypes, type GenerateTypesOptions } from '../../css/src/generateTypes.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) +const rootDir = path.resolve(__dirname, '..') + +const typesCacheDir = path.resolve(rootDir, '.knighted-css-mode') +const strictManifestPath = path.join(typesCacheDir, 'knighted-manifest.json') + +type ModeConfig = { + include: string | string[] + outDir: string + mode: 'module' | 'declaration' + autoStable?: boolean + hashed?: boolean + manifestPath?: string +} + +const modeConfigs: ModeConfig[] = [ + { + include: 'src/mode/module', + outDir: '.knighted-css-mode-module', + mode: 'module', + }, + { + include: 'src/mode/declaration', + outDir: '.knighted-css-mode-declaration', + mode: 'declaration', + manifestPath: '.knighted-css-mode-declaration/knighted-manifest.json', + }, + { + include: 'src/mode/declaration-hashed', + outDir: '.knighted-css-mode-declaration-hashed', + mode: 'declaration', + hashed: true, + manifestPath: '.knighted-css-mode-declaration-hashed/knighted-manifest.json', + }, + { + include: 'src/mode/declaration-stable', + outDir: '.knighted-css-mode-declaration-stable', + mode: 'declaration', + autoStable: true, + manifestPath: '.knighted-css-mode-declaration-stable/knighted-manifest.json', + }, + { + include: 'src/mode/declaration-strict/strict-ok-card.tsx', + outDir: '.knighted-css-mode-declaration-strict', + mode: 'declaration', + manifestPath: '.knighted-css-mode-declaration-strict/knighted-manifest.json', + }, +] + +const manifestPaths: string[] = [] + +type ManifestEntry = { file: string; hash?: string } +type Manifest = Record + +type ModeAwareGenerateTypesOptions = GenerateTypesOptions & { + mode?: ModeConfig['mode'] + manifestPath?: string +} + +function readManifest(filePath: string): Manifest { + if (!fs.existsSync(filePath)) { + throw new Error(`Missing manifest at ${filePath}.`) + } + const raw = JSON.parse(fs.readFileSync(filePath, 'utf8')) as Manifest + const normalized: Manifest = {} + for (const [key, value] of Object.entries(raw)) { + if (value && typeof value.file === 'string') { + normalized[key] = { file: value.file } + } + } + return normalized +} + +function withAliasEntries(manifest: Manifest): Manifest { + const aliasEntries: Manifest = {} + for (const [key, value] of Object.entries(manifest)) { + if (key.endsWith('.ts') || key.endsWith('.tsx')) { + const aliasKey = `${key.slice(0, -path.extname(key).length)}.js` + if (!manifest[aliasKey]) { + aliasEntries[aliasKey] = value + } + continue + } + if (key.endsWith('.mts')) { + const aliasKey = `${key.slice(0, -4)}.mjs` + if (!manifest[aliasKey]) { + aliasEntries[aliasKey] = value + } + continue + } + if (key.endsWith('.cts')) { + const aliasKey = `${key.slice(0, -4)}.cjs` + if (!manifest[aliasKey]) { + aliasEntries[aliasKey] = value + } + } + } + return { ...manifest, ...aliasEntries } +} + +function writeManifest(manifest: Manifest) { + fs.mkdirSync(typesCacheDir, { recursive: true }) + fs.writeFileSync(strictManifestPath, `${JSON.stringify(manifest, null, 2)}\n`) +} + +for (const config of modeConfigs) { + const include = Array.isArray(config.include) + ? config.include.map(entry => path.resolve(rootDir, entry)) + : [path.resolve(rootDir, config.include)] + const outDir = path.resolve(rootDir, config.outDir) + const explicitManifestPath = config.manifestPath + ? path.resolve(rootDir, config.manifestPath) + : undefined + + const options: ModeAwareGenerateTypesOptions = { + rootDir, + include, + outDir, + mode: config.mode, + autoStable: config.autoStable, + hashed: config.hashed, + manifestPath: explicitManifestPath, + } + + const result = await generateTypes(options) + + if (config.mode === 'declaration') { + const fallbackManifestPath = + result.manifestPath ?? path.join(outDir, 'selector-modules.json') + const candidates = [ + 'sidecarManifestPath' in result ? result.sidecarManifestPath : undefined, + explicitManifestPath, + fallbackManifestPath, + ].filter((value): value is string => typeof value === 'string') + + const resolved = candidates.find(candidate => fs.existsSync(candidate)) + if (resolved) { + manifestPaths.push(resolved) + } + } +} + +const mergedManifest = withAliasEntries( + manifestPaths.map(readManifest).reduce((acc, entry) => ({ ...acc, ...entry }), {}), +) +writeManifest(mergedManifest) diff --git a/packages/playwright/src/mode/constants.ts b/packages/playwright/src/mode/constants.ts new file mode 100644 index 0000000..5267d73 --- /dev/null +++ b/packages/playwright/src/mode/constants.ts @@ -0,0 +1,28 @@ +export const MODE_MODULE_HOST_TAG = 'knighted-mode-module-host' +export const MODE_DECL_HOST_TAG = 'knighted-mode-declaration-host' +export const MODE_DECL_HASHED_HOST_TAG = 'knighted-mode-declaration-hashed-host' +export const MODE_DECL_STABLE_HOST_TAG = 'knighted-mode-declaration-stable-host' + +export const MODE_DECL_STRICT_OK_TEST_ID = 'mode-declaration-strict-ok' +export const MODE_DECL_STRICT_SKIP_TEST_ID = 'mode-declaration-strict-skip' +export const MODE_DECL_STRICT_OK_PROBE_TEST_ID = 'mode-declaration-strict-ok-probe' +export const MODE_DECL_STRICT_SKIP_PROBE_TEST_ID = 'mode-declaration-strict-skip-probe' + +export const MODE_MODULE_HOST_TEST_ID = 'mode-module-host' +export const MODE_DECL_HOST_TEST_ID = 'mode-declaration-host' +export const MODE_DECL_HASHED_HOST_TEST_ID = 'mode-declaration-hashed-host' +export const MODE_DECL_STABLE_HOST_TEST_ID = 'mode-declaration-stable-host' + +export const MODE_MODULE_LIGHT_TEST_ID = 'mode-module-light' +export const MODE_DECL_LIGHT_TEST_ID = 'mode-declaration-light' + +export const MODE_DECL_HASHED_LIGHT_TEST_ID = 'mode-declaration-hashed-light' +export const MODE_DECL_STABLE_LIGHT_TEST_ID = 'mode-declaration-stable-light' + +export const MODE_DECL_HASHED_SELECTOR_TEST_ID = 'mode-declaration-hashed-selector' +export const MODE_DECL_STABLE_SELECTOR_TEST_ID = 'mode-declaration-stable-selector' + +export const MODE_MODULE_SHADOW_TEST_ID = 'mode-module-shadow' +export const MODE_DECL_SHADOW_TEST_ID = 'mode-declaration-shadow' +export const MODE_DECL_HASHED_SHADOW_TEST_ID = 'mode-declaration-hashed-shadow' +export const MODE_DECL_STABLE_SHADOW_TEST_ID = 'mode-declaration-stable-shadow' diff --git a/packages/playwright/src/mode/declaration-hashed/declaration-hashed-card.tsx b/packages/playwright/src/mode/declaration-hashed/declaration-hashed-card.tsx new file mode 100644 index 0000000..7df936e --- /dev/null +++ b/packages/playwright/src/mode/declaration-hashed/declaration-hashed-card.tsx @@ -0,0 +1,23 @@ +import styles from './styles.module.css' + +type DeclarationHashedCardProps = { + label: string + testId: string +} + +export const selectors = styles + +export function DeclarationHashedCard({ label, testId }: DeclarationHashedCardProps) { + return ( +
+
+ {label} +

Declaration hashed selectors

+

+ The declaration-hashed module exports CSS module selectors for hashed class + names. +

+
+
+ ) +} diff --git a/packages/playwright/src/mode/declaration-hashed/host.ts b/packages/playwright/src/mode/declaration-hashed/host.ts new file mode 100644 index 0000000..7b8cd17 --- /dev/null +++ b/packages/playwright/src/mode/declaration-hashed/host.ts @@ -0,0 +1,64 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS } from 'lit' + +import { + DeclarationHashedCard, + knightedCss as declarationHashedCss, +} from './declaration-hashed-card.js' +import { + MODE_DECL_HASHED_HOST_TAG, + MODE_DECL_HASHED_SHADOW_TEST_ID, +} from '../constants.js' + +const hostShell = css` + :host { + display: block; + padding: 1.5rem; + border-radius: 1.5rem; + background: rgb(15, 23, 42); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); + } +` + +export class ModeDeclarationHashedHost extends LitElement { + static styles = [hostShell, unsafeCSS(declarationHashedCss)] + #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`<${DeclarationHashedCard} label="Shadow DOM" testId=${MODE_DECL_HASHED_SHADOW_TEST_ID} />`, + ) + } + + render() { + return html`
` + } +} + +export function ensureModeDeclarationHashedHostDefined(): void { + if (!customElements.get(MODE_DECL_HASHED_HOST_TAG)) { + customElements.define(MODE_DECL_HASHED_HOST_TAG, ModeDeclarationHashedHost) + } +} diff --git a/packages/playwright/src/mode/declaration-hashed/styles.module.css b/packages/playwright/src/mode/declaration-hashed/styles.module.css new file mode 100644 index 0000000..78ea6fc --- /dev/null +++ b/packages/playwright/src/mode/declaration-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/src/mode/declaration-stable/declaration-stable-card.tsx b/packages/playwright/src/mode/declaration-stable/declaration-stable-card.tsx new file mode 100644 index 0000000..95e32c9 --- /dev/null +++ b/packages/playwright/src/mode/declaration-stable/declaration-stable-card.tsx @@ -0,0 +1,32 @@ +import { mergeStableClass, stableClass } from '@knighted/css/stableSelectors' + +import styles from './styles.module.css' + +type DeclarationStableCardProps = { + label: string + testId: string +} + +export const stableSelectors = { + badge: stableClass('badge'), + card: stableClass('card'), + copy: stableClass('copy'), + stack: stableClass('stack'), + title: stableClass('title'), +} as const + +const mergedSelectors = mergeStableClass({ hashed: styles, selectors: stableSelectors }) + +export function DeclarationStableCard({ label, testId }: DeclarationStableCardProps) { + return ( +
+
+ {label} +

Declaration stable selectors

+

+ Auto-stable selectors keep hashed class names paired with deterministic tokens. +

+
+
+ ) +} diff --git a/packages/playwright/src/mode/declaration-stable/host.ts b/packages/playwright/src/mode/declaration-stable/host.ts new file mode 100644 index 0000000..81e7411 --- /dev/null +++ b/packages/playwright/src/mode/declaration-stable/host.ts @@ -0,0 +1,64 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS } from 'lit' + +import { + DeclarationStableCard, + knightedCss as declarationStableCss, +} from './declaration-stable-card.js' +import { + MODE_DECL_STABLE_HOST_TAG, + MODE_DECL_STABLE_SHADOW_TEST_ID, +} from '../constants.js' + +const hostShell = css` + :host { + display: block; + padding: 1.5rem; + border-radius: 1.5rem; + background: rgb(17, 24, 39); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); + } +` + +export class ModeDeclarationStableHost extends LitElement { + static styles = [hostShell, unsafeCSS(declarationStableCss)] + #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`<${DeclarationStableCard} label="Shadow DOM" testId=${MODE_DECL_STABLE_SHADOW_TEST_ID} />`, + ) + } + + render() { + return html`
` + } +} + +export function ensureModeDeclarationStableHostDefined(): void { + if (!customElements.get(MODE_DECL_STABLE_HOST_TAG)) { + customElements.define(MODE_DECL_STABLE_HOST_TAG, ModeDeclarationStableHost) + } +} diff --git a/packages/playwright/src/mode/declaration-stable/styles.module.css b/packages/playwright/src/mode/declaration-stable/styles.module.css new file mode 100644 index 0000000..07e9302 --- /dev/null +++ b/packages/playwright/src/mode/declaration-stable/styles.module.css @@ -0,0 +1,39 @@ +.card { + display: grid; + gap: 0.75rem; + padding: 1.5rem; + border-radius: 1.25rem; + background: #111827; + color: #e2e8f0; + box-shadow: 0 20px 50px rgba(15, 23, 42, 0.28); +} + +.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: #f472b6; + color: #111827; + text-transform: uppercase; + letter-spacing: 0.08em; +} + +.title { + margin: 0; + font-size: 1.25rem; + font-weight: 600; + color: #f9fafb; +} + +.copy { + margin: 0; + color: #cbd5f5; + line-height: 1.5; +} diff --git a/packages/playwright/src/mode/declaration-strict/strict-ok-card.tsx b/packages/playwright/src/mode/declaration-strict/strict-ok-card.tsx new file mode 100644 index 0000000..34eccef --- /dev/null +++ b/packages/playwright/src/mode/declaration-strict/strict-ok-card.tsx @@ -0,0 +1,17 @@ +import '../mode.css' + +type StrictCardProps = { + label: string + testId: string +} + +export function StrictOkCard({ label, testId }: StrictCardProps) { + return ( +
+

+ Declaration strict (manifest) +

+

{label}

+
+ ) +} diff --git a/packages/playwright/src/mode/declaration-strict/strict-skip-card.tsx b/packages/playwright/src/mode/declaration-strict/strict-skip-card.tsx new file mode 100644 index 0000000..0a7ddc5 --- /dev/null +++ b/packages/playwright/src/mode/declaration-strict/strict-skip-card.tsx @@ -0,0 +1,15 @@ +import '../mode.css' + +type StrictCardProps = { + label: string + testId: string +} + +export function StrictSkipCard({ label, testId }: StrictCardProps) { + return ( +
+

Declaration strict (skip)

+

{label}

+
+ ) +} diff --git a/packages/playwright/src/mode/declaration/declaration-card.tsx b/packages/playwright/src/mode/declaration/declaration-card.tsx new file mode 100644 index 0000000..bcb6bdf --- /dev/null +++ b/packages/playwright/src/mode/declaration/declaration-card.tsx @@ -0,0 +1,15 @@ +import '../mode.css' + +type DeclarationCardProps = { + label: string + testId: string +} + +export function DeclarationCard({ label, testId }: DeclarationCardProps) { + return ( +
+

Declaration mode

+

{label}

+
+ ) +} diff --git a/packages/playwright/src/mode/declaration/host.ts b/packages/playwright/src/mode/declaration/host.ts new file mode 100644 index 0000000..c071b9c --- /dev/null +++ b/packages/playwright/src/mode/declaration/host.ts @@ -0,0 +1,58 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS } from 'lit' + +import { DeclarationCard, knightedCss as declarationCss } from './declaration-card.js' +import { MODE_DECL_HOST_TAG, MODE_DECL_SHADOW_TEST_ID } from '../constants.js' + +const hostShell = css` + :host { + display: block; + padding: 1.5rem; + border-radius: 1.5rem; + background: rgb(15, 23, 42); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); + } +` + +export class ModeDeclarationHost extends LitElement { + static styles = [hostShell, unsafeCSS(declarationCss)] + #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`<${DeclarationCard} label="Shadow DOM" testId=${MODE_DECL_SHADOW_TEST_ID} />`, + ) + } + + render() { + return html`
` + } +} + +export function ensureModeDeclarationHostDefined(): void { + if (!customElements.get(MODE_DECL_HOST_TAG)) { + customElements.define(MODE_DECL_HOST_TAG, ModeDeclarationHost) + } +} diff --git a/packages/playwright/src/mode/index.ts b/packages/playwright/src/mode/index.ts new file mode 100644 index 0000000..6266eb1 --- /dev/null +++ b/packages/playwright/src/mode/index.ts @@ -0,0 +1,146 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot } from 'react-dom/client' +import type { JSX } from 'react' + +import { ModuleCard } from './module/module-card.js' +import { DeclarationCard } from './declaration/declaration-card.js' +import { + DeclarationHashedCard, + selectors as declarationHashedSelectors, +} from './declaration-hashed/declaration-hashed-card.js' +import { + DeclarationStableCard, + stableSelectors as declarationStableSelectors, +} from './declaration-stable/declaration-stable-card.js' +import { StrictOkCard } from './declaration-strict/strict-ok-card.js' +import { StrictSkipCard } from './declaration-strict/strict-skip-card.js' +import { + MODE_DECL_HOST_TAG, + MODE_DECL_HOST_TEST_ID, + MODE_DECL_HASHED_HOST_TAG, + MODE_DECL_HASHED_HOST_TEST_ID, + MODE_DECL_LIGHT_TEST_ID, + MODE_DECL_STABLE_HOST_TAG, + MODE_DECL_STABLE_HOST_TEST_ID, + MODE_MODULE_HOST_TAG, + MODE_MODULE_HOST_TEST_ID, + MODE_MODULE_LIGHT_TEST_ID, + MODE_DECL_HASHED_LIGHT_TEST_ID, + MODE_DECL_HASHED_SELECTOR_TEST_ID, + MODE_DECL_STABLE_LIGHT_TEST_ID, + MODE_DECL_STABLE_SELECTOR_TEST_ID, + MODE_DECL_STRICT_OK_PROBE_TEST_ID, + MODE_DECL_STRICT_OK_TEST_ID, + MODE_DECL_STRICT_SKIP_PROBE_TEST_ID, + MODE_DECL_STRICT_SKIP_TEST_ID, +} from './constants.js' +import { ensureModeModuleHostDefined } from './module/host.js' +import { ensureModeDeclarationHostDefined } from './declaration/host.js' +import { ensureModeDeclarationHashedHostDefined } from './declaration-hashed/host.js' +import { ensureModeDeclarationStableHostDefined } from './declaration-stable/host.js' + +function renderSection( + root: HTMLElement, + label: string, + Component: (props: { label: string; testId: string }) => JSX.Element, + testId: string, +): HTMLElement { + const section = document.createElement('section') + section.setAttribute('data-mode', label) + root.appendChild(section) + createRoot(section).render( + reactJsx`<${Component} label="Light DOM" testId=${testId} />`, + ) + return section +} + +export function renderModeDemo(): HTMLElement { + const root = document.getElementById('mode-app') ?? document.body + + renderSection(root, 'module', ModuleCard, MODE_MODULE_LIGHT_TEST_ID) + renderSection(root, 'declaration', DeclarationCard, MODE_DECL_LIGHT_TEST_ID) + renderSection( + root, + 'declaration-hashed', + DeclarationHashedCard, + MODE_DECL_HASHED_LIGHT_TEST_ID, + ) + renderSection( + root, + 'declaration-stable', + DeclarationStableCard, + MODE_DECL_STABLE_LIGHT_TEST_ID, + ) + + const strictSection = document.createElement('section') + strictSection.setAttribute('data-mode', 'declaration-strict') + root.appendChild(strictSection) + createRoot(strictSection).render( + reactJsx`<> + <${StrictOkCard} label="Strict OK" testId=${MODE_DECL_STRICT_OK_TEST_ID} /> + <${StrictSkipCard} label="Strict skip" testId=${MODE_DECL_STRICT_SKIP_TEST_ID} /> + `, + ) + + const hashedProbe = document.createElement('span') + hashedProbe.setAttribute('data-testid', MODE_DECL_HASHED_SELECTOR_TEST_ID) + hashedProbe.setAttribute('data-selector', declarationHashedSelectors.card ?? '') + root.appendChild(hashedProbe) + + const stableProbe = document.createElement('span') + stableProbe.setAttribute('data-testid', MODE_DECL_STABLE_SELECTOR_TEST_ID) + stableProbe.setAttribute('data-stable-selector', declarationStableSelectors.card ?? '') + root.appendChild(stableProbe) + + const strictOkProbe = document.createElement('span') + strictOkProbe.setAttribute('data-testid', MODE_DECL_STRICT_OK_PROBE_TEST_ID) + strictOkProbe.setAttribute('data-has-knighted-css', 'false') + root.appendChild(strictOkProbe) + + const strictSkipProbe = document.createElement('span') + strictSkipProbe.setAttribute('data-testid', MODE_DECL_STRICT_SKIP_PROBE_TEST_ID) + strictSkipProbe.setAttribute('data-has-knighted-css', 'false') + root.appendChild(strictSkipProbe) + + void Promise.all([ + import('./declaration-strict/strict-ok-card.js'), + import('./declaration-strict/strict-skip-card.js'), + ]).then(([okModule, skipModule]) => { + const hasKnightedCss = (value: unknown): value is { knightedCss: string } => + typeof value === 'object' && + value !== null && + 'knightedCss' in value && + typeof value.knightedCss === 'string' + strictOkProbe.setAttribute('data-has-knighted-css', String(hasKnightedCss(okModule))) + strictSkipProbe.setAttribute( + 'data-has-knighted-css', + String(hasKnightedCss(skipModule)), + ) + }) + + ensureModeModuleHostDefined() + const moduleHost = document.createElement(MODE_MODULE_HOST_TAG) + moduleHost.setAttribute('data-testid', MODE_MODULE_HOST_TEST_ID) + root.appendChild(moduleHost) + + ensureModeDeclarationHostDefined() + const declarationHost = document.createElement(MODE_DECL_HOST_TAG) + declarationHost.setAttribute('data-testid', MODE_DECL_HOST_TEST_ID) + root.appendChild(declarationHost) + + ensureModeDeclarationHashedHostDefined() + const declarationHashedHost = document.createElement(MODE_DECL_HASHED_HOST_TAG) + declarationHashedHost.setAttribute('data-testid', MODE_DECL_HASHED_HOST_TEST_ID) + root.appendChild(declarationHashedHost) + + ensureModeDeclarationStableHostDefined() + const declarationStableHost = document.createElement(MODE_DECL_STABLE_HOST_TAG) + declarationStableHost.setAttribute('data-testid', MODE_DECL_STABLE_HOST_TEST_ID) + root.appendChild(declarationStableHost) + + return root +} + +if (typeof document !== 'undefined') { + renderModeDemo() +} diff --git a/packages/playwright/src/mode/mode.css b/packages/playwright/src/mode/mode.css new file mode 100644 index 0000000..88267f3 --- /dev/null +++ b/packages/playwright/src/mode/mode.css @@ -0,0 +1,33 @@ +.knighted-mode-module-card, +.knighted-mode-declaration-card { + display: grid; + gap: 0.5rem; + padding: 1.5rem; + border-radius: 1rem; + background: rgb(30, 41, 59); + color: rgb(226, 232, 240); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.3); +} + +.knighted-mode-module-card__title, +.knighted-mode-declaration-card__title { + margin: 0; + font-size: 1.1rem; + color: rgb(129, 140, 248); +} + +.knighted-mode-module-card__copy, +.knighted-mode-declaration-card__copy { + margin: 0; + font-size: 0.95rem; + color: rgb(226, 232, 240); +} + +section[data-mode] { + display: grid; + gap: 0.75rem; + padding: 1.5rem; + border-radius: 1.25rem; + background: rgb(15, 23, 42); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); +} diff --git a/packages/playwright/src/mode/module/host.ts b/packages/playwright/src/mode/module/host.ts new file mode 100644 index 0000000..b531f6b --- /dev/null +++ b/packages/playwright/src/mode/module/host.ts @@ -0,0 +1,58 @@ +import { reactJsx } from '@knighted/jsx/react' +import { createRoot, type Root } from 'react-dom/client' +import { LitElement, css, html, unsafeCSS } from 'lit' + +import { knightedCss as moduleCss, ModuleCard } from './module-card.knighted-css.js' +import { MODE_MODULE_HOST_TAG, MODE_MODULE_SHADOW_TEST_ID } from '../constants.js' + +const hostShell = css` + :host { + display: block; + padding: 1.5rem; + border-radius: 1.5rem; + background: rgb(15, 23, 42); + box-shadow: inset 0 0 0 1px rgba(148, 163, 184, 0.2); + } +` + +export class ModeModuleHost extends LitElement { + static styles = [hostShell, unsafeCSS(moduleCss)] + #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`<${ModuleCard} label="Shadow DOM" testId=${MODE_MODULE_SHADOW_TEST_ID} />`, + ) + } + + render() { + return html`
` + } +} + +export function ensureModeModuleHostDefined(): void { + if (!customElements.get(MODE_MODULE_HOST_TAG)) { + customElements.define(MODE_MODULE_HOST_TAG, ModeModuleHost) + } +} diff --git a/packages/playwright/src/mode/module/module-card.tsx b/packages/playwright/src/mode/module/module-card.tsx new file mode 100644 index 0000000..fa0992f --- /dev/null +++ b/packages/playwright/src/mode/module/module-card.tsx @@ -0,0 +1,15 @@ +import '../mode.css' + +type ModuleCardProps = { + label: string + testId: string +} + +export function ModuleCard({ label, testId }: ModuleCardProps) { + return ( +
+

Module mode

+

{label}

+
+ ) +} diff --git a/packages/playwright/test/mode.spec.ts b/packages/playwright/test/mode.spec.ts new file mode 100644 index 0000000..a93f23b --- /dev/null +++ b/packages/playwright/test/mode.spec.ts @@ -0,0 +1,172 @@ +import { expect, test } from '@playwright/test' + +import { + MODE_DECL_HOST_TEST_ID, + MODE_DECL_HASHED_SELECTOR_TEST_ID, + MODE_DECL_HASHED_HOST_TEST_ID, + MODE_DECL_HASHED_SHADOW_TEST_ID, + MODE_DECL_HASHED_LIGHT_TEST_ID, + MODE_DECL_LIGHT_TEST_ID, + MODE_DECL_STABLE_SELECTOR_TEST_ID, + MODE_DECL_STABLE_HOST_TEST_ID, + MODE_DECL_STABLE_SHADOW_TEST_ID, + MODE_DECL_STABLE_LIGHT_TEST_ID, + MODE_DECL_STRICT_OK_PROBE_TEST_ID, + MODE_DECL_STRICT_SKIP_PROBE_TEST_ID, + MODE_DECL_SHADOW_TEST_ID, + MODE_MODULE_HOST_TEST_ID, + MODE_MODULE_LIGHT_TEST_ID, + MODE_MODULE_SHADOW_TEST_ID, +} from '../src/mode/constants.js' + +type CardMetrics = { + background: string + color: string + borderRadius: string +} + +async function readMetrics( + handle: import('@playwright/test').Locator, +): Promise { + return handle.evaluate(node => { + const el = node as HTMLElement + const style = getComputedStyle(el) + return { + background: style.getPropertyValue('background-color').trim(), + color: style.getPropertyValue('color').trim(), + borderRadius: style.getPropertyValue('border-top-left-radius').trim(), + } + }) +} + +async function readShadowMetrics( + page: import('@playwright/test').Page, + hostId: string, + cardId: string, +): Promise { + const handle = await page.waitForFunction( + ({ hostId, cardId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(`[data-testid="${cardId}"]`) + }, + { hostId, cardId }, + ) + await handle.dispose() + + return page.evaluate( + ({ hostId, cardId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + const card = hostEl?.shadowRoot?.querySelector(`[data-testid="${cardId}"]`) + if (!card) { + throw new Error('Shadow DOM card was not rendered') + } + const style = getComputedStyle(card as HTMLElement) + return { + background: style.getPropertyValue('background-color').trim(), + color: style.getPropertyValue('color').trim(), + borderRadius: style.getPropertyValue('border-top-left-radius').trim(), + } + }, + { hostId, cardId }, + ) +} + +test.describe('mode resolver fixture', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/mode.html') + }) + + test('module mode light and shadow styles match', async ({ page }) => { + const lightCard = page.getByTestId(MODE_MODULE_LIGHT_TEST_ID) + await expect(lightCard).toBeVisible() + const lightMetrics = await readMetrics(lightCard) + + const host = page.getByTestId(MODE_MODULE_HOST_TEST_ID) + await expect(host).toBeVisible() + + const shadowMetrics = await readShadowMetrics( + page, + MODE_MODULE_HOST_TEST_ID, + MODE_MODULE_SHADOW_TEST_ID, + ) + + expect(shadowMetrics.background).toBe(lightMetrics.background) + expect(shadowMetrics.color).toBe(lightMetrics.color) + expect(shadowMetrics.borderRadius).toBe(lightMetrics.borderRadius) + }) + + test('declaration mode light and shadow styles match', async ({ page }) => { + const lightCard = page.getByTestId(MODE_DECL_LIGHT_TEST_ID) + await expect(lightCard).toBeVisible() + const lightMetrics = await readMetrics(lightCard) + + const host = page.getByTestId(MODE_DECL_HOST_TEST_ID) + await expect(host).toBeVisible() + + const shadowMetrics = await readShadowMetrics( + page, + MODE_DECL_HOST_TEST_ID, + MODE_DECL_SHADOW_TEST_ID, + ) + + expect(shadowMetrics.background).toBe(lightMetrics.background) + expect(shadowMetrics.color).toBe(lightMetrics.color) + expect(shadowMetrics.borderRadius).toBe(lightMetrics.borderRadius) + }) + + test('declaration hashed selectors are hashed', async ({ page }) => { + const probe = page.getByTestId(MODE_DECL_HASHED_SELECTOR_TEST_ID) + await expect(probe).toHaveAttribute('data-selector', /.+/) + const selector = await probe.getAttribute('data-selector') + expect(selector).toBeTruthy() + expect(selector).not.toBe('card') + expect(selector).not.toBe('knighted-card') + }) + + test('declaration stable selectors are stable', async ({ page }) => { + const probe = page.getByTestId(MODE_DECL_STABLE_SELECTOR_TEST_ID) + await expect(probe).toHaveAttribute('data-stable-selector', /.+/) + const selector = await probe.getAttribute('data-stable-selector') + expect(selector).toBe('knighted-card') + }) + + test('declaration hashed light and shadow styles match', async ({ page }) => { + const lightCard = page.getByTestId(MODE_DECL_HASHED_LIGHT_TEST_ID) + await expect(lightCard).toBeVisible() + const lightMetrics = await readMetrics(lightCard) + + const shadowMetrics = await readShadowMetrics( + page, + MODE_DECL_HASHED_HOST_TEST_ID, + MODE_DECL_HASHED_SHADOW_TEST_ID, + ) + + expect(shadowMetrics.background).toBe(lightMetrics.background) + expect(shadowMetrics.color).toBe(lightMetrics.color) + expect(shadowMetrics.borderRadius).toBe(lightMetrics.borderRadius) + }) + + test('declaration stable light and shadow styles match', async ({ page }) => { + const lightCard = page.getByTestId(MODE_DECL_STABLE_LIGHT_TEST_ID) + await expect(lightCard).toBeVisible() + const lightMetrics = await readMetrics(lightCard) + + const shadowMetrics = await readShadowMetrics( + page, + MODE_DECL_STABLE_HOST_TEST_ID, + MODE_DECL_STABLE_SHADOW_TEST_ID, + ) + + expect(shadowMetrics.background).toBe(lightMetrics.background) + expect(shadowMetrics.color).toBe(lightMetrics.color) + expect(shadowMetrics.borderRadius).toBe(lightMetrics.borderRadius) + }) + + test('declaration strict manifest rewrites only matched modules', async ({ page }) => { + const okProbe = page.getByTestId(MODE_DECL_STRICT_OK_PROBE_TEST_ID) + const skipProbe = page.getByTestId(MODE_DECL_STRICT_SKIP_PROBE_TEST_ID) + + await expect(okProbe).toHaveAttribute('data-has-knighted-css', 'true') + await expect(skipProbe).toHaveAttribute('data-has-knighted-css', 'false') + }) +})