Skip to content

Commit 5d43a04

Browse files
fix: emit canonical ?knighted-css declaration specifiers.
1 parent 086fd85 commit 5d43a04

6 files changed

Lines changed: 95 additions & 16 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -204,7 +204,7 @@ CSS Modules hash class names after the loader extracts selectors, so the stylesh
204204
205205
Run `npx knighted-css-generate-types --root .` to scan your project for `?knighted-css&types` imports. The CLI:
206206
207-
- extracts selectors via the loader, then writes literal module declarations into `node_modules/@knighted/css/node_modules/.knighted-css`
207+
- 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.
208208
- updates the packaged stub at `node_modules/@knighted/css/types-stub/index.d.ts`
209209
- exposes the declarations automatically because `types.d.ts` references the stub, so no `tsconfig` wiring is required
210210
@@ -316,7 +316,7 @@ or wire it into `package.json` for local workflows:
316316
}
317317
```
318318

319-
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 automaticallyno extra `typeRoots` configuration is required.
319+
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 automaticallyno extra `typeRoots` or registry scripts are required.
320320

321321
Key flags:
322322

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/css/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@knighted/css",
3-
"version": "1.0.0-rc.10",
3+
"version": "1.0.0-rc.11",
44
"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.",
55
"type": "module",
66
"main": "./dist/css.js",

packages/css/src/generateTypes.ts

Lines changed: 74 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
determineSelectorVariant,
1616
hasQueryFlag,
1717
TYPES_QUERY_FLAG,
18+
buildSanitizedQuery,
19+
COMBINED_QUERY_FLAG,
20+
NAMED_ONLY_QUERY_FLAGS,
1821
type SelectorTypeVariant,
1922
} from './loaderInternals.js'
2023
import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js'
@@ -174,9 +177,6 @@ async function generateDeclarations(
174177
if (!query || !hasQueryFlag(query, TYPES_QUERY_FLAG)) {
175178
continue
176179
}
177-
if (processedSpecifiers.has(cleaned)) {
178-
continue
179-
}
180180
const resolvedNamespace = resolveStableNamespace(options.stableNamespace)
181181
const resolvedPath = await resolveImportPath(
182182
resource,
@@ -214,23 +214,35 @@ async function generateDeclarations(
214214
selectorCache.set(cacheKey, selectorMap)
215215
}
216216

217+
const canonicalSpecifier = buildDeclarationModuleSpecifier(
218+
resolvedPath,
219+
options.outDir,
220+
query,
221+
)
222+
if (processedSpecifiers.has(canonicalSpecifier)) {
223+
continue
224+
}
217225
const variant = determineSelectorVariant(query)
218-
const declaration = formatModuleDeclaration(cleaned, variant, selectorMap)
226+
const declaration = formatModuleDeclaration(
227+
canonicalSpecifier,
228+
variant,
229+
selectorMap,
230+
)
219231
const declarationHash = hashContent(declaration)
220-
const fileName = buildDeclarationFileName(cleaned)
232+
const fileName = buildDeclarationFileName(canonicalSpecifier)
221233
const targetPath = path.join(options.outDir, fileName)
222-
const previousEntry = previousManifest[cleaned]
234+
const previousEntry = previousManifest[canonicalSpecifier]
223235
const needsWrite =
224236
previousEntry?.hash !== declarationHash || !(await fileExists(targetPath))
225237
if (needsWrite) {
226238
await fs.writeFile(targetPath, declaration, 'utf8')
227239
writes += 1
228240
}
229-
nextManifest[cleaned] = { file: fileName, hash: declarationHash }
241+
nextManifest[canonicalSpecifier] = { file: fileName, hash: declarationHash }
230242
if (needsWrite) {
231-
declarations.push({ specifier: cleaned, filePath: targetPath })
243+
declarations.push({ specifier: canonicalSpecifier, filePath: targetPath })
232244
}
233-
processedSpecifiers.add(cleaned)
245+
processedSpecifiers.add(canonicalSpecifier)
234246
}
235247
}
236248

@@ -436,6 +448,57 @@ ${lines.join('\n')}
436448
}>`
437449
}
438450

