diff --git a/.github/instructions/pr-review.md b/.github/instructions/pr-review.md new file mode 100644 index 0000000..8cec188 --- /dev/null +++ b/.github/instructions/pr-review.md @@ -0,0 +1,18 @@ +--- +name: PR review instructions +description: Guidance for Copilot when reviewing pull requests. +applyTo: '**/*' +--- + +When reviewing PRs in this repository: + +- Focus on correctness, edge cases, and TypeScript type safety. +- Call out potential bugs, duplicated logic, and opportunities to simplify. +- Verify generated artifacts (dist, coverage, test-results, .knighted-css) are not modified. +- Prefer minimal, localized changes and preserve existing public APIs. +- Check for new or missing tests when behavior changes. +- Flag NodeNext/ESM import correctness, especially extension handling. +- Call out performance regressions in graph walking or CSS extraction. +- Note any security or path traversal risks in resolver or loader changes. +- Ensure docs/readme updates match behavior changes when user-facing. +- Summarize risks and required follow-ups clearly. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b211163..cecead3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: run: npm run check:cycles - name: Test run: npm test + - name: Generate Playwright Types + run: npm run types -w @knighted/css-playwright-fixture - name: Check Types run: npm run check-types - name: Report Coverage diff --git a/.gitignore b/.gitignore index 825b09e..64aa2a7 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,4 @@ test-results/ blob-report/ .knighted-css/ .knighted-css-auto/ +packages/playwright/src/**/*.knighted-css.ts diff --git a/AGENTS.md b/AGENTS.md index 5e6ece5..eef560e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -51,6 +51,7 @@ Workspace-scoped (preferred when changing @knighted/css only): - ESM only (`type: module`). - Prettier: single quotes, no semicolons, `printWidth: 90`, `arrowParens: avoid`. - Keep functions small and side-effect aware; prefer pure helpers where possible. +- Prefer multiline comment style (`/* ... */`) when a comment spans more than one line. ### Example style (good) diff --git a/docs/combined-queries.md b/docs/combined-queries.md index a40e0c6..8a6c57a 100644 --- a/docs/combined-queries.md +++ b/docs/combined-queries.md @@ -1,12 +1,12 @@ # Knighted CSS Combined Loader Reference -This document summarizes how `?knighted-css&combined` behaves for different module export shapes and how to structure your imports accordingly. Use it as guidance when filing documentation feedback for `@knighted/css`. +This document summarizes how `?knighted-css&combined` behaves for different module export shapes and how to structure your imports accordingly, plus how the double-extension proxy import replaces most combined-query use cases. > [!NOTE] -> TypeScript reads literal selector tokens from the generated `.knighted-css.ts` modules (emitted by `knighted-css-generate-types`). Append `&types` to combined imports only when you also need `stableSelectors` at runtime—the loader still exports the map, while the double-extension modules keep your editors in sync. +> TypeScript reads literal selector tokens from the generated `.knighted-css.ts` modules (emitted by `knighted-css-generate-types`). The double-extension modules also re-export the original module exports and `knightedCss`, so a single import can provide component exports, typed selectors, and the compiled stylesheet string without helper casts. > [!TIP] -> Prefer importing `asKnightedCssCombinedModule` from `@knighted/css/loader-helpers` when you want a runtime helper—the file has zero Node dependencies, so both browser and Node builds stay green. +> Prefer the double-extension import when you run `knighted-css-generate-types`—it removes the need for `asKnightedCssCombinedModule`. Use the helper only for `?knighted-css&combined` queries or when you cannot rely on generated proxy modules. ## Decision Matrix @@ -43,6 +43,12 @@ const { Component, knightedCss } = > [!NOTE] > Namespace imports (`import * as combined …`) are the most reliable pattern for `&named-only` queries because you intentionally drop the default export. Keep using the helper type to narrow the namespace. +If you run `knighted-css-generate-types`, use the unified proxy import instead: + +```ts +import { Component, knightedCss, stableSelectors } from './module.knighted-css.js' +``` + ## Default export only _Use when your component only exposes a default export and you want `knightedCss` beside it._ @@ -55,6 +61,12 @@ const { default: Component, knightedCss } = asKnightedCssCombinedModule(combinedModule) ``` +With generated proxies, use a single import instead: + +```ts +import Component, { knightedCss, stableSelectors } from './module.knighted-css.js' +``` + ## Default and named exports _Use when you consume both the default export and its named helpers from the same module._ @@ -70,12 +82,20 @@ const { } = asKnightedCssCombinedModule(combinedModule) ``` +With generated proxies, a single import covers everything: + +```ts +import Component, { helper, knightedCss, stableSelectors } from './module.knighted-css.js' +``` + Prefer `?knighted-css&combined&named-only` plus the [named exports only](#named-exports-only) snippet when you intentionally avoid default exports but still need the named members and `knightedCss`. ## Adding stable selectors (`&types`) Append `&types` whenever you need the runtime `stableSelectors` map in addition to `knightedCss`. Configure the loader’s `stableNamespace` option (or pass `--stable-namespace` to the CLI) so runtime exports and generated `.knighted-css.ts` modules stay aligned. +If you run `knighted-css-generate-types`, the double-extension proxy already exports `stableSelectors` and keeps the literal types in sync—no `&types` query or helper required. + ### Named exports with stable selectors _Use when you only consume named exports (no default) but still want typed `stableSelectors`._ @@ -93,6 +113,12 @@ const { Component, knightedCss, stableSelectors } = asKnightedCssCombinedModule< stableSelectors.card // "knighted-card" ``` +With generated proxies, use a single import instead: + +```ts +import { Component, knightedCss, stableSelectors } from './module.knighted-css.js' +``` + > [!TIP] > Add `&named-only` before `&types` to drop the synthetic default export while still receiving `stableSelectors`. @@ -117,6 +143,12 @@ const { stableSelectors.badge // "knighted-badge" ``` +With generated proxies, use a single import instead: + +```ts +import Component, { knightedCss, stableSelectors } from './module.knighted-css.js' +``` + ### Default and named exports with stable selectors _Use when you consume every export from the module and still want the selector map._ @@ -139,16 +171,22 @@ const { stableSelectors.card // "knighted-card" (or your configured namespace) ``` +With generated proxies, a single import covers everything: + +```ts +import Component, { helper, knightedCss, stableSelectors } from './module.knighted-css.js' +``` + ## Key Takeaways - The loader always injects `knightedCss` alongside the module’s exports. -- To avoid synthetic defaults (and TypeScript warnings) for modules that only expose named exports, add `&named-only` and use a namespace import. -- Namespace imports plus `KnightedCssCombinedModule` work universally; default imports are optional conveniences when the source module exposes a default you actually consume. -- Add `&types` when you also need the `stableSelectors` map. Configure the namespace globally (loader option or CLI flag) so runtime + generated types stay consistent. +- When you run `knighted-css-generate-types`, prefer the double-extension proxy import to get component exports, `knightedCss`, and `stableSelectors` in one place. +- Use `?knighted-css&combined` (plus `asKnightedCssCombinedModule`) when you need runtime combined imports without generated proxy modules. +- Add `&types` only when you need a runtime selector map and cannot rely on the generated proxy files. ## When to use `KnightedCssCombinedModule` -The helper type still earns its keep when you need to narrow combined results without `asKnightedCssCombinedModule` (for example, in test doubles or custom wrappers): +The helper type still earns its keep when you need to narrow combined results without `asKnightedCssCombinedModule` (for example, in test doubles, custom wrappers, or when you skip generated proxies): ```ts import type { KnightedCssCombinedModule } from '@knighted/css/loader' diff --git a/docs/type-generation.md b/docs/type-generation.md index 6a15327..e87a48c 100644 --- a/docs/type-generation.md +++ b/docs/type-generation.md @@ -1,6 +1,6 @@ # Type generation (`*.knighted-css*`) -Use the `knighted-css-generate-types` CLI to create selector manifests that TypeScript can import. The CLI scans for specifiers ending in `.knighted-css` (for example `./button.module.scss.knighted-css.ts`), compiles the stylesheet once, and writes a sibling module that exports the literal selector tokens. +Use the `knighted-css-generate-types` CLI to generate modules for `.knighted-css` double-extension imports. For stylesheets, it emits a sibling module with literal selector tokens. For JavaScript/TypeScript module specifiers, the generated file acts as a unified proxy that re-exports the module’s exports plus `knightedCss`. ## Running the CLI @@ -20,6 +20,21 @@ Typical script entry: Wire it into `postinstall` or your build so new selectors land automatically. +### Options + +- `--root` / `-r` – project root (defaults to `process.cwd()`). +- `--include` / `-i` – additional directories or files to scan (repeatable). +- `--out-dir` – directory for the selector module manifest cache (defaults to `/.knighted-css`). +- `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime. +- `--auto-stable` – enable auto-stable selector generation during extraction (mirrors the loader’s auto-stable behavior). + +### Relationship to the loader + +- `.knighted-css*` imports include the generated selector map and, for module specifiers, re-exports plus `knightedCss`. +- `?knighted-css` imports are purely runtime (see [docs/loader.md](./loader.md)). Append `&types` only when you also need the selector map at runtime; the compiler still reads the literal tokens from the generated modules. + +For CSS Modules or Sass files that need stable selectors, import the generated `.knighted-css` module for types. For JS/TS component modules, the generated proxy already provides `knightedCss` alongside the exports, so you can rely on the proxy in place of separate `?knighted-css` runtime imports when appropriate. + ## Minimal usage ```ts @@ -28,18 +43,50 @@ import selectors from './button.module.scss.knighted-css.js' selectors.card // "knighted-card" ``` +## Unified proxy usage (module exports + CSS + selectors) + +```ts +import Button, { knightedCss, stableSelectors } from './button.knighted-css.js' + +stableSelectors.card // "knighted-card" +knightedCss // compiled CSS string +``` + Because the generated module lives next to the source stylesheet, TypeScript’s normal resolution logic applies—no custom `paths` entries required. Use the manifest in conjunction with runtime helpers such as `mergeStableClass` or `stableClassName` to keep hashed class names in sync. -### Options +## Rspack watch hook -- `--root` / `-r` – project root (defaults to `process.cwd()`). -- `--include` / `-i` – additional directories or files to scan (repeatable). -- `--out-dir` – directory for the selector module manifest cache (defaults to `/.knighted-css`). -- `--stable-namespace` – namespace prefix shared by the generated selector maps and loader runtime. +If you want the CLI to rerun during dev, hook it into Rspack’s watch pipeline. This keeps the generated `.knighted-css` proxy modules in sync whenever source files change. You can also scope the `--include` list using `compiler.modifiedFiles` to avoid rescanning the entire project on every rebuild. -### Relationship to the loader +```js +// rspack.config.js +import { exec } from 'node:child_process' -- `.knighted-css*` imports are purely for types; they never include the compiled CSS string. -- `?knighted-css` imports are purely runtime (see [docs/loader.md](./loader.md)). Append `&types` only when you also need the selector map at runtime; the compiler still reads the literal tokens from the generated modules. +export default { + // ... your existing config + plugins: [ + { + apply(compiler) { + compiler.hooks.watchRun.tapPromise('knighted-css-generate-types', () => { + const modified = Array.from(compiler.modifiedFiles ?? []) + const includes = modified.length > 0 ? modified : ['src'] + const includeArgs = includes.flatMap(entry => ['--include', entry]) + const command = ['knighted-css-generate-types', '--root', '.', ...includeArgs] + + return new Promise((resolve, reject) => { + exec(command.join(' '), error => { + if (error) { + reject(error) + return + } + resolve() + }) + }) + }) + }, + }, + ], +} +``` -Keep both hooks in mind when authoring CSS Modules or Sass files that need stable selectors: import the generated module for types, and import the loader query when you need the runtime stylesheet. +Scope the `--include` paths to the folders that actually import `.knighted-css` to keep the watch step fast. When `modifiedFiles` is empty (for example on the first run), fall back to a stable include root like `src`. diff --git a/packages/css/README.md b/packages/css/README.md index 5b66d09..986c911 100644 --- a/packages/css/README.md +++ b/packages/css/README.md @@ -113,6 +113,12 @@ Run `knighted-css-generate-types` so every specifier that ends with `.knighted-c import stableSelectors from './button.module.scss.knighted-css.js' ``` +When the `.knighted-css` import targets a JavaScript/TypeScript module, the generated proxy also re-exports the module’s exports and `knightedCss`, so a single import can provide component exports, typed selectors, and the compiled stylesheet string: + +```ts +import Button, { knightedCss, stableSelectors } from './button.knighted-css.js' +``` + Refer to [docs/type-generation.md](../../docs/type-generation.md) for CLI options and workflow tips. ### Combined + runtime selectors @@ -136,6 +142,9 @@ const { stableSelectors.shell ``` +> [!TIP] +> If you run `knighted-css-generate-types`, prefer the double-extension proxy import shown above instead of `?knighted-css&combined` and `asKnightedCssCombinedModule`. + > [!NOTE] > `stableSelectors` here is for runtime use; TypeScript still reads literal tokens from the generated `.knighted-css.*` modules. For a full decision matrix, see [docs/combined-queries.md](../../docs/combined-queries.md). > Prefer importing `asKnightedCssCombinedModule` from `@knighted/css/loader-helpers` instead of grabbing it from `@knighted/css/loader`—the helper lives in a Node-free chunk so both browser and server bundles stay happy. diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index 3a9f204..6b769fe 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -9,8 +9,8 @@ import { moduleType } from 'node-module-type' import { getTsconfig, type TsConfigResult } from 'get-tsconfig' import { createMatchPath, type MatchPath } from 'tsconfig-paths' -import { cssWithMeta } from './css.js' -import { analyzeModule } from './lexer.js' +import { cssWithMeta, DEFAULT_EXTENSIONS } from './css.js' +import { analyzeModule, type DefaultExportSignal } from './lexer.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' @@ -31,6 +31,11 @@ interface TsconfigResolutionContext { matchPath?: MatchPath } +interface SelectorModuleProxyInfo { + moduleSpecifier: string + includeDefault: boolean +} + type CssWithMetaFn = typeof cssWithMeta let activeCssWithMeta: CssWithMetaFn = cssWithMeta @@ -111,6 +116,13 @@ function getImportMetaUrl(): string | undefined { const SELECTOR_REFERENCE = '.knighted-css' const SELECTOR_MODULE_SUFFIX = '.knighted-css.ts' +const STYLE_EXTENSIONS = DEFAULT_EXTENSIONS.map(ext => ext.toLowerCase()) +const EXTENSION_FALLBACKS: Record = { + '.js': ['.ts', '.tsx', '.jsx', '.mjs', '.cjs'], + '.mjs': ['.mts', '.mjs', '.js', '.ts', '.tsx'], + '.cjs': ['.cts', '.cjs', '.js', '.ts', '.tsx'], + '.jsx': ['.tsx', '.jsx'], +} export async function generateTypes( options: GenerateTypesOptions = {}, @@ -144,6 +156,7 @@ async function generateDeclarations( const nextSelectorManifest: SelectorModuleManifest = {} const selectorCache = new Map>() const processedSelectors = new Set() + const proxyInfoCache = new Map() const warnings: string[] = [] let selectorModuleWrites = 0 @@ -211,11 +224,18 @@ async function generateDeclarations( if (processedSelectors.has(manifestKey)) { continue } + const proxyInfo = await resolveProxyInfo( + manifestKey, + selectorSource, + resolvedPath, + proxyInfoCache, + ) const moduleWrite = await ensureSelectorModule( resolvedPath, selectorMap, previousSelectorManifest, nextSelectorManifest, + proxyInfo ?? undefined, ) if (moduleWrite) { selectorModuleWrites += 1 @@ -354,6 +374,13 @@ function extractSelectorSourceSpecifier(specifier: string): string | undefined { if (!base) { return undefined } + /** + * Handles specifiers like "./entry.knighted-css.ts" where the base has no + * extension but the selector suffix includes one. + */ + if (suffix && !path.extname(base)) { + return `${base}${suffix}` + } return base } @@ -367,14 +394,16 @@ async function resolveImportPath( ): Promise { if (!resourceSpecifier) return undefined if (resourceSpecifier.startsWith('.')) { - return path.resolve(path.dirname(importerPath), resourceSpecifier) + return resolveWithExtensionFallback( + path.resolve(path.dirname(importerPath), resourceSpecifier), + ) } if (resourceSpecifier.startsWith('/')) { - return path.resolve(rootDir, resourceSpecifier.slice(1)) + return resolveWithExtensionFallback(path.resolve(rootDir, resourceSpecifier.slice(1))) } const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig) if (tsconfigResolved) { - return tsconfigResolved + return resolveWithExtensionFallback(tsconfigResolved) } const requireFromRoot = getProjectRequire(rootDir) try { @@ -389,11 +418,19 @@ function buildSelectorModuleManifestKey(resolvedPath: string): string { } function buildSelectorModulePath(resolvedPath: string): string { - return `${resolvedPath}${SELECTOR_MODULE_SUFFIX}` + if (isStyleResource(resolvedPath)) { + return `${resolvedPath}${SELECTOR_MODULE_SUFFIX}` + } + const ext = path.extname(resolvedPath) + const base = ext ? resolvedPath.slice(0, -ext.length) : resolvedPath + return `${base}${SELECTOR_MODULE_SUFFIX}` } -function formatSelectorModuleSource(selectors: Map): string { - const header = '// Generated by @knighted/css/generate-types\n// Do not edit.\n' +function formatSelectorModuleSource( + selectors: Map, + proxyInfo?: SelectorModuleProxyInfo, +): string { + const header = '// Generated by @knighted/css/generate-types\n// Do not edit.\n\n' const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b)) const lines = entries.map( ([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`, @@ -404,14 +441,23 @@ function formatSelectorModuleSource(selectors: Map): string { ${lines.join('\n')} } as const` : '{} as const' - return `${header} + const proxyLines: string[] = [] + if (proxyInfo) { + proxyLines.push(`export * from '${proxyInfo.moduleSpecifier}'`) + if (proxyInfo.includeDefault) { + proxyLines.push(`export { default } from '${proxyInfo.moduleSpecifier}'`) + } + proxyLines.push( + `export { knightedCss } from '${proxyInfo.moduleSpecifier}?knighted-css'`, + ) + proxyLines.push('') + } + const defaultExport = proxyInfo ? '' : '\nexport default stableSelectors\n' + return `${header}${proxyLines.join('\n')} export const stableSelectors = ${literal} export type KnightedCssStableSelectors = typeof stableSelectors -export type KnightedCssStableSelectorToken = keyof typeof stableSelectors - -export default stableSelectors -` +export type KnightedCssStableSelectorToken = keyof typeof stableSelectors${defaultExport}` } function hashContent(content: string): string { @@ -472,10 +518,11 @@ async function ensureSelectorModule( selectors: Map, previousManifest: SelectorModuleManifest, nextManifest: SelectorModuleManifest, + proxyInfo?: SelectorModuleProxyInfo, ): Promise { const manifestKey = buildSelectorModuleManifestKey(resolvedPath) const targetPath = buildSelectorModulePath(resolvedPath) - const source = formatSelectorModuleSource(selectors) + const source = formatSelectorModuleSource(selectors, proxyInfo) const hash = hashContent(source) const previousEntry = previousManifest[manifestKey] const needsWrite = previousEntry?.hash !== hash || !(await fileExists(targetPath)) @@ -520,6 +567,71 @@ async function resolveWithTsconfigPaths( return undefined } +async function resolveWithExtensionFallback( + candidatePath: string, +): Promise { + try { + const stat = await fs.stat(candidatePath) + if (stat.isFile()) { + return candidatePath + } + } catch { + // continue to resolution fallbacks + } + const ext = path.extname(candidatePath) + const base = ext ? candidatePath.slice(0, -ext.length) : candidatePath + if (!ext) { + const resolved = await resolveWithExtensionList( + base, + Array.from(SUPPORTED_EXTENSIONS), + ) + if (resolved) { + return resolved + } + } + if (ext && EXTENSION_FALLBACKS[ext]) { + const resolved = await resolveWithExtensionList(base, EXTENSION_FALLBACKS[ext]) + if (resolved) { + return resolved + } + } + const indexResolved = await resolveIndexFallback(candidatePath) + if (indexResolved) { + return indexResolved + } + /* + * Return the original candidate to preserve existing behavior when nothing + * resolves (callers may still want a best-effort path for warnings). + */ + return candidatePath +} + +async function resolveWithExtensionList( + base: string, + extensions: string[], +): Promise { + for (const extension of extensions) { + const candidate = `${base}${extension}` + if (await fileExists(candidate)) { + return candidate + } + } + return undefined +} + +async function resolveIndexFallback(candidatePath: string): Promise { + try { + const stat = await fs.stat(candidatePath) + if (!stat.isDirectory()) { + return undefined + } + } catch { + return undefined + } + const base = path.join(candidatePath, 'index') + return resolveWithExtensionList(base, Array.from(SUPPORTED_EXTENSIONS)) +} + function loadTsconfigResolutionContext( rootDir: string, loader: typeof getTsconfig = getTsconfig, @@ -582,6 +694,51 @@ function isNonRelativeSpecifier(specifier: string): boolean { return true } +function isStyleResource(filePath: string): boolean { + const normalized = filePath.toLowerCase() + return STYLE_EXTENSIONS.some(ext => normalized.endsWith(ext)) +} + +async function resolveProxyInfo( + manifestKey: string, + selectorSource: string, + resolvedPath: string, + cache: Map, +): Promise { + if (isStyleResource(resolvedPath)) { + return null + } + const cached = cache.get(manifestKey) + if (cached !== undefined) { + return cached + } + const defaultSignal = await getDefaultExportSignal(resolvedPath) + const proxyInfo = { + moduleSpecifier: buildProxyModuleSpecifier(resolvedPath, selectorSource), + includeDefault: defaultSignal === 'has-default', + } + cache.set(manifestKey, proxyInfo) + return proxyInfo +} + +function buildProxyModuleSpecifier(resolvedPath: string, selectorSource: string): string { + const resolvedExt = path.extname(resolvedPath) + const baseName = path.basename(resolvedPath, resolvedExt) + const selectorExt = path.extname(selectorSource) + const fileName = selectorExt ? `${baseName}${selectorExt}` : `${baseName}.js` + return `./${fileName}` +} + +async function getDefaultExportSignal(filePath: string): Promise { + try { + const source = await fs.readFile(filePath, 'utf8') + const analysis = await analyzeModule(source, filePath) + return analysis.defaultSignal + } catch { + return 'unknown' + } +} + function createProjectPeerResolver(rootDir: string) { const resolver = getProjectRequire(rootDir) return async (name: string) => { @@ -755,6 +912,8 @@ export const __generateTypesInternals = { setModuleTypeDetector, setImportMetaUrlProvider, isNonRelativeSpecifier, + isStyleResource, + resolveWithExtensionFallback, createProjectPeerResolver, getProjectRequire, loadTsconfigResolutionContext, diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 985609f..a96e72f 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -196,9 +196,13 @@ test('generateTypes emits declarations and reuses cache', async () => { project.root, 'src', 'fixture', - 'entry.js.knighted-css.ts', + 'entry.knighted-css.ts', ) const selectorModule = await fs.readFile(selectorModulePath, 'utf8') + assert.ok(selectorModule.includes("export * from './entry.js'")) + assert.ok( + selectorModule.includes("export { knightedCss } from './entry.js?knighted-css'"), + ) assert.ok(selectorModule.includes('export const stableSelectors')) assert.ok(selectorModule.includes('"demo": "knighted-demo"')) @@ -500,6 +504,71 @@ test('generateTypes discovers selector imports in tsx via oxc fallback', async ( } }) +test('generateTypes emits unified proxy modules for JS/TS specifiers', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-proxy-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + + await fs.writeFile( + path.join(srcDir, 'styles.css'), + '.card { color: teal; }\n.knighted-card { color: rebeccapurple; }\n', + ) + + await fs.writeFile( + path.join(srcDir, 'button.ts'), + "import './styles.css'\nexport default function Button() { return null }\n", + ) + + await fs.writeFile( + path.join(srcDir, 'helper.ts'), + "import './styles.css'\nexport const helper = () => 'ok'\n", + ) + + await fs.writeFile( + path.join(srcDir, 'entry.ts'), + "import { knightedCss, stableSelectors } from './button.knighted-css'\n" + + "import { helper } from './helper.knighted-css'\n" + + 'console.log(knightedCss.length, stableSelectors.card, helper)\n', + ) + + await fs.writeFile( + path.join(srcDir, 'fancy.ts'), + "import './styles.css'\nexport default function Fancy() { return null }\n", + ) + + await fs.writeFile( + path.join(srcDir, 'entry-2.ts'), + "import { knightedCss } from './fancy.knighted-css.js'\n" + + 'console.log(knightedCss.length)\n', + ) + + const result = await generateTypes({ rootDir: root, include: ['src'] }) + assert.equal(result.warnings.length, 0) + + const buttonProxyPath = path.join(srcDir, 'button.knighted-css.ts') + const buttonProxy = await fs.readFile(buttonProxyPath, 'utf8') + assert.ok(buttonProxy.includes("export * from './button.js'")) + assert.ok(buttonProxy.includes("export { default } from './button.js'")) + assert.ok( + buttonProxy.includes("export { knightedCss } from './button.js?knighted-css'"), + ) + assert.ok(buttonProxy.includes('"card": "knighted-card"')) + + const helperProxyPath = path.join(srcDir, 'helper.knighted-css.ts') + const helperProxy = await fs.readFile(helperProxyPath, 'utf8') + assert.ok(helperProxy.includes("export * from './helper.js'")) + assert.ok(!helperProxy.includes("export { default } from './helper.js'")) + + const fancyProxyPath = path.join(srcDir, 'fancy.knighted-css.ts') + const fancyProxy = await fs.readFile(fancyProxyPath, 'utf8') + assert.ok(fancyProxy.includes("export * from './fancy.js'")) + assert.ok(fancyProxy.includes("export { default } from './fancy.js'")) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('runGenerateTypesCli executes generation and reports summaries', async () => { const project = await setupFixtureProject() try { @@ -560,6 +629,8 @@ test('generateTypes internals support selector module helpers', async () => { setImportMetaUrlProvider, relativeToRoot, isNonRelativeSpecifier, + isStyleResource, + resolveWithExtensionFallback, createProjectPeerResolver, getProjectRequire, loadTsconfigResolutionContext, @@ -585,6 +656,8 @@ test('generateTypes internals support selector module helpers', async () => { assert.equal(extractSelectorSourceSpecifier('./demo.ts.knighted-css'), './demo.ts') assert.equal(extractSelectorSourceSpecifier('./demo.ts.knighted-css.ts'), './demo.ts') assert.equal(extractSelectorSourceSpecifier('./demo.ts'), undefined) + assert.equal(extractSelectorSourceSpecifier('.knighted-css'), undefined) + assert.equal(extractSelectorSourceSpecifier('./demo.knighted-css.css'), undefined) const selectorMap = new Map([ ['beta', 'knighted-beta'], @@ -594,10 +667,21 @@ test('generateTypes internals support selector module helpers', async () => { assert.match(selectorModuleSource, /export const stableSelectors/) assert.match(selectorModuleSource, /"alpha": "knighted-alpha"/) + const proxySource = formatSelectorModuleSource(selectorMap, { + moduleSpecifier: './demo.js', + includeDefault: false, + }) + assert.match(proxySource, /export \* from '\.\/demo\.js'/) + assert.match(proxySource, /export \{ knightedCss \} from '\.\/demo\.js\?knighted-css'/) + assert.ok(!proxySource.includes('export default stableSelectors')) + const manifestKey = buildSelectorModuleManifestKey(path.join('src', 'entry.js')) assert.ok(manifestKey.includes('entry.js')) const modulePath = buildSelectorModulePath('/tmp/project/src/entry.js') assert.ok(modulePath.endsWith('.knighted-css.ts')) + assert.ok(!modulePath.includes('.js.knighted-css.ts')) + const cssModulePath = buildSelectorModulePath('/tmp/project/src/styles.css') + assert.ok(cssModulePath.endsWith('styles.css.knighted-css.ts')) const normalized = normalizeIncludeOptions(undefined, '/tmp/project') assert.deepEqual(normalized, ['/tmp/project']) @@ -605,6 +689,35 @@ test('generateTypes internals support selector module helpers', async () => { path.resolve('/tmp/project', './src'), ]) + assert.equal(isStyleResource('/tmp/styles.css'), true) + assert.equal(isStyleResource('/tmp/styles.css.ts'), true) + assert.equal(isStyleResource('/tmp/app.ts'), false) + + const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-extension-')) + try { + const filePath = path.join(tempRoot, 'widget.ts') + await fs.writeFile(filePath, 'export const widget = true') + const resolved = await resolveWithExtensionFallback(path.join(tempRoot, 'widget.js')) + assert.equal(resolved, filePath) + + const missingResolved = await resolveWithExtensionFallback( + path.join(tempRoot, 'missing.ts'), + ) + assert.equal(missingResolved, path.join(tempRoot, 'missing.ts')) + + const indexDir = path.join(tempRoot, 'pkg') + await fs.mkdir(indexDir, { recursive: true }) + const indexPath = path.join(indexDir, 'index.ts') + await fs.writeFile(indexPath, 'export const ok = true') + const indexResolved = await resolveWithExtensionFallback(indexDir) + assert.equal(indexResolved, indexPath) + } finally { + await fs.rm(tempRoot, { recursive: true, force: true }) + } + + const fallbackRoot = resolvePackageRoot() + assert.ok(fallbackRoot.endsWith(path.join('node_modules', '@knighted', 'css'))) + const nonFileEntry = path.join(os.tmpdir(), 'knighted-non-file-entry') const resolvedNonFileEntry = path.resolve(nonFileEntry) const originalStat = fs.stat @@ -810,10 +923,35 @@ test('generateTypes internals support selector module helpers', async () => { resolveRoot, ) assert.equal(relativeResolved, path.join(resolveRoot, 'src', 'styles', 'demo.css')) + + const absoluteTarget = path.join(resolveRoot, 'src', 'absolute.ts') + await fs.writeFile(absoluteTarget, 'export const abs = true') + const absoluteResolved = await resolveImportPath( + '/src/absolute.ts', + importer, + resolveRoot, + ) + assert.equal(absoluteResolved, absoluteTarget) } finally { await fs.rm(resolveRoot, { recursive: true, force: true }) } + const baseUrlRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-tsconfig-baseurl-'), + ) + try { + const baseUrl = path.join(baseUrlRoot, 'src') + await fs.mkdir(baseUrl, { recursive: true }) + const baseUrlFile = path.join(baseUrl, 'base.ts') + await fs.writeFile(baseUrlFile, 'export const base = true') + const resolvedBaseUrl = await resolveWithTsconfigPaths('base.ts', { + absoluteBaseUrl: baseUrl, + }) + assert.equal(resolvedBaseUrl, baseUrlFile) + } finally { + await fs.rm(baseUrlRoot, { recursive: true, force: true }) + } + const rooted = relativeToRoot( path.join('/tmp/project', 'src', 'demo.css'), '/tmp/project', diff --git a/packages/playwright/src/auto-stable/constants.ts b/packages/playwright/src/auto-stable/constants.ts index df745a7..776f1c6 100644 --- a/packages/playwright/src/auto-stable/constants.ts +++ b/packages/playwright/src/auto-stable/constants.ts @@ -3,3 +3,4 @@ export const AUTO_STABLE_HOST_TEST_ID = 'auto-stable-host' export const AUTO_STABLE_LIGHT_TEST_ID = 'auto-stable-light' export const AUTO_STABLE_SHADOW_TEST_ID = 'auto-stable-shadow' export const AUTO_STABLE_TOKEN_TEST_ID = 'auto-stable-token' +export const AUTO_STABLE_PROXY_TEST_ID = 'auto-stable-proxy' diff --git a/packages/playwright/src/auto-stable/lit-host.ts b/packages/playwright/src/auto-stable/lit-host.ts index a9d1e86..fd263d0 100644 --- a/packages/playwright/src/auto-stable/lit-host.ts +++ b/packages/playwright/src/auto-stable/lit-host.ts @@ -1,13 +1,14 @@ import { reactJsx } from '@knighted/jsx/react' -import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' import { createRoot, type Root } from 'react-dom/client' import { LitElement, css, html, unsafeCSS, type PropertyValues } from 'lit' -import * as shadowTree from './shadow-tree.js?knighted-css&combined&named-only' -import { AUTO_STABLE_HOST_TAG } from './constants.js' +import { + ShadowTree, + knightedCss as shadowTreeCss, + stableSelectors, +} from './shadow-tree.knighted-css.js' +import { AUTO_STABLE_HOST_TAG, AUTO_STABLE_PROXY_TEST_ID } from './constants.js' -const { ShadowTree, knightedCss: shadowTreeCss } = - asKnightedCssCombinedModule(shadowTree) const hostShell = css` :host { display: block; @@ -52,7 +53,12 @@ export class AutoStableHost extends LitElement { } render() { - return html`
` + return html`
+ ` } } diff --git a/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts b/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts deleted file mode 100644 index 0998ed6..0000000 --- a/packages/playwright/src/auto-stable/styles.module.css.knighted-css.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Generated by @knighted/css/generate-types -// Do not edit. - -export const stableSelectors = { - badge: 'knighted-badge', - card: 'knighted-card', - copy: 'knighted-copy', - stack: 'knighted-stack', - title: 'knighted-title', - token: 'knighted-token', -} as const - -export type KnightedCssStableSelectors = typeof stableSelectors -export type KnightedCssStableSelectorToken = keyof typeof stableSelectors - -export default stableSelectors diff --git a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts deleted file mode 100644 index 91f5559..0000000 --- a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by @knighted/css/generate-types -// Do not edit. - -export const stableSelectors = { - 'combined-types-badge': 'knighted-combined-types-badge', - 'combined-types-copy': 'knighted-combined-types-copy', - 'combined-types-footer': 'knighted-combined-types-footer', - 'combined-types-shell': 'knighted-combined-types-shell', -} as const - -export type KnightedCssStableSelectors = typeof stableSelectors -export type KnightedCssStableSelectorToken = keyof typeof stableSelectors - -export default stableSelectors diff --git a/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts deleted file mode 100644 index 6b8a68a..0000000 --- a/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Generated by @knighted/css/generate-types -// Do not edit. - -export const stableSelectors = { - 'fallback-card': 'knighted-fallback-card', - 'fallback-card__badge': 'knighted-fallback-card__badge', - 'fallback-card__copy': 'knighted-fallback-card__copy', - 'fallback-card__token': 'knighted-fallback-card__token', -} as const - -export type KnightedCssStableSelectors = typeof stableSelectors -export type KnightedCssStableSelectorToken = keyof typeof stableSelectors - -export default stableSelectors diff --git a/packages/playwright/test/auto-stable.spec.ts b/packages/playwright/test/auto-stable.spec.ts index b3ac2b5..421a47f 100644 --- a/packages/playwright/test/auto-stable.spec.ts +++ b/packages/playwright/test/auto-stable.spec.ts @@ -3,6 +3,7 @@ import { expect, test } from '@playwright/test' import { AUTO_STABLE_HOST_TEST_ID, AUTO_STABLE_LIGHT_TEST_ID, + AUTO_STABLE_PROXY_TEST_ID, AUTO_STABLE_SHADOW_TEST_ID, AUTO_STABLE_TOKEN_TEST_ID, } from '../src/auto-stable/constants.js' @@ -84,4 +85,50 @@ test.describe('Auto-stable demo', () => { expect(tokenText?.trim()).toBe('knighted-card') }) + + test('unified proxy provides exports, css, and selectors', async ({ page }) => { + const proxyHandle = await page.waitForFunction( + ({ hostId, proxyId }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(`[data-testid="${proxyId}"]`) + }, + { hostId: AUTO_STABLE_HOST_TEST_ID, proxyId: AUTO_STABLE_PROXY_TEST_ID }, + ) + + const proxyMarker = proxyHandle.asElement() + if (!proxyMarker) throw new Error('Unified proxy marker was not rendered') + + const proxyData = await proxyMarker.evaluate(node => { + const el = node as HTMLElement + return { + stableClass: el.dataset.stableClass, + cssLength: Number(el.dataset.cssLength ?? 0), + } + }) + + await proxyHandle.dispose() + + expect(proxyData.stableClass).toBe('knighted-card') + expect(proxyData.cssLength).toBeGreaterThan(0) + + const cardHandle = await page.waitForFunction( + ({ hostId, selector }) => { + const hostEl = document.querySelector(`[data-testid="${hostId}"]`) + return hostEl?.shadowRoot?.querySelector(selector) + }, + { hostId: AUTO_STABLE_HOST_TEST_ID, selector: `.${proxyData.stableClass}` }, + ) + + const card = cardHandle.asElement() + if (!card) throw new Error('Shadow DOM card was not rendered') + + const background = await card.evaluate(node => { + const el = node as HTMLElement + return getComputedStyle(el).getPropertyValue('background-color').trim() + }) + + await cardHandle.dispose() + + expect(background).toBe('rgb(15, 23, 42)') + }) })