diff --git a/README.md b/README.md index 949d63e..48de82e 100644 --- a/README.md +++ b/README.md @@ -200,15 +200,25 @@ CSS Modules hash class names after the loader extracts selectors, so the stylesh
``` -### Stable selector type generation +### Stable selector modules (`*.knighted-css.ts`) -Run `npx knighted-css-generate-types --root .` to scan your project for `?knighted-css&types` imports. The CLI: +Import the generated selector module anywhere you want literal tokens: -- extracts selectors via the loader, then writes literal module declarations whose specifiers resolve to the real stylesheet paths (TypeScript picks up `import './foo.scss?knighted-css&types'` directly—no registries or casting helpers required). The default output still lands in `node_modules/@knighted/css/node_modules/.knighted-css`, but every module declaration points back to your source tree. -- updates the packaged stub at `node_modules/@knighted/css/types-stub/index.d.ts` -- exposes the declarations automatically because `types.d.ts` references the stub, so no `tsconfig` wiring is required +```ts +import { stableSelectors } from './styles.css.knighted-css.js' + +stableSelectors.demo // "knighted-demo" +type StableSelectors = typeof stableSelectors +``` + +Run `npx knighted-css-generate-types --root .` to scan for `.knighted-css` specifiers and keep those modules current. The CLI: -Re-run the command whenever imports change (add it to a `types:css` npm script or your build). If you need a different destination, pass `--out-dir` and/or `--types-root` to override the defaults. +- extracts selectors via the loader, applies your `stableNamespace`, and sorts the tokens deterministically +- writes sibling `*.knighted-css.ts` files next to each stylesheet so editors resolve them immediately +- records everything inside `/.knighted-css/selector-modules.json` (or your custom `--out-dir`) and removes stale modules when imports disappear +- warns whenever a specifier cannot be resolved or escapes the configured project root + +Because these are regular modules—not `.d.ts` shims—you can import the default export or the named `stableSelectors`, and helper types (`KnightedCssStableSelectors`, `KnightedCssStableSelectorToken`) ship alongside the literal map. Pass `--stable-namespace` so the CLI and loader agree on prefixes, and use `--include` / `--out-dir` to expand the scan or relocate the manifest cache (the selector modules themselves always live beside the source stylesheet). Re-run the command whenever you touch `.knighted-css` imports (wire it into a `knighted:types` script or a watch task). Sass/Less projects can import the shared mixins directly: @@ -252,7 +262,7 @@ Override the namespace via `:root { --knighted-stable-namespace: 'acme'; }` if y #### Type-safe selector maps (`?knighted-css&types`) -Append `&types` to any loader import to receive a literal map of the discovered stable selectors alongside the raw CSS: +Use `?knighted-css&types` when you need the `stableSelectors` map at runtime (Lit styles, SSR, tests, etc.). TypeScript already sees the literal tokens via the generated `.knighted-css` modules, but the loader surfaces the same data for application code: ```ts import { knightedCss, stableSelectors } from './styles.css?knighted-css&types' @@ -274,7 +284,7 @@ const { knightedCss } = combined as KnightedCssCombinedModule< stableSelectors.demo // "knighted-demo" ``` -Namespaces default to `knighted`, but you can configure a global fallback via the loader’s `stableNamespace` option: +Namespaces default to `knighted`, but you can configure a global fallback via the loader’s `stableNamespace` option (match it with the CLI’s `--stable-namespace` flag so runtime + generated modules agree): ```js { @@ -289,24 +299,20 @@ All imports share the namespace resolved by the loader (or the `knighted-css-gen #### TypeScript support for loader queries -Loader query types ship directly with `@knighted/css`. Reference them once in your project—either by adding `"types": ["@knighted/css/loader-queries"]` to `tsconfig.json` or dropping `/// ` into a global `.d.ts`—and the following ambient modules become available everywhere: +Loader query types still ship directly with `@knighted/css`. Reference them once in your project—either by adding `"types": ["@knighted/css/loader-queries"]` to `tsconfig.json` or dropping `/// ` into a global `.d.ts`—and the following ambient modules become available everywhere: - `*?knighted-css` imports expose a `knightedCss: string` export. - `*?knighted-css&types` exposes both `knightedCss` and `stableSelectors`, the readonly selector map. - `*?knighted-css&combined` (plus `&named-only` / `&no-default`) mirror the source module exports while adding `knightedCss`, which you can narrow with `KnightedCssCombinedModule` before destructuring named members. - `*?knighted-css&combined&types` variants add the same `stableSelectors` map on top of the combined behavior so a single import can surface everything. -No vendor copies are necessary—the declarations live inside `@knighted/css`, you just need to point your TypeScript config at the shipped `loader-queries` subpath once. +No vendor copies are necessary—the declarations live inside `@knighted/css`, you just need to point your TypeScript config at the shipped `loader-queries` subpath once. Use them for runtime loader imports and lean on the `.knighted-css` modules for editor-time selector literals. -#### Generate literal selector types +#### Keeping selector modules up to date -The runtime `stableSelectors` export is always a literal `as const` map, but TypeScript can only see those exact tokens if your project emits matching `.d.ts` files. Run the bundled CLI whenever you change a module that imports `?knighted-css&types` (or any `&combined&types` variants): +The CLI walks every file you include (defaults to the project root, skipping `node_modules`, `dist`, etc.), finds specifiers ending in `.knighted-css`, reuses the loader to extract CSS, and writes deterministic `*.knighted-css.ts` siblings next to the real stylesheets. Each module exports the literal selector map plus helper types, and the manifest stored in `/.knighted-css/selector-modules.json` keeps the cache tidy by removing stale files. Because the generator writes actual TypeScript modules, editors pick them up immediately through normal resolution—no registries or `typeRoots` wiring required. -```bash -npx knighted-css-generate-types --root . -``` - -or wire it into `package.json` for local workflows: +Wire the CLI into `package.json` so local workflows stay fresh: ```json { @@ -316,17 +322,14 @@ or wire it into `package.json` for local workflows: } ``` -The CLI scans every file you include (by default the project root, skipping `node_modules`, `dist`, etc.), finds imports containing `?knighted-css&types`, reuses the loader to extract CSS, and writes deterministic `.d.ts` files into `node_modules/.knighted-css/knt-*.d.ts`. Each declaration uses a specifier that resolves back to the original stylesheet path, so TypeScript sees the literal `import './foo.scss?knighted-css&types'` as soon as the CLI runs. It also maintains `node_modules/@knighted/css/types-stub/index.d.ts`, so TypeScript picks up the generated declarations automatically—no extra `typeRoots` or registry scripts are required. - Key flags: - `--root` / `-r` – project root (defaults to `process.cwd()`). - `--include` / `-i` – additional directories or files to scan (repeatable). -- `--out-dir` – custom output folder for the generated `knt-*` declarations. -- `--types-root` – override the `@types` directory used for the aggregator. -- `--stable-namespace` – namespace prefix for the generated selector map. +- `--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. -Re-run the CLI (or add it to a pre-build hook) whenever selectors change so new tokens land in the literal declaration files. +Re-run the CLI (or hook it into a watcher) whenever you touch `.knighted-css` imports so new tokens land in the literal selector modules. #### Combined module + CSS import diff --git a/docs/combined-queries.md b/docs/combined-queries.md index f4a73b0..5b97f46 100644 --- a/docs/combined-queries.md +++ b/docs/combined-queries.md @@ -2,6 +2,9 @@ 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`. +> [!NOTE] +> TypeScript now 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. + ## Decision Matrix | Source module exports | Recommended query | TypeScript import pattern | Notes | diff --git a/package-lock.json b/package-lock.json index d573a7f..58c5b79 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11922,7 +11922,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.11", + "version": "1.0.0-rc.12", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -12222,7 +12222,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.0-rc.11", + "@knighted/css": "1.0.0-rc.12", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index fb16d00..a1c505a 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.11", + "version": "1.0.0-rc.12", "description": "A build-time utility that traverses JavaScript/TypeScript module dependency graphs to extract, compile, and optimize all imported CSS into a single, in-memory string.", "type": "module", "main": "./dist/css.js", diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index d6d48b4..2e49df9 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -11,35 +11,21 @@ import { getTsconfig, type TsConfigResult } from 'get-tsconfig' import { createMatchPath, type MatchPath } from 'tsconfig-paths' import { cssWithMeta } from './css.js' -import { - determineSelectorVariant, - hasQueryFlag, - TYPES_QUERY_FLAG, - buildSanitizedQuery, - COMBINED_QUERY_FLAG, - NAMED_ONLY_QUERY_FLAGS, - type SelectorTypeVariant, -} from './loaderInternals.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' -interface ManifestEntry { - file: string - hash: string -} - -type Manifest = Record - interface ImportMatch { specifier: string importer: string } -interface DeclarationRecord { - specifier: string - filePath: string +interface ManifestEntry { + file: string + hash: string } +type SelectorModuleManifest = Record + interface TsconfigResolutionContext { absoluteBaseUrl?: string matchPath?: MatchPath @@ -52,26 +38,22 @@ let activeCssWithMeta: CssWithMetaFn = cssWithMeta interface GenerateTypesInternalOptions { rootDir: string include: string[] - outDir: string - typesRoot: string + cacheDir: string stableNamespace?: string tsconfig?: TsconfigResolutionContext } export interface GenerateTypesResult { - written: number - removed: number - declarations: DeclarationRecord[] + selectorModulesWritten: number + selectorModulesRemoved: number warnings: string[] - outDir: string - typesIndexPath: string + manifestPath: string } export interface GenerateTypesOptions { rootDir?: string include?: string[] outDir?: string - typesRoot?: string stableNamespace?: string } @@ -126,26 +108,23 @@ function getImportMetaUrl(): string | undefined { } const PACKAGE_ROOT = resolvePackageRoot() -const DEFAULT_TYPES_ROOT = path.join(PACKAGE_ROOT, 'types-stub') -const DEFAULT_OUT_DIR = path.join(PACKAGE_ROOT, 'node_modules', '.knighted-css') +const SELECTOR_REFERENCE = '.knighted-css' +const SELECTOR_MODULE_SUFFIX = '.knighted-css.ts' export async function generateTypes( options: GenerateTypesOptions = {}, ): Promise { const rootDir = path.resolve(options.rootDir ?? process.cwd()) const include = normalizeIncludeOptions(options.include, rootDir) - const outDir = path.resolve(options.outDir ?? DEFAULT_OUT_DIR) - const typesRoot = path.resolve(options.typesRoot ?? DEFAULT_TYPES_ROOT) + const cacheDir = path.resolve(options.outDir ?? path.join(rootDir, '.knighted-css')) const tsconfig = loadTsconfigResolutionContext(rootDir) await init - await fs.mkdir(outDir, { recursive: true }) - await fs.mkdir(typesRoot, { recursive: true }) + await fs.mkdir(cacheDir, { recursive: true }) const internalOptions: GenerateTypesInternalOptions = { rootDir, include, - outDir, - typesRoot, + cacheDir, stableNamespace: options.stableNamespace, tsconfig, } @@ -158,35 +137,34 @@ async function generateDeclarations( ): Promise { const peerResolver = createProjectPeerResolver(options.rootDir) const files = await collectCandidateFiles(options.include) - const manifestPath = path.join(options.outDir, 'manifest.json') - const previousManifest = await readManifest(manifestPath) - const nextManifest: Manifest = {} + const selectorModulesManifestPath = path.join(options.cacheDir, 'selector-modules.json') + const previousSelectorManifest = await readManifest(selectorModulesManifestPath) + const nextSelectorManifest: SelectorModuleManifest = {} const selectorCache = new Map>() - const processedSpecifiers = new Set() - const declarations: DeclarationRecord[] = [] + const processedSelectors = new Set() const warnings: string[] = [] - let writes = 0 + 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) - if (!inlineFree.includes('?knighted-css')) continue - const { resource, query } = splitResourceAndQuery(inlineFree) - if (!query || !hasQueryFlag(query, TYPES_QUERY_FLAG)) { + const { resource } = splitResourceAndQuery(inlineFree) + const selectorSource = extractSelectorSourceSpecifier(resource) + if (!selectorSource) { continue } const resolvedNamespace = resolveStableNamespace(options.stableNamespace) const resolvedPath = await resolveImportPath( - resource, + selectorSource, match.importer, options.rootDir, options.tsconfig, ) if (!resolvedPath) { warnings.push( - `Unable to resolve ${resource} referenced by ${relativeToRoot(match.importer, options.rootDir)}.`, + `Unable to resolve ${selectorSource} referenced by ${relativeToRoot(match.importer, options.rootDir)}.`, ) continue } @@ -214,58 +192,41 @@ async function generateDeclarations( selectorCache.set(cacheKey, selectorMap) } - const canonicalSpecifier = buildDeclarationModuleSpecifier( - resolvedPath, - options.outDir, - query, - ) - if (processedSpecifiers.has(canonicalSpecifier)) { + if (!isWithinRoot(resolvedPath, options.rootDir)) { + warnings.push( + `Skipping selector module for ${relativeToRoot(resolvedPath, options.rootDir)} because it is outside the project root.`, + ) continue } - const variant = determineSelectorVariant(query) - const declaration = formatModuleDeclaration( - canonicalSpecifier, - variant, + + const manifestKey = buildSelectorModuleManifestKey(resolvedPath) + if (processedSelectors.has(manifestKey)) { + continue + } + const moduleWrite = await ensureSelectorModule( + resolvedPath, selectorMap, + previousSelectorManifest, + nextSelectorManifest, ) - const declarationHash = hashContent(declaration) - const fileName = buildDeclarationFileName(canonicalSpecifier) - const targetPath = path.join(options.outDir, fileName) - const previousEntry = previousManifest[canonicalSpecifier] - const needsWrite = - previousEntry?.hash !== declarationHash || !(await fileExists(targetPath)) - if (needsWrite) { - await fs.writeFile(targetPath, declaration, 'utf8') - writes += 1 - } - nextManifest[canonicalSpecifier] = { file: fileName, hash: declarationHash } - if (needsWrite) { - declarations.push({ specifier: canonicalSpecifier, filePath: targetPath }) + if (moduleWrite) { + selectorModuleWrites += 1 } - processedSpecifiers.add(canonicalSpecifier) + processedSelectors.add(manifestKey) } } - const removed = await removeStaleDeclarations( - previousManifest, - nextManifest, - options.outDir, + const selectorModulesRemoved = await removeStaleSelectorModules( + previousSelectorManifest, + nextSelectorManifest, ) - await writeManifest(manifestPath, nextManifest) - const typesIndexPath = path.join(options.typesRoot, 'index.d.ts') - await writeTypesIndex(typesIndexPath, nextManifest, options.outDir) - - if (Object.keys(nextManifest).length === 0) { - declarations.length = 0 - } + await writeManifest(selectorModulesManifestPath, nextSelectorManifest) return { - written: writes, - removed, - declarations, + selectorModulesWritten: selectorModuleWrites, + selectorModulesRemoved, warnings, - outDir: options.outDir, - typesIndexPath, + manifestPath: selectorModulesManifestPath, } } @@ -332,18 +293,18 @@ async function findSpecifierImports(filePath: string): Promise { } catch { return [] } - if (!source.includes('?knighted-css')) { + if (!source.includes(SELECTOR_REFERENCE)) { return [] } const matches: ImportMatch[] = [] const [imports] = parse(source, filePath) for (const record of imports) { const specifier = record.n ?? source.slice(record.s, record.e) - if (specifier && specifier.includes('?knighted-css')) { + if (specifier && specifier.includes(SELECTOR_REFERENCE)) { matches.push({ specifier, importer: filePath }) } } - const requireRegex = /require\((['"])([^'"`]+?\?knighted-css[^'"`]*)\1\)/g + const requireRegex = /require\((['"])([^'"`]+?\.knighted-css[^'"`]*)\1\)/g let reqMatch: RegExpExecArray | null while ((reqMatch = requireRegex.exec(source)) !== null) { const spec = reqMatch[2] @@ -369,6 +330,22 @@ function splitResourceAndQuery(specifier: string): { resource: string; query: st return { resource: trimmed.slice(0, queryIndex), query: trimmed.slice(queryIndex) } } +function extractSelectorSourceSpecifier(specifier: string): string | undefined { + const markerIndex = specifier.indexOf(SELECTOR_REFERENCE) + if (markerIndex < 0) { + return undefined + } + const suffix = specifier.slice(markerIndex + SELECTOR_REFERENCE.length) + if (suffix.length > 0 && !/\.(?:[cm]?[tj]s|[tj]sx)$/.test(suffix)) { + return undefined + } + const base = specifier.slice(0, markerIndex) + if (!base) { + return undefined + } + return base +} + const projectRequireCache = new Map>() async function resolveImportPath( @@ -396,137 +373,65 @@ async function resolveImportPath( } } -function buildDeclarationFileName(specifier: string): string { - const digest = crypto.createHash('sha1').update(specifier).digest('hex').slice(0, 12) - return `knt-${digest}.d.ts` +function buildSelectorModuleManifestKey(resolvedPath: string): string { + return resolvedPath.split(path.sep).join('/') } -function formatModuleDeclaration( - specifier: string, - variant: SelectorTypeVariant, - selectors: Map, -): string { - const literalSpecifier = JSON.stringify(specifier) - const selectorType = formatSelectorType(selectors) - const header = `declare module ${literalSpecifier} {` - const footer = '}' - if (variant === 'types') { - return `${header} - export const knightedCss: string - export const stableSelectors: ${selectorType} -${footer} -` - } - const stableLine = ` export const stableSelectors: ${selectorType}` - const shared = ` const combined: KnightedCssCombinedModule> - export const knightedCss: string -${stableLine}` - if (variant === 'combined') { - return `${header} -${shared} - export default combined -${footer} -` - } - return `${header} -${shared} -${footer} -` +function buildSelectorModulePath(resolvedPath: string): string { + return `${resolvedPath}${SELECTOR_MODULE_SUFFIX}` } -function formatSelectorType(selectors: Map): string { - if (selectors.size === 0) { - return 'Readonly>' - } +function formatSelectorModuleSource(selectors: Map): string { + const header = '// Generated by @knighted/css/generate-types\n// Do not edit.\n' const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b)) const lines = entries.map( - ([token, selector]) => - ` readonly ${JSON.stringify(token)}: ${JSON.stringify(selector)}`, + ([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`, ) - return `Readonly<{ + const literal = + lines.length > 0 + ? `{ ${lines.join('\n')} - }>` -} +} as const` + : '{} as const' + return `${header} +export const stableSelectors = ${literal} -function buildDeclarationModuleSpecifier( - resolvedPath: string, - declarationDir: string, - query: string, -): string { - const relativePath = path.relative(declarationDir, resolvedPath) - const normalizedPath = normalizeRelativePath(relativePath) - const canonicalQuery = buildCanonicalQuery(query) - return `${normalizedPath}${canonicalQuery}` -} +export type KnightedCssStableSelectors = typeof stableSelectors +export type KnightedCssStableSelectorToken = keyof typeof stableSelectors -function normalizeRelativePath(relativePath: string): string { - let normalized = relativePath.split(path.sep).join('/') - if (!normalized || normalized === '') { - normalized = '.' - } - if (normalized === '.') { - return './' - } - if (normalized.startsWith('./') || normalized.startsWith('../')) { - return normalized - } - if (normalized.startsWith('.')) { - return normalized - } - return `./${normalized}` -} - -function buildCanonicalQuery(query: string): string { - if (!query) { - return '' - } - const sanitized = buildSanitizedQuery(query) - const extraParts = sanitized ? sanitized.slice(1).split('&').filter(Boolean) : [] - const parts: string[] = [] - parts.push('knighted-css') - if (hasQueryFlag(query, COMBINED_QUERY_FLAG)) { - parts.push(COMBINED_QUERY_FLAG) - } - for (const flag of NAMED_ONLY_QUERY_FLAGS) { - if (hasQueryFlag(query, flag)) { - parts.push(flag) - } - } - if (hasQueryFlag(query, TYPES_QUERY_FLAG)) { - parts.push(TYPES_QUERY_FLAG) - } - const merged = [...parts, ...extraParts] - return merged.length > 0 ? `?${merged.join('&')}` : '' +export default stableSelectors +` } function hashContent(content: string): string { return crypto.createHash('sha1').update(content).digest('hex') } -async function readManifest(manifestPath: string): Promise { +async function readManifest(manifestPath: string): Promise { try { const raw = await fs.readFile(manifestPath, 'utf8') - return JSON.parse(raw) as Manifest + return JSON.parse(raw) as SelectorModuleManifest } catch { return {} } } -async function writeManifest(manifestPath: string, manifest: Manifest): Promise { +async function writeManifest( + manifestPath: string, + manifest: SelectorModuleManifest, +): Promise { await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8') } -async function removeStaleDeclarations( - previous: Manifest, - next: Manifest, - outDir: string, +async function removeStaleSelectorModules( + previous: SelectorModuleManifest, + next: SelectorModuleManifest, ): Promise { - const stale = Object.entries(previous).filter(([specifier]) => !next[specifier]) + const stale = Object.entries(previous).filter(([key]) => !next[key]) let removed = 0 for (const [, entry] of stale) { - const targetPath = path.join(outDir, entry.file) try { - await fs.unlink(targetPath) + await fs.unlink(entry.file) removed += 1 } catch { // ignore @@ -535,31 +440,6 @@ async function removeStaleDeclarations( return removed } -async function writeTypesIndex( - indexPath: string, - manifest: Manifest, - outDir: string, -): Promise { - const header = '// Generated by @knighted/css/generate-types\n// Do not edit.\n' - const references = Object.values(manifest) - .sort((a, b) => a.file.localeCompare(b.file)) - .map(entry => { - const rel = path - .relative(path.dirname(indexPath), path.join(outDir, entry.file)) - .split(path.sep) - .join('/') - return `/// ` - }) - const content = - references.length > 0 - ? `${header} -${references.join('\n')} -` - : `${header} -` - await fs.writeFile(indexPath, content, 'utf8') -} - function formatErrorMessage(error: unknown): string { if (error instanceof Error && typeof error.message === 'string') { return error.message @@ -571,6 +451,30 @@ function relativeToRoot(filePath: string, rootDir: string): string { return path.relative(rootDir, filePath) || filePath } +function isWithinRoot(filePath: string, rootDir: string): boolean { + const relative = path.relative(rootDir, filePath) + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)) +} + +async function ensureSelectorModule( + resolvedPath: string, + selectors: Map, + previousManifest: SelectorModuleManifest, + nextManifest: SelectorModuleManifest, +): Promise { + const manifestKey = buildSelectorModuleManifestKey(resolvedPath) + const targetPath = buildSelectorModulePath(resolvedPath) + const source = formatSelectorModuleSource(selectors) + 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) @@ -709,7 +613,6 @@ export async function runGenerateTypesCli(argv = process.argv.slice(2)): Promise rootDir: parsed.rootDir, include: parsed.include, outDir: parsed.outDir, - typesRoot: parsed.typesRoot, stableNamespace: parsed.stableNamespace, }) reportCliResult(result) @@ -724,7 +627,6 @@ export interface ParsedCliArgs { rootDir: string include?: string[] outDir?: string - typesRoot?: string stableNamespace?: string help?: boolean } @@ -733,13 +635,12 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { let rootDir = process.cwd() const include: string[] = [] let outDir: string | undefined - let typesRoot: string | undefined let stableNamespace: string | undefined for (let i = 0; i < argv.length; i += 1) { const arg = argv[i] if (arg === '--help' || arg === '-h') { - return { rootDir, include, outDir, typesRoot, stableNamespace, help: true } + return { rootDir, include, outDir, stableNamespace, help: true } } if (arg === '--root' || arg === '-r') { const value = argv[++i] @@ -765,14 +666,6 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { outDir = value continue } - if (arg === '--types-root') { - const value = argv[++i] - if (!value) { - throw new Error('Missing value for --types-root') - } - typesRoot = value - continue - } if (arg === '--stable-namespace') { const value = argv[++i] if (!value) { @@ -787,7 +680,7 @@ function parseCliArgs(argv: string[]): ParsedCliArgs { include.push(arg) } - return { rootDir, include, outDir, typesRoot, stableNamespace } + return { rootDir, include, outDir, stableNamespace } } function printHelp(): void { @@ -796,24 +689,21 @@ function printHelp(): void { Options: -r, --root Project root directory (default: cwd) -i, --include Additional directories/files to scan (repeatable) - --out-dir Output directory for generated declarations - --types-root Directory for generated @types entrypoint + --out-dir Directory to store selector module manifest cache --stable-namespace Stable namespace prefix for generated selector maps -h, --help Show this help message `) } function reportCliResult(result: GenerateTypesResult): void { - if (result.written === 0 && result.removed === 0) { - console.log( - '[knighted-css] No changes to ?knighted-css&types declarations (cache is up to date).', - ) + if (result.selectorModulesWritten === 0 && result.selectorModulesRemoved === 0) { + console.log('[knighted-css] Selector modules are up to date.') } else { console.log( - `[knighted-css] Updated ${result.written} declaration(s), removed ${result.removed}, output in ${result.outDir}.`, + `[knighted-css] Selector modules updated: wrote ${result.selectorModulesWritten}, removed ${result.selectorModulesRemoved}.`, ) } - console.log(`[knighted-css] Type references: ${result.typesIndexPath}`) + console.log(`[knighted-css] Manifest: ${result.manifestPath}`) for (const warning of result.warnings) { console.warn(`[knighted-css] ${warning}`) } @@ -832,17 +722,12 @@ function setImportMetaUrlProvider(provider?: () => string | undefined): void { } export const __generateTypesInternals = { - writeTypesIndex, stripInlineLoader, splitResourceAndQuery, + extractSelectorSourceSpecifier, findSpecifierImports, resolveImportPath, resolvePackageRoot, - buildDeclarationFileName, - formatModuleDeclaration, - formatSelectorType, - buildDeclarationModuleSpecifier, - buildCanonicalQuery, relativeToRoot, collectCandidateFiles, normalizeIncludeOptions, @@ -858,4 +743,11 @@ export const __generateTypesInternals = { parseCliArgs, printHelp, reportCliResult, + buildSelectorModuleManifestKey, + buildSelectorModulePath, + formatSelectorModuleSource, + ensureSelectorModule, + removeStaleSelectorModules, + readManifest, + writeManifest, } diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index a6acc4f..c0ee939 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -21,10 +21,13 @@ async function setupFixtureProject(): Promise<{ const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-generate-types-')) const srcDir = path.join(tmpRoot, 'src') await fs.mkdir(srcDir, { recursive: true }) - const fixtureSource = path.join(__dirname, 'fixtures', 'dialects', 'basic', 'entry.js') + const fixtureDir = path.join(__dirname, 'fixtures', 'dialects', 'basic') + const projectFixtureDir = path.join(srcDir, 'fixture') + await fs.cp(fixtureDir, projectFixtureDir, { recursive: true }) + const fixtureSource = path.join(projectFixtureDir, 'entry.js') const relativeImport = path.relative(srcDir, fixtureSource).split(path.sep).join('/') - const specifier = `${relativeImport}?knighted-css&types` - const entrySource = `import { stableSelectors } from '${specifier}' + const specifier = `./${relativeImport}.knighted-css` + const entrySource = `import stableSelectors from '${specifier}' console.log(stableSelectors.demo) ` await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) @@ -49,8 +52,8 @@ async function setupBaseUrlFixture(): Promise<{ .knighted-demo { color: teal; } `, ) - const specifier = 'styles/demo.css?knighted-css&types' - const entrySource = `import { stableSelectors } from '${specifier}' + const specifier = 'styles/demo.css.knighted-css' + const entrySource = `import stableSelectors from '${specifier}' console.log(stableSelectors.demo) ` await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) @@ -69,34 +72,47 @@ console.log(stableSelectors.demo) } } +async function pathExists(target: string): Promise { + try { + await fs.access(target) + return true + } catch { + return false + } +} + test('generateTypes emits declarations and reuses cache', async () => { const project = await setupFixtureProject() try { const outDir = path.join(project.root, '.knighted-css-test') - const typesRoot = path.join(project.root, '.knighted-css-types') - const sharedOptions = { rootDir: project.root, include: ['src'], outDir, typesRoot } + const sharedOptions = { rootDir: project.root, include: ['src'], outDir } const firstRun = await generateTypes(sharedOptions) - assert.ok(firstRun.written >= 1) - assert.equal(firstRun.removed, 0) + assert.ok(firstRun.selectorModulesWritten >= 1) + assert.equal(firstRun.selectorModulesRemoved, 0) assert.equal(firstRun.warnings.length, 0) - const manifestPath = path.join(firstRun.outDir, 'manifest.json') - const manifestRaw = await fs.readFile(manifestPath, 'utf8') - const manifest = JSON.parse(manifestRaw) as Record - const entries = Object.values(manifest) - assert.equal(entries.length, 1) - const declarationPath = path.join(firstRun.outDir, entries[0]?.file ?? '') - const declaration = await fs.readFile(declarationPath, 'utf8') - assert.ok(declaration.includes('stableSelectors')) - assert.ok(declaration.includes('knighted-demo')) - - const indexContent = await fs.readFile(firstRun.typesIndexPath, 'utf8') - assert.ok(indexContent.includes(entries[0]?.file ?? '')) + const selectorModulePath = path.join( + project.root, + 'src', + 'fixture', + 'entry.js.knighted-css.ts', + ) + const selectorModule = await fs.readFile(selectorModulePath, 'utf8') + assert.ok(selectorModule.includes('export const stableSelectors')) + assert.ok(selectorModule.includes('"demo": "knighted-demo"')) + + const selectorManifestPath = path.join(outDir, 'selector-modules.json') + const selectorManifest = JSON.parse( + await fs.readFile(selectorManifestPath, 'utf8'), + ) as Record + const selectorEntries = Object.values(selectorManifest) + assert.equal(selectorEntries.length, 1) + assert.equal(selectorEntries[0]?.file, selectorModulePath) const secondRun = await generateTypes(sharedOptions) - assert.equal(secondRun.written, 0) - assert.equal(secondRun.removed, 0) + assert.equal(secondRun.selectorModulesWritten, 0) + assert.equal(secondRun.selectorModulesRemoved, 0) assert.equal(secondRun.warnings.length, 0) } finally { await project.cleanup() @@ -107,49 +123,47 @@ test('generateTypes resolves tsconfig baseUrl specifiers', async () => { const project = await setupBaseUrlFixture() try { const outDir = path.join(project.root, '.knighted-css-test') - const typesRoot = path.join(project.root, '.knighted-css-types') const result = await generateTypes({ rootDir: project.root, include: ['src'], outDir, - typesRoot, }) - assert.ok(result.written >= 1) + assert.ok(result.selectorModulesWritten >= 1) assert.equal(result.warnings.length, 0) - const manifestPath = path.join(outDir, 'manifest.json') + const manifestPath = path.join(outDir, 'selector-modules.json') const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< string, - unknown + { file: string } > - assert.ok(manifest['../src/styles/demo.css?knighted-css&types']) + assert.equal(Object.keys(manifest).length, 1) } finally { await project.cleanup() } }) -test('generateTypes removes stale manifest entries when declarations are missing', async () => { +test('generateTypes removes stale selector manifest entries when modules vanish', async () => { const project = await setupFixtureProject() try { const outDir = path.join(project.root, '.knighted-css-test') - const typesRoot = path.join(project.root, '.knighted-css-types') - const options = { rootDir: project.root, include: ['src'], outDir, typesRoot } + const options = { rootDir: project.root, include: ['src'], outDir } await generateTypes(options) - const manifestPath = path.join(outDir, 'manifest.json') - const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< - string, - { file: string; hash: string } - > - manifest['./ghost.css?knighted-css'] = { file: 'ghost.d.ts', hash: 'ghost-hash' } - await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2)) + const selectorManifestPath = path.join(outDir, 'selector-modules.json') + const selectorManifest = JSON.parse( + await fs.readFile(selectorManifestPath, 'utf8'), + ) as Record + const ghostModulePath = path.join(project.root, 'src', 'ghost.knighted-css.ts') + selectorManifest['ghost-module'] = { file: ghostModulePath, hash: 'ghost' } + await fs.writeFile(selectorManifestPath, JSON.stringify(selectorManifest, null, 2)) + await fs.writeFile(ghostModulePath, '// ghost module') const result = await generateTypes(options) - assert.equal(result.removed, 0) - const updatedManifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< - string, - { file: string; hash: string } - > - assert.ok(!updatedManifest['./ghost.css?knighted-css']) + assert.ok(result.selectorModulesRemoved >= 1) + const updatedSelectorManifest = JSON.parse( + await fs.readFile(selectorManifestPath, 'utf8'), + ) as Record + assert.ok(!updatedSelectorManifest['ghost-module']) + assert.equal(await pathExists(ghostModulePath), false) } finally { await project.cleanup() } @@ -162,17 +176,15 @@ test('generateTypes reports warnings when specifiers cannot be resolved', async await fs.mkdir(srcDir, { recursive: true }) await fs.writeFile( path.join(srcDir, 'entry.ts'), - "import 'missing-package/style.css?knighted-css&types'\n", + "import 'missing-package/style.css.knighted-css'\n", ) const outDir = path.join(root, '.knighted-css-out') - const typesRoot = path.join(root, '.knighted-css-types') const result = await generateTypes({ rootDir: root, include: ['src'], outDir, - typesRoot, }) - assert.equal(result.written, 0) + assert.equal(result.selectorModulesWritten, 0) assert.ok(result.warnings.some(w => w.includes('Unable to resolve'))) } finally { await fs.rm(root, { recursive: true, force: true }) @@ -184,7 +196,6 @@ test('generateTypes surfaces css extraction failures', async () => { const { setCssWithMetaImplementation } = __generateTypesInternals try { const outDir = path.join(project.root, '.knighted-css-test') - const typesRoot = path.join(project.root, '.knighted-css-types') setCssWithMetaImplementation(async () => { throw new Error('css failure') }) @@ -192,9 +203,8 @@ test('generateTypes surfaces css extraction failures', async () => { rootDir: project.root, include: ['src'], outDir, - typesRoot, }) - assert.equal(result.written, 0) + assert.equal(result.selectorModulesWritten, 0) assert.ok(result.warnings.some(w => w.includes('Failed to extract CSS'))) } finally { setCssWithMetaImplementation() @@ -202,29 +212,27 @@ test('generateTypes surfaces css extraction failures', async () => { } }) -test('generateTypes completes with no declarations when no matching imports exist', async () => { +test('generateTypes completes with no selector modules when no matching imports exist', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-empty-spec-')) try { const srcDir = path.join(root, 'src') await fs.mkdir(srcDir, { recursive: true }) await fs.writeFile(path.join(srcDir, 'entry.ts'), 'console.log("noop")\n') const outDir = path.join(root, '.knighted-css-out') - const typesRoot = path.join(root, '.knighted-css-types') const result = await generateTypes({ rootDir: root, include: ['src'], outDir, - typesRoot, }) - assert.equal(result.written, 0) - assert.equal(result.declarations.length, 0) + assert.equal(result.selectorModulesWritten, 0) + assert.equal(result.selectorModulesRemoved, 0) assert.equal(result.warnings.length, 0) } finally { await fs.rm(root, { recursive: true, force: true }) } }) -test('generateTypes ignores specifiers lacking the types flag', async () => { +test('generateTypes ignores specifiers lacking the selector suffix', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-missing-flag-')) try { const srcDir = path.join(root, 'src') @@ -236,15 +244,13 @@ test('generateTypes ignores specifiers lacking the types flag', async () => { "import './styles/demo.css?knighted-css'\n", ) const outDir = path.join(root, '.knighted-css-out') - const typesRoot = path.join(root, '.knighted-css-types') const result = await generateTypes({ rootDir: root, include: ['src'], outDir, - typesRoot, }) - assert.equal(result.written, 0) - assert.equal(result.declarations.length, 0) + assert.equal(result.selectorModulesWritten, 0) + assert.equal(result.selectorModulesRemoved, 0) } finally { await fs.rm(root, { recursive: true, force: true }) } @@ -254,51 +260,89 @@ test('generateTypes dedupes repeated specifiers', async () => { const project = await setupFixtureProject() try { const srcDir = path.join(project.root, 'src') - const fixtureSource = path.join( - __dirname, - 'fixtures', - 'dialects', - 'basic', - 'entry.js', - ) - const relativeImport = path.relative(srcDir, fixtureSource).split(path.sep).join('/') - const specifier = `${relativeImport}?knighted-css&types` - const entrySource = `import { stableSelectors as firstSelectors } from '${specifier}' -import { stableSelectors as secondSelectors } from '${specifier}' + const specifier = './fixture/entry.js.knighted-css' + const entrySource = `import firstSelectors from '${specifier}' +import secondSelectors from '${specifier}' console.log(firstSelectors.demo, secondSelectors.demo) ` await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) const outDir = path.join(project.root, '.knighted-css-out') - const typesRoot = path.join(project.root, '.knighted-css-types') const result = await generateTypes({ rootDir: project.root, include: ['src'], outDir, - typesRoot, }) - assert.equal(result.written, 1) + assert.equal( + result.selectorModulesWritten, + 1, + `Unexpected selector module writes: ${JSON.stringify(result)}`, + ) + assert.equal(result.warnings.length, 0) + } finally { + await project.cleanup() + } +}) + +test('generateTypes handles inline loader prefixes on specifiers', async () => { + const project = await setupFixtureProject() + try { + const srcDir = path.join(project.root, 'src') + const specifier = 'style-loader!./fixture/entry.js.knighted-css' + const entrySource = `import selectors from '${specifier}' +console.log(selectors.demo) +` + await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) + + const outDir = path.join(project.root, '.knighted-css-inline') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + }) + assert.equal(result.selectorModulesWritten, 1) assert.equal(result.warnings.length, 0) } finally { await project.cleanup() } }) +test('generateTypes warns when selector sources fall outside the project root', async () => { + const sandboxRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-outside-root-')) + const projectRoot = path.join(sandboxRoot, 'project') + const sharedRoot = path.join(sandboxRoot, 'shared') + try { + await fs.mkdir(sharedRoot, { recursive: true }) + await fs.mkdir(path.join(projectRoot, 'src'), { recursive: true }) + await fs.writeFile( + path.join(projectRoot, 'package.json'), + JSON.stringify({ name: 'outside-root', version: '1.0.0' }), + ) + const cssPath = path.join(sharedRoot, 'global.css') + await fs.writeFile(cssPath, '.global { color: teal; }\n') + const entrySource = + "import selectors from '../../shared/global.css.knighted-css'\nconsole.log(selectors.global)\n" + await fs.writeFile(path.join(projectRoot, 'src', 'entry.ts'), entrySource) + + const outDir = path.join(projectRoot, '.knighted-css-cache') + const result = await generateTypes({ + rootDir: projectRoot, + include: ['src'], + outDir, + }) + assert.equal(result.selectorModulesWritten, 0) + assert.ok(result.warnings.some(w => w.includes('Skipping selector module'))) + assert.equal(await pathExists(`${cssPath}.knighted-css.ts`), false) + } finally { + await fs.rm(sandboxRoot, { recursive: true, force: true }) + } +}) + test('runGenerateTypesCli executes generation and reports summaries', async () => { const project = await setupFixtureProject() try { const outDir = path.join(project.root, '.knighted-css-cli') - const typesRoot = path.join(project.root, '.knighted-css-types-cli') - const args = [ - '--root', - project.root, - '--include', - 'src', - '--out-dir', - outDir, - '--types-root', - typesRoot, - ] + const args = ['--root', project.root, '--include', 'src', '--out-dir', outDir] const logs: string[] = [] const warns: string[] = [] const originalLog = console.log @@ -312,16 +356,10 @@ test('runGenerateTypesCli executes generation and reports summaries', async () = console.log = originalLog console.warn = originalWarn } - assert.ok(logs.some(log => log.includes('[knighted-css] Updated 1 declaration(s)'))) - assert.ok( - logs.some(log => - log.includes( - 'No changes to ?knighted-css&types declarations (cache is up to date).', - ), - ), - ) + assert.ok(logs.some(log => log.includes('Selector modules updated'))) + assert.ok(logs.some(log => log.includes('Selector modules are up to date.'))) assert.equal(warns.length, 0) - const manifestPath = path.join(outDir, 'manifest.json') + const manifestPath = path.join(outDir, 'selector-modules.json') const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf8')) as Record< string, unknown @@ -343,20 +381,14 @@ test('runGenerateTypesCli prints help output when requested', async () => { } assert.ok(printed.some(line => line.includes('Usage: knighted-css-generate-types'))) }) - -test('generateTypes internals format selector-aware declarations', async () => { +test('generateTypes internals support selector module helpers', async () => { const { stripInlineLoader, splitResourceAndQuery, + extractSelectorSourceSpecifier, findSpecifierImports, resolveImportPath, resolvePackageRoot, - buildDeclarationFileName, - formatSelectorType, - formatModuleDeclaration, - buildDeclarationModuleSpecifier, - buildCanonicalQuery, - writeTypesIndex, normalizeIncludeOptions, collectCandidateFiles, normalizeTsconfigPaths, @@ -371,64 +403,42 @@ test('generateTypes internals format selector-aware declarations', async () => { parseCliArgs, printHelp, reportCliResult, + buildSelectorModuleManifestKey, + buildSelectorModulePath, + formatSelectorModuleSource, } = __generateTypesInternals assert.equal( - stripInlineLoader('style-loader!css-loader!./demo.css?knighted-css&types'), - './demo.css?knighted-css&types', + stripInlineLoader('style-loader!css-loader!./demo.ts.knighted-css'), + './demo.ts.knighted-css', ) - assert.deepEqual(splitResourceAndQuery('./demo.css?knighted-css#hash'), { - resource: './demo.css', - query: '?knighted-css', - }) - assert.deepEqual(splitResourceAndQuery('./demo.css'), { - resource: './demo.css', - query: '', + assert.deepEqual(splitResourceAndQuery('./demo.ts.knighted-css?foo=1#hash'), { + resource: './demo.ts.knighted-css', + query: '?foo=1', }) + 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) + const selectorMap = new Map([ ['beta', 'knighted-beta'], ['alpha', 'knighted-alpha'], ]) - const hashedName = buildDeclarationFileName('./demo.css?knighted-css') - assert.match(hashedName, /^knt-[a-f0-9]{12}\.d\.ts$/) - const selectorType = formatSelectorType(selectorMap) - assert.match(selectorType, /readonly "alpha": "knighted-alpha"/) - assert.match(selectorType, /readonly "beta": "knighted-beta"/) - assert.equal(formatSelectorType(new Map()), 'Readonly>') - - const declaration = formatModuleDeclaration( - './demo.css?knighted-css&combined', - 'combined', - selectorMap, - ) - assert.match(declaration, /declare module/) - assert.match(declaration, /export const stableSelectors/) - - const withoutDefault = formatModuleDeclaration( - './demo.css?knighted-css&combined&named-only', - 'combinedWithoutDefault', - selectorMap, - ) - assert.doesNotMatch(withoutDefault, /export default/) - - const canonicalSpecifier = buildDeclarationModuleSpecifier( - path.join('/tmp/project', 'src', 'styles', 'demo.css'), - path.join('/tmp/project', '.knighted-css'), - '?types&knighted-css&foo=1', - ) - assert.equal(canonicalSpecifier, '../src/styles/demo.css?knighted-css&types&foo=1') - - assert.equal( - buildCanonicalQuery('?knighted-css&combined&no-default&types&foo=1'), - '?knighted-css&combined&no-default&types&foo=1', - ) - - const normalized = normalizeIncludeOptions(undefined, '/tmp/demo') - assert.deepEqual(normalized, ['/tmp/demo']) - assert.deepEqual(normalizeIncludeOptions(['./src'], '/tmp/demo'), [ - path.resolve('/tmp/demo', './src'), + const selectorModuleSource = formatSelectorModuleSource(selectorMap) + assert.match(selectorModuleSource, /export const stableSelectors/) + assert.match(selectorModuleSource, /"alpha": "knighted-alpha"/) + + 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')) + + const normalized = normalizeIncludeOptions(undefined, '/tmp/project') + assert.deepEqual(normalized, ['/tmp/project']) + assert.deepEqual(normalizeIncludeOptions(['./src'], '/tmp/project'), [ + path.resolve('/tmp/project', './src'), ]) const nonFileEntry = path.join(os.tmpdir(), 'knighted-non-file-entry') @@ -462,11 +472,6 @@ test('generateTypes internals format selector-aware declarations', async () => { const duplicateResult = await collectCandidateFiles([entryFile, entryFile]) assert.equal(duplicateResult.length, 1) - const missingResult = await collectCandidateFiles([ - path.join(collectRoot, 'missing.ts'), - ]) - assert.deepEqual(missingResult, []) - const skipDir = path.join(collectRoot, 'node_modules') await fs.mkdir(skipDir, { recursive: true }) const skipResult = await collectCandidateFiles([skipDir]) @@ -484,8 +489,6 @@ test('generateTypes internals format selector-aware declarations', async () => { 'storybook', '--out-dir', '.knighted-css', - '--types-root', - './types', ]) as ParsedCliArgs assert.equal(parsed.rootDir, path.resolve('/tmp/project')) assert.deepEqual(parsed.include, ['src']) @@ -494,7 +497,6 @@ test('generateTypes internals format selector-aware declarations', async () => { assert.throws(() => parseCliArgs(['--root']), /Missing value/) assert.throws(() => parseCliArgs(['--include']), /Missing value/) assert.throws(() => parseCliArgs(['--out-dir']), /Missing value/) - assert.throws(() => parseCliArgs(['--types-root']), /Missing value/) assert.throws(() => parseCliArgs(['--stable-namespace']), /Missing value/) assert.throws(() => parseCliArgs(['--wat']), /Unknown flag/) const helpParsed = parseCliArgs(['--help']) @@ -508,22 +510,11 @@ test('generateTypes internals format selector-aware declarations', async () => { assert.deepEqual(normalizedPaths, { '@demo/*': ['src/demo/*', 'fallback/*'], }) - assert.equal(normalizeTsconfigPaths(undefined), undefined) - assert.equal(normalizeTsconfigPaths({ foo: [] }), undefined) - assert.equal( - normalizeTsconfigPaths({ foo: undefined as unknown as string[] }), - undefined, - ) assert.equal(isNonRelativeSpecifier('pkg/component'), true) assert.equal(isNonRelativeSpecifier('./local'), false) - assert.equal(isNonRelativeSpecifier('/absolute/path'), false) - assert.equal(isNonRelativeSpecifier('http://example.com/style.css'), false) assert.equal(isNonRelativeSpecifier(''), false) - const positionalParsed = parseCliArgs(['src', 'stories']) - assert.deepEqual(positionalParsed.include, ['src', 'stories']) - const printed: string[] = [] const logged = console.log try { @@ -542,35 +533,53 @@ test('generateTypes internals format selector-aware declarations', async () => { console.log = (msg: string) => summaryLogs.push(msg) console.warn = (msg: string) => summaryWarns.push(msg) reportCliResult({ - written: 0, - removed: 0, - declarations: [], + selectorModulesWritten: 0, + selectorModulesRemoved: 0, warnings: ['warn'], - outDir: '/tmp/types', - typesIndexPath: '/tmp/types/index.d.ts', + manifestPath: '/tmp/types/selector-modules.json', }) reportCliResult({ - written: 2, - removed: 1, - declarations: [], + selectorModulesWritten: 2, + selectorModulesRemoved: 1, warnings: [], - outDir: '/tmp/types', - typesIndexPath: '/tmp/types/index.d.ts', + manifestPath: '/tmp/types/selector-modules.json', }) } finally { console.log = originalLog console.warn = originalWarn } - assert.ok( - summaryLogs.some(log => - log.includes( - 'No changes to ?knighted-css&types declarations (cache is up to date).', - ), - ), - ) - assert.ok(summaryLogs.some(log => log.includes('Updated 2 declaration(s)'))) + assert.ok(summaryLogs.some(log => log.includes('Selector modules are up to date.'))) + assert.ok(summaryLogs.some(log => log.includes('Selector modules updated'))) assert.equal(summaryWarns.length, 1) + const fakeMetaDir = path.join(os.tmpdir(), 'knighted-meta', 'esm') + const fakeModuleUrl = pathToFileURL(path.join(fakeMetaDir, 'index.js')).href + try { + setModuleTypeDetector(() => 'module') + setImportMetaUrlProvider(() => fakeModuleUrl) + const resolvedRoot = resolvePackageRoot() + assert.equal(resolvedRoot, path.resolve(fakeMetaDir, '..')) + } finally { + setModuleTypeDetector() + setImportMetaUrlProvider() + } + + const specifierRoot = await fs.mkdtemp( + path.join(os.tmpdir(), 'knighted-specifier-imports-'), + ) + try { + const specFile = path.join(specifierRoot, 'entry.ts') + await fs.writeFile( + specFile, + "import selectors from './demo.js.knighted-css'\nconst lazy = require('./other.knighted-css')\n", + ) + const matches = await findSpecifierImports(specFile) + assert.equal(matches.length, 2) + assert.ok(matches.every(match => match.importer === specFile)) + } finally { + await fs.rm(specifierRoot, { recursive: true, force: true }) + } + const peerRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-peer-resolver-')) try { await fs.writeFile(path.join(peerRoot, 'package.json'), '{}') @@ -602,17 +611,6 @@ test('generateTypes internals format selector-aware declarations', async () => { await fs.rm(tsconfigRoot, { recursive: true, force: true }) } - const brokenTsconfigRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'knighted-tsconfig-bad-'), - ) - try { - await fs.writeFile(path.join(brokenTsconfigRoot, 'tsconfig.json'), '{ invalid') - const context = loadTsconfigResolutionContext(brokenTsconfigRoot) - assert.equal(context, undefined) - } finally { - await fs.rm(brokenTsconfigRoot, { recursive: true, force: true }) - } - const aliasRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-tsconfig-alias-')) try { const aliasFile = path.join(aliasRoot, 'alias.css') @@ -621,90 +619,16 @@ test('generateTypes internals format selector-aware declarations', async () => { matchPath: () => aliasFile, }) assert.equal(matchResolved, aliasFile) - - const nestedDir = path.join(aliasRoot, 'nested') - await fs.mkdir(nestedDir, { recursive: true }) - const nestedFile = path.join(nestedDir, 'file.css') - await fs.writeFile(nestedFile, '.nested {}\n') - const baseUrlResolved = await resolveWithTsconfigPaths('nested/file.css', { - absoluteBaseUrl: aliasRoot, - }) - assert.equal(baseUrlResolved, nestedFile) - - const unresolved = await resolveWithTsconfigPaths('alias-missing', { - matchPath: () => path.join(aliasRoot, 'missing.css'), - }) - assert.equal(unresolved, undefined) } finally { await fs.rm(aliasRoot, { recursive: true, force: true }) } assert.equal(await resolveWithTsconfigPaths('standalone'), undefined) - const tsconfigWithPathsRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'knighted-tsconfig-combo-'), - ) - try { - await fs.mkdir(path.join(tsconfigWithPathsRoot, 'src'), { recursive: true }) - await fs.writeFile( - path.join(tsconfigWithPathsRoot, 'tsconfig.json'), - JSON.stringify( - { - compilerOptions: { - baseUrl: './src', - paths: { - '@alias/*': ['@alias/*'], - }, - }, - }, - null, - 2, - ), - ) - const context = loadTsconfigResolutionContext(tsconfigWithPathsRoot) - assert.ok(context?.absoluteBaseUrl) - assert.ok(context?.matchPath) - } finally { - await fs.rm(tsconfigWithPathsRoot, { recursive: true, force: true }) - } - - const specifierRoot = await fs.mkdtemp( - path.join(os.tmpdir(), 'knighted-specifier-imports-'), - ) - try { - const plainFile = path.join(specifierRoot, 'plain.ts') - await fs.writeFile(plainFile, 'console.log("no selectors here")\n') - const noMatches = await findSpecifierImports(plainFile) - assert.deepEqual(noMatches, []) - - const requireFile = path.join(specifierRoot, 'require.js') - await fs.writeFile( - requireFile, - "const styles = require('./demo.css?knighted-css&types')\n", - ) - const requireMatches = await findSpecifierImports(requireFile) - assert.equal(requireMatches.length, 1) - assert.equal(requireMatches[0]?.specifier, './demo.css?knighted-css&types') - assert.equal(requireMatches[0]?.importer, requireFile) - const missingMatches = await findSpecifierImports( - path.join(specifierRoot, 'missing.js'), - ) - assert.deepEqual(missingMatches, []) - } finally { - await fs.rm(specifierRoot, { recursive: true, force: true }) - } - - const fakeMetaDir = path.join(os.tmpdir(), 'knighted-meta', 'esm') - const fakeModuleUrl = pathToFileURL(path.join(fakeMetaDir, 'index.js')).href - try { - setModuleTypeDetector(() => 'module') - setImportMetaUrlProvider(() => fakeModuleUrl) - const resolvedRoot = resolvePackageRoot() - assert.equal(resolvedRoot, path.resolve(fakeMetaDir, '..')) - } finally { - setModuleTypeDetector() - setImportMetaUrlProvider() - } + const loaderErrorContext = loadTsconfigResolutionContext('/tmp/project', () => { + throw new Error('tsconfig failure') + }) + assert.equal(loaderErrorContext, undefined) const resolveRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-resolve-import-')) try { @@ -714,48 +638,21 @@ test('generateTypes internals format selector-aware declarations', async () => { JSON.stringify({ name: 'resolve-fixture', version: '1.0.0' }, null, 2), ) const importer = path.join(resolveRoot, 'src', 'entry.ts') - const absoluteResult = await resolveImportPath( - '/styles/demo.css', - importer, - resolveRoot, - ) - assert.equal(absoluteResult, path.join(resolveRoot, 'styles', 'demo.css')) - const missingResult = await resolveImportPath( - 'non-existent-module', + const relativeResolved = await resolveImportPath( + './styles/demo.css', importer, resolveRoot, ) - assert.equal(missingResult, undefined) + assert.equal(relativeResolved, path.join(resolveRoot, 'src', 'styles', 'demo.css')) } finally { await fs.rm(resolveRoot, { recursive: true, force: true }) } - const loaderErrorContext = loadTsconfigResolutionContext('/tmp/project', () => { - throw new Error('tsconfig failure') - }) - assert.equal(loaderErrorContext, undefined) - const rooted = relativeToRoot( path.join('/tmp/project', 'src', 'demo.css'), '/tmp/project', ) assert.equal(rooted, path.join('src', 'demo.css')) - const outside = path.join(os.tmpdir(), 'outside.css') - const outsideRelative = path.relative('/tmp/project', outside) - assert.equal(relativeToRoot(outside, '/tmp/project'), outsideRelative) - - const indexRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-types-index-')) - try { - const outDir = path.join(indexRoot, 'out') - await fs.mkdir(outDir, { recursive: true }) - const indexPath = path.join(indexRoot, 'index.d.ts') - await writeTypesIndex(indexPath, {}, outDir) - const indexContent = await fs.readFile(indexPath, 'utf8') - assert.ok(indexContent.includes('Generated by @knighted/css/generate-types')) - assert.ok(!indexContent.includes(' { @@ -795,8 +692,6 @@ test('runGenerateTypesCli surfaces generator failures', async () => { 'src', '--out-dir', outDirFile, - '--types-root', - path.join(project.root, '.cli-types-error'), ]) observedExitCode = process.exitCode as number | undefined } finally { diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 88e396d..87d4793 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -14,7 +14,7 @@ "pretest": "npm run build" }, "dependencies": { - "@knighted/css": "1.0.0-rc.11", + "@knighted/css": "1.0.0-rc.12", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0",