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",