451+
function buildDeclarationModuleSpecifier(
452+
resolvedPath: string,
453+
declarationDir: string,
454+
query: string,
455+
): string {
456+
const relativePath = path.relative(declarationDir, resolvedPath)
457+
const normalizedPath = normalizeRelativePath(relativePath)
458+
const canonicalQuery = buildCanonicalQuery(query)
459+
return `${normalizedPath}${canonicalQuery}`
460+
}
461+
462+
function normalizeRelativePath(relativePath: string): string {
463+
let normalized = relativePath.split(path.sep).join('/')
464+
if (!normalized || normalized === '') {
465+
normalized = '.'
466+
}
467+
if (normalized === '.') {
468+
return './'
469+
}
470+
if (normalized.startsWith('./') || normalized.startsWith('../')) {
471+
return normalized
472+
}
473+
if (normalized.startsWith('.')) {
474+
return normalized
475+
}
476+
return `./${normalized}`
477+
}
478+
479+
function buildCanonicalQuery(query: string): string {
480+
if (!query) {
481+
return ''
482+
}
483+
const sanitized = buildSanitizedQuery(query)
484+
const extraParts = sanitized ? sanitized.slice(1).split('&').filter(Boolean) : []
485+
const parts: string[] = []
486+
parts.push('knighted-css')
487+
if (hasQueryFlag(query, COMBINED_QUERY_FLAG)) {
488+
parts.push(COMBINED_QUERY_FLAG)
489+
}
490+
for (const flag of NAMED_ONLY_QUERY_FLAGS) {
491+
if (hasQueryFlag(query, flag)) {
492+
parts.push(flag)
493+
}
494+
}
495+
if (hasQueryFlag(query, TYPES_QUERY_FLAG)) {
496+
parts.push(TYPES_QUERY_FLAG)
497+
}
498+
const merged = [...parts, ...extraParts]
499+
return merged.length > 0 ? `?${merged.join('&')}` : ''
500+
}
501+
439502
function hashContent(content: string): string {
440503
return crypto.createHash('sha1').update(content).digest('hex')
441504
}
@@ -778,6 +841,8 @@ export const __generateTypesInternals = {
778841
buildDeclarationFileName,
779842
formatModuleDeclaration,
780843
formatSelectorType,
844+
buildDeclarationModuleSpecifier,
845+
buildCanonicalQuery,
781846
relativeToRoot,
782847
collectCandidateFiles,
783848
normalizeIncludeOptions,

packages/css/test/generateTypes.test.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,7 +121,7 @@ test('generateTypes resolves tsconfig baseUrl specifiers', async () => {
121121
string,
122122
unknown
123123
>
124-
assert.ok(manifest['styles/demo.css?knighted-css&types'])
124+
assert.ok(manifest['../src/styles/demo.css?knighted-css&types'])
125125
} finally {
126126
await project.cleanup()
127127
}
@@ -354,6 +354,8 @@ test('generateTypes internals format selector-aware declarations', async () => {
354354
buildDeclarationFileName,
355355
formatSelectorType,
356356
formatModuleDeclaration,
357+
buildDeclarationModuleSpecifier,
358+
buildCanonicalQuery,
357359
writeTypesIndex,
358360
normalizeIncludeOptions,
359361
collectCandidateFiles,
@@ -411,6 +413,18 @@ test('generateTypes internals format selector-aware declarations', async () => {
411413
)
412414
assert.doesNotMatch(withoutDefault, /export default/)
413415

416+
const canonicalSpecifier = buildDeclarationModuleSpecifier(
417+
path.join('/tmp/project', 'src', 'styles', 'demo.css'),
418+
path.join('/tmp/project', '.knighted-css'),
419+
'?types&knighted-css&foo=1',
420+
)
421+
assert.equal(canonicalSpecifier, '../src/styles/demo.css?knighted-css&types&foo=1')
422+
423+
assert.equal(
424+
buildCanonicalQuery('?knighted-css&combined&no-default&types&foo=1'),
425+
'?knighted-css&combined&no-default&types&foo=1',
426+
)
427+
414428
const normalized = normalizeIncludeOptions(undefined, '/tmp/demo')
415429
assert.deepEqual(normalized, ['/tmp/demo'])
416430
assert.deepEqual(normalizeIncludeOptions(['./src'], '/tmp/demo'), [

packages/playwright/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"pretest": "npm run build"
1515
},
1616
"dependencies": {
17-
"@knighted/css": "1.0.0-rc.10",
17+
"@knighted/css": "1.0.0-rc.11",
1818
"@knighted/jsx": "^1.4.1",
1919
"lit": "^3.2.1",
2020
"react": "^19.0.0",

0 commit comments

Comments
 (0)