From 5d43a04a33adfa3d9fff4d666bf68fb5b6686781 Mon Sep 17 00:00:00 2001 From: KCM Date: Sun, 21 Dec 2025 18:06:25 -0600 Subject: [PATCH] fix: emit canonical ?knighted-css declaration specifiers. --- README.md | 4 +- package-lock.json | 4 +- packages/css/package.json | 2 +- packages/css/src/generateTypes.ts | 83 ++++++++++++++++++++++--- packages/css/test/generateTypes.test.ts | 16 ++++- packages/playwright/package.json | 2 +- 6 files changed, 95 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 8f79ccd..949d63e 100644 --- a/README.md +++ b/README.md @@ -204,7 +204,7 @@ CSS Modules hash class names after the loader extracts selectors, so the stylesh Run `npx knighted-css-generate-types --root .` to scan your project for `?knighted-css&types` imports. The CLI: -- extracts selectors via the loader, then writes literal module declarations into `node_modules/@knighted/css/node_modules/.knighted-css` +- 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 @@ -316,7 +316,7 @@ 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`. It also maintains `node_modules/@knighted/css/types-stub/index.d.ts`, so TypeScript picks up the generated declarations automatically—no extra `typeRoots` configuration is required. +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: diff --git a/package-lock.json b/package-lock.json index 3980d7e..d573a7f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11922,7 +11922,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.0-rc.10", + "version": "1.0.0-rc.11", "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.10", + "@knighted/css": "1.0.0-rc.11", "@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 1032c9c..fb16d00 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.0-rc.10", + "version": "1.0.0-rc.11", "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 be3f659..d6d48b4 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -15,6 +15,9 @@ import { determineSelectorVariant, hasQueryFlag, TYPES_QUERY_FLAG, + buildSanitizedQuery, + COMBINED_QUERY_FLAG, + NAMED_ONLY_QUERY_FLAGS, type SelectorTypeVariant, } from './loaderInternals.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' @@ -174,9 +177,6 @@ async function generateDeclarations( if (!query || !hasQueryFlag(query, TYPES_QUERY_FLAG)) { continue } - if (processedSpecifiers.has(cleaned)) { - continue - } const resolvedNamespace = resolveStableNamespace(options.stableNamespace) const resolvedPath = await resolveImportPath( resource, @@ -214,23 +214,35 @@ async function generateDeclarations( selectorCache.set(cacheKey, selectorMap) } + const canonicalSpecifier = buildDeclarationModuleSpecifier( + resolvedPath, + options.outDir, + query, + ) + if (processedSpecifiers.has(canonicalSpecifier)) { + continue + } const variant = determineSelectorVariant(query) - const declaration = formatModuleDeclaration(cleaned, variant, selectorMap) + const declaration = formatModuleDeclaration( + canonicalSpecifier, + variant, + selectorMap, + ) const declarationHash = hashContent(declaration) - const fileName = buildDeclarationFileName(cleaned) + const fileName = buildDeclarationFileName(canonicalSpecifier) const targetPath = path.join(options.outDir, fileName) - const previousEntry = previousManifest[cleaned] + const previousEntry = previousManifest[canonicalSpecifier] const needsWrite = previousEntry?.hash !== declarationHash || !(await fileExists(targetPath)) if (needsWrite) { await fs.writeFile(targetPath, declaration, 'utf8') writes += 1 } - nextManifest[cleaned] = { file: fileName, hash: declarationHash } + nextManifest[canonicalSpecifier] = { file: fileName, hash: declarationHash } if (needsWrite) { - declarations.push({ specifier: cleaned, filePath: targetPath }) + declarations.push({ specifier: canonicalSpecifier, filePath: targetPath }) } - processedSpecifiers.add(cleaned) + processedSpecifiers.add(canonicalSpecifier) } } @@ -436,6 +448,57 @@ ${lines.join('\n')} }>` } +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}` +} + +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('&')}` : '' +} + function hashContent(content: string): string { return crypto.createHash('sha1').update(content).digest('hex') } @@ -778,6 +841,8 @@ export const __generateTypesInternals = { buildDeclarationFileName, formatModuleDeclaration, formatSelectorType, + buildDeclarationModuleSpecifier, + buildCanonicalQuery, relativeToRoot, collectCandidateFiles, normalizeIncludeOptions, diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index e231d02..a6acc4f 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -121,7 +121,7 @@ test('generateTypes resolves tsconfig baseUrl specifiers', async () => { string, unknown > - assert.ok(manifest['styles/demo.css?knighted-css&types']) + assert.ok(manifest['../src/styles/demo.css?knighted-css&types']) } finally { await project.cleanup() } @@ -354,6 +354,8 @@ test('generateTypes internals format selector-aware declarations', async () => { buildDeclarationFileName, formatSelectorType, formatModuleDeclaration, + buildDeclarationModuleSpecifier, + buildCanonicalQuery, writeTypesIndex, normalizeIncludeOptions, collectCandidateFiles, @@ -411,6 +413,18 @@ test('generateTypes internals format selector-aware declarations', async () => { ) 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'), [ diff --git a/packages/playwright/package.json b/packages/playwright/package.json index c16cd65..88e396d 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.10", + "@knighted/css": "1.0.0-rc.11", "@knighted/jsx": "^1.4.1", "lit": "^3.2.1", "react": "^19.0.0",