diff --git a/docs/combined-queries.md b/docs/combined-queries.md index a40e0c6..ca1d380 100644 --- a/docs/combined-queries.md +++ b/docs/combined-queries.md @@ -3,7 +3,7 @@ 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 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`). 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. The `&stable` flag is an additive alias that expands to `&combined&types` and also marks the import for type generation. > [!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. @@ -15,9 +15,9 @@ This document summarizes how `?knighted-css&combined` behaves for different modu | **Named only** | `?knighted-css&combined&named-only` | [Snippet](#named-exports-only) | `&named-only` disables the synthetic default export so you only destructure the original named members plus `knightedCss`. | | **Default only** | `?knighted-css&combined` | [Snippet](#default-export-only) | Loader mirrors the default export and adds `knightedCss`, so default-import code keeps working. | | **Default + named** | `?knighted-css&combined` (append `&named-only` when you never consume the default) | [Snippet](#default-and-named-exports) | Without the flag you get both default + named exports; adding it drops the synthetic default for stricter codebases. | -| **Named + stable selectors** | `?knighted-css&combined&named-only&types` | [Snippet](#named-exports-with-stable-selectors) | Adds a `stableSelectors` named export; configure namespaces via the loader option or CLI flag. | -| **Default only + stable selectors** | `?knighted-css&combined&types` | [Snippet](#default-export-with-stable-selectors) | Keep your default-import flow and add `stableSelectors`; namespaces come from loader/CLI configuration. | -| **Default + named + stable selectors** | `?knighted-css&combined&types` (append `&named-only` if you skip the default) | [Snippet](#default-and-named-exports-with-stable-selectors) | Best of both worlds—`stableSelectors` is exported alongside `knightedCss`; add `&named-only` if you don’t use the default. | +| **Named + stable selectors** | `?knighted-css&stable&named-only` (alias: `&combined&named-only&types`) | [Snippet](#named-exports-with-stable-selectors) | Adds a `stableSelectors` named export; configure namespaces via the loader option or CLI flag. | +| **Default only + stable selectors** | `?knighted-css&stable` (alias: `&combined&types`) | [Snippet](#default-export-with-stable-selectors) | Keep your default-import flow and add `stableSelectors`; namespaces come from loader/CLI configuration. | +| **Default + named + stable selectors** | `?knighted-css&stable` (append `&named-only` if you skip the default) | [Snippet](#default-and-named-exports-with-stable-selectors) | Best of both worlds—`stableSelectors` is exported alongside `knightedCss`; add `&named-only` if you don’t use the default. | ### Jump to a scenario @@ -72,9 +72,13 @@ const { 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`) +## Adding stable selectors (`&stable` or `&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. +Use `&stable` when you want a single import that provides everything the loader can export: the module itself, `knightedCss`, and the runtime `stableSelectors` map. `&stable` is an additive alias for `&combined&types`, and it also marks the import for type generation so the CLI emits the `.knighted-css.ts` module that TypeScript reads for selector tokens. + +Use `&types` when you only need the runtime `stableSelectors` map alongside the combined import and you already wire type generation elsewhere. + +Configure the loader’s `stableNamespace` option (or pass `--stable-namespace` to the CLI) so runtime exports and generated `.knighted-css.ts` modules stay aligned. ### Named exports with stable selectors @@ -83,7 +87,7 @@ _Use when you only consume named exports (no default) but still want typed `stab ```ts import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' import type { KnightedCssStableSelectors as ModuleStableSelectors } from './module.css.knighted-css.js' -import * as combinedModule from './module.js?knighted-css&combined&named-only&types' +import * as combinedModule from './module.js?knighted-css&stable&named-only' const { Component, knightedCss, stableSelectors } = asKnightedCssCombinedModule< typeof import('./module.js'), @@ -103,7 +107,7 @@ _Use when you stick with default imports but also need access to the selector ma ```ts import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' import type { KnightedCssStableSelectors as ModuleStableSelectors } from './module.css.knighted-css.js' -import * as combinedModule from './module.js?knighted-css&combined&types' +import * as combinedModule from './module.js?knighted-css&stable' const { default: Component, @@ -124,7 +128,7 @@ _Use when you consume every export from the module and still want the selector m ```ts import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' import type { KnightedCssStableSelectors as ModuleStableSelectors } from './module.css.knighted-css.js' -import * as combinedModule from './module.js?knighted-css&combined&types' +import * as combinedModule from './module.js?knighted-css&stable' const { default: Component, @@ -144,7 +148,7 @@ stableSelectors.card // "knighted-card" (or your configured namespace) - 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. +- Add `&stable` when you want the single-import UX (module + `knightedCss` + `stableSelectors`). Use `&types` for the legacy combined selector map only. ## When to use `KnightedCssCombinedModule` diff --git a/packages/css/loader-queries.d.ts b/packages/css/loader-queries.d.ts index b601908..7f054b7 100644 --- a/packages/css/loader-queries.d.ts +++ b/packages/css/loader-queries.d.ts @@ -28,6 +28,11 @@ type KnightedCssCombinedModule< TExtras extends KnightedCssCombinedExtras = Record, > = TModule & TExtras & { knightedCss: string } +type KnightedCssStableCombinedModule = KnightedCssCombinedModule< + TModule, + { stableSelectors: KnightedCssStableSelectorMap } +> + declare module '*?knighted-css&combined' { const combined: KnightedCssCombinedModule> export default combined @@ -53,6 +58,20 @@ declare module '*?knighted-css&combined&types' { export const stableSelectors: KnightedCssStableSelectorMap } +declare module '*?knighted-css&stable' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + +declare module '*?knighted-css&combined&stable' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + declare module '*?knighted-css&combined&named-only&types' { const combined: KnightedCssCombinedModule> export default combined @@ -60,9 +79,51 @@ declare module '*?knighted-css&combined&named-only&types' { export const stableSelectors: KnightedCssStableSelectorMap } +declare module '*?knighted-css&stable&named-only' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + +declare module '*?knighted-css&combined&stable&named-only' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + +declare module '*?knighted-css&combined&named-only&stable' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + declare module '*?knighted-css&combined&no-default&types' { const combined: KnightedCssCombinedModule> export default combined export const knightedCss: string export const stableSelectors: KnightedCssStableSelectorMap } + +declare module '*?knighted-css&stable&no-default' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + +declare module '*?knighted-css&combined&stable&no-default' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} + +declare module '*?knighted-css&combined&no-default&stable' { + const combined: KnightedCssStableCombinedModule> + export default combined + export const knightedCss: string + export const stableSelectors: KnightedCssStableSelectorMap +} diff --git a/packages/css/src/generateTypes.ts b/packages/css/src/generateTypes.ts index 3a9f204..6864987 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -25,6 +25,7 @@ interface ManifestEntry { } type SelectorModuleManifest = Record +type StableDeclarationManifest = Record interface TsconfigResolutionContext { absoluteBaseUrl?: string @@ -110,7 +111,10 @@ function getImportMetaUrl(): string | undefined { } const SELECTOR_REFERENCE = '.knighted-css' +const KNIGHTED_CSS_QUERY_FLAG = 'knighted-css' +const STABLE_QUERY_FLAG = 'stable' const SELECTOR_MODULE_SUFFIX = '.knighted-css.ts' +const STABLE_DECLARATION_SUFFIX = '.knighted-css-stable.d.ts' export async function generateTypes( options: GenerateTypesOptions = {}, @@ -140,10 +144,27 @@ async function generateDeclarations( const peerResolver = createProjectPeerResolver(options.rootDir) const files = await collectCandidateFiles(options.include) const selectorModulesManifestPath = path.join(options.cacheDir, 'selector-modules.json') + const stableDeclarationsManifestPath = path.join( + options.cacheDir, + 'stable-declarations.json', + ) const previousSelectorManifest = await readManifest(selectorModulesManifestPath) + const previousStableManifest = await readManifest(stableDeclarationsManifestPath) const nextSelectorManifest: SelectorModuleManifest = {} + const nextStableManifest: StableDeclarationManifest = {} const selectorCache = new Map>() + const moduleExportCache = new Map() const processedSelectors = new Set() + const stableDeclarations = new Map< + string, + Array<{ + specifier: string + resourcePath: string + selectorMap: Map + exportNames: string[] + namedOnly: boolean + }> + >() const warnings: string[] = [] let selectorModuleWrites = 0 @@ -152,8 +173,11 @@ async function generateDeclarations( for (const match of matches) { const cleaned = match.specifier.trim() const inlineFree = stripInlineLoader(cleaned) - const { resource } = splitResourceAndQuery(inlineFree) - const selectorSource = extractSelectorSourceSpecifier(resource) + const { resource, query } = splitResourceAndQuery(inlineFree) + const isStableImport = hasStableQuery(query) + const selectorSource = + extractSelectorSourceSpecifier(resource) || + (isStableImport ? resource : undefined) if (!selectorSource) { continue } @@ -208,19 +232,37 @@ async function generateDeclarations( } const manifestKey = buildSelectorModuleManifestKey(resolvedPath) - if (processedSelectors.has(manifestKey)) { - continue + if (!isStableImport) { + if (processedSelectors.has(manifestKey)) { + continue + } + const moduleWrite = await ensureSelectorModule( + resolvedPath, + selectorMap, + previousSelectorManifest, + nextSelectorManifest, + ) + if (moduleWrite) { + selectorModuleWrites += 1 + } + processedSelectors.add(manifestKey) } - const moduleWrite = await ensureSelectorModule( - resolvedPath, - selectorMap, - previousSelectorManifest, - nextSelectorManifest, - ) - if (moduleWrite) { - selectorModuleWrites += 1 + + if (isStableImport) { + const namedOnly = hasNamedOnlyQueryFlag(query) + const exportNames = isScriptResource(resolvedPath) + ? await getModuleExportNames(resolvedPath, moduleExportCache) + : [] + const stableEntries = stableDeclarations.get(match.importer) ?? [] + stableEntries.push({ + specifier: match.specifier, + resourcePath: resolvedPath, + selectorMap, + exportNames, + namedOnly, + }) + stableDeclarations.set(match.importer, stableEntries) } - processedSelectors.add(manifestKey) } } @@ -228,11 +270,30 @@ async function generateDeclarations( previousSelectorManifest, nextSelectorManifest, ) + const stableDeclarationsRemoved = await removeStaleSelectorModules( + previousStableManifest, + nextStableManifest, + ) await writeManifest(selectorModulesManifestPath, nextSelectorManifest) + await writeManifest(stableDeclarationsManifestPath, nextStableManifest) + + for (const [importerPath, entries] of stableDeclarations) { + const declarationSource = formatStableDeclarationSource(entries) + const declarationPath = buildStableDeclarationPath(importerPath) + const hash = hashContent(declarationSource) + const manifestKey = buildSelectorModuleManifestKey(importerPath) + const previousEntry = previousStableManifest[manifestKey] + const needsWrite = + previousEntry?.hash !== hash || !(await fileExists(declarationPath)) + if (needsWrite) { + await fs.writeFile(declarationPath, declarationSource, 'utf8') + } + nextStableManifest[manifestKey] = { file: declarationPath, hash } + } return { selectorModulesWritten: selectorModuleWrites, - selectorModulesRemoved, + selectorModulesRemoved: selectorModulesRemoved + stableDeclarationsRemoved, warnings, manifestPath: selectorModulesManifestPath, } @@ -301,14 +362,14 @@ async function findSpecifierImports(filePath: string): Promise { } catch { return [] } - if (!source.includes(SELECTOR_REFERENCE)) { + if (!source.includes(SELECTOR_REFERENCE) && !source.includes(KNIGHTED_CSS_QUERY_FLAG)) { return [] } const matches: ImportMatch[] = [] try { const { imports } = await analyzeModule(source, filePath) for (const specifier of imports) { - if (specifier.includes(SELECTOR_REFERENCE)) { + if (specifier.includes(SELECTOR_REFERENCE) || isStableQuerySpecifier(specifier)) { matches.push({ specifier, importer: filePath }) } } @@ -323,6 +384,15 @@ async function findSpecifierImports(filePath: string): Promise { matches.push({ specifier: spec, importer: filePath }) } } + const stableSpecifierRegex = + /(?:import\s+(?:[^'"`]+?\s+from\s+)?|export\s+[^'"`]+?\s+from\s+|import\s*\(\s*|require\()(['"])([^'"`]+?\?[^'"`]*knighted-css[^'"`]*stable[^'"`]*)\1/g + let stableMatch: RegExpExecArray | null + while ((stableMatch = stableSpecifierRegex.exec(source)) !== null) { + const spec = stableMatch[2] + if (spec) { + matches.push({ specifier: spec, importer: filePath }) + } + } return matches } @@ -341,6 +411,44 @@ function splitResourceAndQuery(specifier: string): { resource: string; query: st return { resource: trimmed.slice(0, queryIndex), query: trimmed.slice(queryIndex) } } +function splitQuery(query: string): string[] { + const trimmed = query.startsWith('?') ? query.slice(1) : query + if (!trimmed) return [] + return trimmed.split('&').filter(Boolean) +} + +function isQueryFlag(entry: string, flag: string): boolean { + const [rawKey] = entry.split('=') + try { + return decodeURIComponent(rawKey) === flag + } catch { + return rawKey === flag + } +} + +function hasQueryFlag(query: string, flag: string): boolean { + if (!query) return false + const entries = splitQuery(query) + if (entries.length === 0) return false + return entries.some(part => isQueryFlag(part, flag)) +} + +function hasStableQuery(query: string): boolean { + return ( + hasQueryFlag(query, KNIGHTED_CSS_QUERY_FLAG) && hasQueryFlag(query, STABLE_QUERY_FLAG) + ) +} + +function hasNamedOnlyQueryFlag(query: string): boolean { + return hasQueryFlag(query, 'named-only') || hasQueryFlag(query, 'no-default') +} + +function isStableQuerySpecifier(specifier: string): boolean { + const cleaned = stripInlineLoader(specifier) + const { query } = splitResourceAndQuery(cleaned) + return hasStableQuery(query) +} + function extractSelectorSourceSpecifier(specifier: string): string | undefined { const markerIndex = specifier.indexOf(SELECTOR_REFERENCE) if (markerIndex < 0) { @@ -367,18 +475,21 @@ async function resolveImportPath( ): Promise { if (!resourceSpecifier) return undefined if (resourceSpecifier.startsWith('.')) { - return path.resolve(path.dirname(importerPath), resourceSpecifier) + const resolved = path.resolve(path.dirname(importerPath), resourceSpecifier) + return resolveScriptExtensionAlias(resolved) } if (resourceSpecifier.startsWith('/')) { - return path.resolve(rootDir, resourceSpecifier.slice(1)) + const resolved = path.resolve(rootDir, resourceSpecifier.slice(1)) + return resolveScriptExtensionAlias(resolved) } const tsconfigResolved = await resolveWithTsconfigPaths(resourceSpecifier, tsconfig) if (tsconfigResolved) { - return tsconfigResolved + return resolveScriptExtensionAlias(tsconfigResolved) } const requireFromRoot = getProjectRequire(rootDir) try { - return requireFromRoot.resolve(resourceSpecifier) + const resolved = requireFromRoot.resolve(resourceSpecifier) + return resolveScriptExtensionAlias(resolved) } catch { return undefined } @@ -392,6 +503,33 @@ function buildSelectorModulePath(resolvedPath: string): string { return `${resolvedPath}${SELECTOR_MODULE_SUFFIX}` } +function buildStableDeclarationPath(importerPath: string): string { + return `${importerPath}${STABLE_DECLARATION_SUFFIX}` +} + +function isScriptResource(resourcePath: string): boolean { + return SUPPORTED_EXTENSIONS.has(path.extname(resourcePath).toLowerCase()) +} + +async function getModuleExportNames( + resourcePath: string, + cache: Map, +): Promise { + const cached = cache.get(resourcePath) + if (cached) { + return cached + } + try { + const source = await fs.readFile(resourcePath, 'utf8') + const { exports } = await analyzeModule(source, resourcePath) + cache.set(resourcePath, exports) + return exports + } catch { + cache.set(resourcePath, []) + return [] + } +} + 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)) @@ -414,14 +552,75 @@ export default stableSelectors ` } +function formatStableSelectorTypeLiteral(selectors: Map): string { + const entries = Array.from(selectors.entries()).sort(([a], [b]) => a.localeCompare(b)) + const lines = entries.map( + ([token, selector]) => ` ${JSON.stringify(token)}: ${JSON.stringify(selector)},`, + ) + return lines.length > 0 + ? `{ +${lines.join('\n')} +}` + : '{}' +} + +function formatStableDeclarationSource( + entries: Array<{ + specifier: string + resourcePath: string + selectorMap: Map + exportNames: string[] + namedOnly: boolean + }>, +): string { + const header = '// Generated by @knighted/css/generate-types\n// Do not edit.\n' + const blocks = entries.map(entry => { + const { resource } = splitResourceAndQuery(stripInlineLoader(entry.specifier)) + const moduleSpecifier = resource || entry.resourcePath + const selectorType = formatStableSelectorTypeLiteral(entry.selectorMap) + const namedExports = entry.exportNames.filter(name => name !== 'default') + const namedExportBlock = + namedExports.length > 0 + ? namedExports + .sort() + .map( + name => + `export const ${name}: typeof import(${JSON.stringify( + moduleSpecifier, + )}).${name};`, + ) + .join('\n') + : '' + const defaultBlock = entry.namedOnly + ? '' + : `declare const combined: (typeof import(${JSON.stringify( + moduleSpecifier, + )})) & { knightedCss: string; stableSelectors: Readonly<${selectorType}> }; +` + const exportsBlock = entry.namedOnly + ? namedExportBlock + : `${namedExportBlock}${namedExportBlock ? '\n' : ''}export default combined;` + return `${header} +declare module ${JSON.stringify(entry.specifier)} { + export const knightedCss: string + export const stableSelectors: Readonly<${selectorType}> + ${defaultBlock} ${exportsBlock} +} +` + }) + return blocks.join('\n') +} + 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 SelectorModuleManifest + return JSON.parse(raw) as Record } catch { return {} } @@ -429,14 +628,14 @@ async function readManifest(manifestPath: string): Promise, ): Promise { await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2), 'utf8') } async function removeStaleSelectorModules( - previous: SelectorModuleManifest, - next: SelectorModuleManifest, + previous: Record, + next: Record, ): Promise { const stale = Object.entries(previous).filter(([key]) => !next[key]) let removed = 0 @@ -495,6 +694,34 @@ async function fileExists(target: string): Promise { } } +async function resolveScriptExtensionAlias(resolvedPath: string): Promise { + if (await fileExists(resolvedPath)) { + return resolvedPath + } + const ext = path.extname(resolvedPath).toLowerCase() + const base = resolvedPath.slice(0, resolvedPath.length - ext.length) + const candidates: string[] = [] + if (ext === '.js') { + candidates.push( + `${base}.ts`, + `${base}.tsx`, + `${base}.mts`, + `${base}.cts`, + `${base}.jsx`, + ) + } else if (ext === '.mjs') { + candidates.push(`${base}.mts`) + } else if (ext === '.cjs') { + candidates.push(`${base}.cts`) + } + for (const candidate of candidates) { + if (await fileExists(candidate)) { + return candidate + } + } + return resolvedPath +} + async function resolveWithTsconfigPaths( specifier: string, tsconfig?: TsconfigResolutionContext, diff --git a/packages/css/src/lexer.ts b/packages/css/src/lexer.ts index 9e2be7f..32e77f6 100644 --- a/packages/css/src/lexer.ts +++ b/packages/css/src/lexer.ts @@ -21,6 +21,7 @@ interface AnalyzeOptions { interface ModuleAnalysis { imports: string[] defaultSignal: DefaultExportSignal + exports: string[] } const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']) @@ -44,6 +45,7 @@ export async function analyzeModule( return { imports: normalizeEsImports(imports, sourceText), defaultSignal: classifyDefault(exports), + exports: normalizeEsExports(exports), } } catch { // fall through to oxc fallback @@ -104,10 +106,11 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { } if (!program) { - return { imports: [], defaultSignal: 'unknown' } + return { imports: [], defaultSignal: 'unknown', exports: [] } } const imports: string[] = [] + const exports = new Set() let defaultSignal: DefaultExportSignal = 'unknown' const addSpecifier = (raw?: string | null) => { if (!raw) { @@ -127,6 +130,17 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { if (node.source) { addSpecifier(node.source.value) } + if (node.declaration) { + for (const name of getExportedNamesFromDeclaration(node.declaration)) { + exports.add(name) + } + } + for (const spec of node.specifiers ?? []) { + const exportedName = getExportedName(spec.exported) + if (exportedName && exportedName !== 'default') { + exports.add(exportedName) + } + } if (hasDefaultSpecifier(node)) { defaultSignal = 'has-default' } else if (defaultSignal === 'unknown' && hasAnySpecifier(node)) { @@ -138,6 +152,10 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { if (node.exported && isExportedAsDefault(node.exported)) { defaultSignal = 'has-default' } + const exportedName = getExportedName(node.exported) + if (exportedName && exportedName !== 'default') { + exports.add(exportedName) + } }, ExportDefaultDeclaration() { defaultSignal = 'has-default' @@ -172,7 +190,19 @@ function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { visitor.visit(program) - return { imports, defaultSignal } + return { imports, defaultSignal, exports: Array.from(exports) } +} + +function normalizeEsExports(records: readonly { n: string | undefined }[]): string[] { + const exports = new Set() + for (const record of records) { + const name = record.n?.trim() + if (!name || name === 'default' || name === '*') { + continue + } + exports.add(name) + } + return Array.from(exports) } function normalizeSpecifier(raw: string): string { @@ -203,6 +233,95 @@ function hasAnySpecifier(node: ExportNamedDeclaration): boolean { return Array.isArray(node.specifiers) && node.specifiers.length > 0 } +function getExportedName( + exported: { name?: string; value?: string } | null | undefined, +): string | undefined { + if (!exported) return undefined + if (typeof exported.name === 'string') { + return exported.name + } + if (typeof exported.value === 'string') { + return exported.value + } + return undefined +} + +function getExportedNamesFromDeclaration(declaration: unknown): string[] { + if (!declaration || typeof declaration !== 'object') { + return [] + } + if (isFunctionDeclaration(declaration) || isClassDeclaration(declaration)) { + const name = declaration.id?.name + return name ? [name] : [] + } + if (isVariableDeclaration(declaration)) { + const names: string[] = [] + for (const declarator of declaration.declarations) { + names.push(...collectBindingNames(declarator.id)) + } + return names + } + return [] +} + +type IdentifierLike = { type: 'Identifier'; name: string } +type BindingPatternLike = IdentifierLike | { type: string } +type VariableDeclaratorLike = { id: BindingPatternLike } +type VariableDeclarationLike = { + type: 'VariableDeclaration' + declarations: VariableDeclaratorLike[] +} +type FunctionDeclarationLike = { type: 'FunctionDeclaration'; id?: IdentifierLike | null } +type ClassDeclarationLike = { type: 'ClassDeclaration'; id?: IdentifierLike | null } + +function isIdentifier(value: unknown): value is IdentifierLike { + if (!value || typeof value !== 'object') { + return false + } + if (!('type' in value) || !('name' in value)) { + return false + } + return value.type === 'Identifier' && typeof value.name === 'string' +} + +function isVariableDeclaration(value: unknown): value is VariableDeclarationLike { + if (!value || typeof value !== 'object') { + return false + } + if (!('type' in value) || !('declarations' in value)) { + return false + } + return value.type === 'VariableDeclaration' && Array.isArray(value.declarations) +} + +function isFunctionDeclaration(value: unknown): value is FunctionDeclarationLike { + if (!value || typeof value !== 'object') { + return false + } + if (!('type' in value)) { + return false + } + return value.type === 'FunctionDeclaration' +} + +function isClassDeclaration(value: unknown): value is ClassDeclarationLike { + if (!value || typeof value !== 'object') { + return false + } + if (!('type' in value)) { + return false + } + return value.type === 'ClassDeclaration' +} + +function collectBindingNames(binding: BindingPatternLike | undefined): string[] { + if (!binding) return [] + if (isIdentifier(binding)) { + return [binding.name] + } + return [] +} + function isExportedAsDefault( exported: { name?: string; value?: string } | null | undefined, ): boolean { diff --git a/packages/css/src/loader.ts b/packages/css/src/loader.ts index e951602..8d2b266 100644 --- a/packages/css/src/loader.ts +++ b/packages/css/src/loader.ts @@ -21,6 +21,7 @@ import { hasQueryFlag, shouldEmitCombinedDefault, shouldForwardDefaultExport, + STABLE_QUERY_FLAG, TYPES_QUERY_FLAG, } from './loaderInternals.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' @@ -65,7 +66,9 @@ const loader: LoaderDefinitionFunction = async functio stableNamespace: optionNamespace, } = resolveLoaderOptions(this) const resolvedNamespace = resolveStableNamespace(optionNamespace) - const typesRequested = hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG) + const typesRequested = + hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG) || + hasQueryFlag(this.resourceQuery, STABLE_QUERY_FLAG) const isStyleModule = this.resourcePath.endsWith('.css.ts') const cssOptionsForExtract = isStyleModule ? { ...cssOptions, autoStable: undefined } @@ -151,7 +154,9 @@ export const pitch: PitchLoaderDefinitionFunction = const request = buildProxyRequest(this) const { cssOptions, stableNamespace: optionNamespace } = resolveLoaderOptions(this) - const typesRequested = hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG) + const typesRequested = + hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG) || + hasQueryFlag(this.resourceQuery, STABLE_QUERY_FLAG) const resolvedNamespace = resolveStableNamespace(optionNamespace) const skipSyntheticDefault = hasNamedOnlyQueryFlag(this.resourceQuery) const defaultSignalPromise = skipSyntheticDefault diff --git a/packages/css/src/loaderInternals.ts b/packages/css/src/loaderInternals.ts index 815f8a2..435e3ba 100644 --- a/packages/css/src/loaderInternals.ts +++ b/packages/css/src/loaderInternals.ts @@ -1,6 +1,7 @@ import type { ModuleDefaultSignal } from './moduleInfo.js' export const COMBINED_QUERY_FLAG = 'combined' +export const STABLE_QUERY_FLAG = 'stable' export const TYPES_QUERY_FLAG = 'types' export const NAMED_ONLY_QUERY_FLAGS = ['named-only', 'no-default'] as const export type SelectorTypeVariant = 'types' | 'combined' | 'combinedWithoutDefault' @@ -32,6 +33,9 @@ export function buildSanitizedQuery(query?: string | null): string { if (isQueryFlag(part, TYPES_QUERY_FLAG)) { return false } + if (isQueryFlag(part, STABLE_QUERY_FLAG)) { + return false + } if (NAMED_ONLY_QUERY_FLAGS.some(flag => isQueryFlag(part, flag))) { return false } @@ -58,7 +62,9 @@ export function shouldForwardDefaultExport(request: string): boolean { } export function hasCombinedQuery(query?: string | null): boolean { - return hasQueryFlag(query, COMBINED_QUERY_FLAG) + return ( + hasQueryFlag(query, COMBINED_QUERY_FLAG) || hasQueryFlag(query, STABLE_QUERY_FLAG) + ) } export function hasNamedOnlyQueryFlag(query?: string | null): boolean { diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index 985609f..4731823 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -44,6 +44,29 @@ console.log(stableSelectors.demo) } } +async function setupStableQueryProject(): Promise<{ + root: string + cleanup: () => Promise +}> { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-generate-stable-')) + const srcDir = path.join(tmpRoot, 'src') + await fs.mkdir(srcDir, { recursive: true }) + 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&stable` + const entrySource = `import { stableSelectors } from '${specifier}' +console.log(stableSelectors.demo) +` + await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) + return { + root: tmpRoot, + cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + } +} + async function setupBaseUrlFixture(): Promise<{ root: string cleanup: () => Promise @@ -219,6 +242,33 @@ test('generateTypes emits declarations and reuses cache', async () => { } }) +test('generateTypes scans ?knighted-css&stable imports', async () => { + const project = await setupStableQueryProject() + try { + const outDir = path.join(project.root, '.knighted-css-test') + const sharedOptions = { rootDir: project.root, include: ['src'], outDir } + + const result = await generateTypes(sharedOptions) + assert.equal(result.selectorModulesWritten, 0) + assert.equal(result.warnings.length, 0) + + const declarationPath = path.join( + project.root, + 'src', + 'entry.ts.knighted-css-stable.d.ts', + ) + const declarationSource = await fs.readFile(declarationPath, 'utf8') + assert.ok( + declarationSource.includes( + 'declare module "./fixture/entry.js?knighted-css&stable"', + ), + ) + assert.ok(declarationSource.includes('export const stableSelectors')) + } finally { + await project.cleanup() + } +}) + test('generateTypes autoStable emits selectors for CSS Modules', async () => { const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-auto-stable-')) try { diff --git a/packages/css/test/lexer.test.ts b/packages/css/test/lexer.test.ts index dd167d3..e8990c0 100644 --- a/packages/css/test/lexer.test.ts +++ b/packages/css/test/lexer.test.ts @@ -27,6 +27,7 @@ void styles }) assert.equal(result.defaultSignal, 'has-default') + assert.deepEqual(result.exports, []) assert.deepEqual(result.imports.sort(), [ '#hash/map.css', './styles/import-equals.css', @@ -55,6 +56,7 @@ void spread }) assert.equal(result.defaultSignal, 'has-default') + assert.deepEqual(result.exports.sort(), ['another', 'named']) assert.deepEqual(result.imports.sort(), [ './styles/another.css', './styles/named.css', diff --git a/packages/css/test/loaderQueries.types.ts b/packages/css/test/loaderQueries.types.ts index 0eb65f3..43a0f69 100644 --- a/packages/css/test/loaderQueries.types.ts +++ b/packages/css/test/loaderQueries.types.ts @@ -7,6 +7,9 @@ import '@knighted/css/loader-queries' type CombinedModule = typeof import('./fixtures/combined/runtime-entry.ts?knighted-css&combined&types') +type StableModule = + typeof import('./fixtures/combined/runtime-entry.ts?knighted-css&stable') + type ExpectTrue = T type KnightedCssIsString = ExpectTrue< @@ -18,3 +21,14 @@ type StableSelectorsAreReadonly = ExpectTrue< : false > export type LoaderQueriesSmokeTest = [KnightedCssIsString, StableSelectorsAreReadonly] + +type StableKnightedCssIsString = ExpectTrue< + StableModule['knightedCss'] extends string ? true : false +> +type StableSelectorsAreReadonlyForStable = ExpectTrue< + StableModule['stableSelectors'] extends Readonly> ? true : false +> +export type LoaderQueriesStableSmokeTest = [ + StableKnightedCssIsString, + StableSelectorsAreReadonlyForStable, +] diff --git a/packages/css/test/loaderUnit.test.ts b/packages/css/test/loaderUnit.test.ts index 2558fad..75fa61c 100644 --- a/packages/css/test/loaderUnit.test.ts +++ b/packages/css/test/loaderUnit.test.ts @@ -546,9 +546,35 @@ test('combined&types proxy surfaces runtime stableSelectors export', async () => assert.match(combinedOutput, /export const knightedCss = /) }) +test('stable query behaves like combined&types', async () => { + const resourcePath = path.resolve(__dirname, 'fixtures/dialects/basic/entry.js') + const ctx = createMockContext({ + resourcePath, + resourceQuery: '?knighted-css&stable', + loadModule: (_request: string, callback: LoaderCallback) => { + callback(null, 'export const Card = () => null;') + }, + }) + + const result = await pitch.call( + ctx as LoaderContext, + `${resourcePath}?knighted-css&stable`, + '', + {}, + ) + + const combinedOutput = String(result ?? '') + assert.match( + combinedOutput, + /export const stableSelectors = Object\.freeze\(\{[^}]+\}\);/, + ) + assert.match(combinedOutput, /export const knightedCss = /) +}) + test('determineSelectorVariant maps query combinations to expected variants', () => { assert.equal(determineSelectorVariant('?knighted-css&types'), 'types') assert.equal(determineSelectorVariant('?knighted-css&combined'), 'combined') + assert.equal(determineSelectorVariant('?knighted-css&stable'), 'combined') assert.equal( determineSelectorVariant('?knighted-css&combined&named-only'), 'combinedWithoutDefault', @@ -557,4 +583,8 @@ test('determineSelectorVariant maps query combinations to expected variants', () determineSelectorVariant('?knighted-css&combined&no-default'), 'combinedWithoutDefault', ) + assert.equal( + determineSelectorVariant('?knighted-css&stable&named-only'), + 'combinedWithoutDefault', + ) }) diff --git a/packages/playwright/playwright.config.ts b/packages/playwright/playwright.config.ts index b60df8a..303b07e 100644 --- a/packages/playwright/playwright.config.ts +++ b/packages/playwright/playwright.config.ts @@ -17,7 +17,7 @@ if (isCI) { export default defineConfig({ testDir: 'test', - timeout: 20_000, + timeout: 5_000, retries: isCI ? 1 : 0, expect: { timeout: 10_000, diff --git a/packages/playwright/src/auto-stable/lit-host.ts b/packages/playwright/src/auto-stable/lit-host.ts index a9d1e86..12fba4d 100644 --- a/packages/playwright/src/auto-stable/lit-host.ts +++ b/packages/playwright/src/auto-stable/lit-host.ts @@ -3,11 +3,17 @@ 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 * as shadowTree from './shadow-tree.js?knighted-css&combined&named-only&stable' import { AUTO_STABLE_HOST_TAG } from './constants.js' -const { ShadowTree, knightedCss: shadowTreeCss } = - asKnightedCssCombinedModule(shadowTree) +const { + ShadowTree, + knightedCss: shadowTreeCss, + stableSelectors, +} = asKnightedCssCombinedModule< + typeof import('./shadow-tree.js'), + { stableSelectors: typeof shadowTree.stableSelectors } +>(shadowTree) const hostShell = css` :host { display: block; @@ -44,7 +50,7 @@ export class AutoStableHost extends LitElement { #renderReactTree(): void { if (!this.#reactRoot) return - this.#reactRoot.render(reactJsx`<${ShadowTree} />`) + this.#reactRoot.render(reactJsx`<${ShadowTree} stableSelectors=${stableSelectors} />`) } protected updated(changed: PropertyValues): void { diff --git a/packages/playwright/src/auto-stable/lit-host.ts.knighted-css-stable.d.ts b/packages/playwright/src/auto-stable/lit-host.ts.knighted-css-stable.d.ts new file mode 100644 index 0000000..c4ba4e8 --- /dev/null +++ b/packages/playwright/src/auto-stable/lit-host.ts.knighted-css-stable.d.ts @@ -0,0 +1,15 @@ +// Generated by @knighted/css/generate-types +// Do not edit. + +declare module './shadow-tree.js?knighted-css&combined&named-only&stable' { + export const knightedCss: string + export const stableSelectors: Readonly<{ + badge: 'knighted-badge' + card: 'knighted-card' + copy: 'knighted-copy' + stack: 'knighted-stack' + title: 'knighted-title' + token: 'knighted-token' + }> + export const ShadowTree: typeof import('./shadow-tree.js').ShadowTree +} diff --git a/packages/playwright/src/auto-stable/shadow-tree.tsx b/packages/playwright/src/auto-stable/shadow-tree.tsx index ccb739b..d142a98 100644 --- a/packages/playwright/src/auto-stable/shadow-tree.tsx +++ b/packages/playwright/src/auto-stable/shadow-tree.tsx @@ -1,8 +1,17 @@ import './styles.module.css' -import stableSelectors from './styles.module.css.knighted-css.js' import { AUTO_STABLE_SHADOW_TEST_ID, AUTO_STABLE_TOKEN_TEST_ID } from './constants.js' -export function ShadowTree() { +import type { KnightedCssStableSelectors } from './styles.module.css.knighted-css.js' + +type ShadowTreeStableSelectors = Readonly< + Record +> + +type ShadowTreeProps = { + stableSelectors: ShadowTreeStableSelectors +} + +export function ShadowTree({ stableSelectors }: ShadowTreeProps) { return (
diff --git a/packages/playwright/src/auto-stable/shadow-tree.tsx.knighted-css-stable.d.ts b/packages/playwright/src/auto-stable/shadow-tree.tsx.knighted-css-stable.d.ts new file mode 100644 index 0000000..c0c638d --- /dev/null +++ b/packages/playwright/src/auto-stable/shadow-tree.tsx.knighted-css-stable.d.ts @@ -0,0 +1,26 @@ +// Generated by @knighted/css/generate-types +// Do not edit. + +declare module './styles.module.css?knighted-css&stable' { + export const knightedCss: string + export const stableSelectors: Readonly<{ + badge: 'knighted-badge' + card: 'knighted-card' + copy: 'knighted-copy' + stack: 'knighted-stack' + title: 'knighted-title' + token: 'knighted-token' + }> + declare const combined: typeof import('./styles.module.css') & { + knightedCss: string + stableSelectors: Readonly<{ + badge: 'knighted-badge' + card: 'knighted-card' + copy: 'knighted-copy' + stack: 'knighted-stack' + title: 'knighted-title' + token: 'knighted-token' + }> + } + export default combined +}