Skip to content

Commit d0bda67

Browse files
fix: loader query default exports, lexer jsx fallback. (#51)
1 parent b3411e8 commit d0bda67

23 files changed

Lines changed: 738 additions & 41 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ coverage/
1010
playwright-report/
1111
test-results/
1212
blob-report/
13+
.knighted-css/

docs/how-it-works.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- The loader walks the module graph and gathers style-producing files (`.css`, `.scss`, `.sass`, `.less`, `.css.ts`, etc.).
66
- It walks the module graph with a built-in depth-first resolver so imports are visited in source order.
77
- Resolution is powered by [`oxc-resolver`](https://github.com/oxc-project/oxc-resolver), so tsconfig `paths`, package `exports` conditions, and extension aliasing (like `.css.js``.css.ts`) all map to the same targets you’d get in a bundler.
8+
- JSX/TSX is supported: if `es-module-lexer` can’t parse a file or the extension is `.jsx`/`.tsx`, we fall back to `oxc-parser` to read imports and defaults without any user configuration.
89
- CSS from those files is concatenated in that discovery order and returned as `knightedCss` for injection (e.g., Lit ` css`` `, SSR, SSG).
910
- We do **not** sort or reorder; first-seen order is kept, so the CSS cascade mirrors the original import sequence.
1011

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/loader-queries.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@
44
*/
55
declare module '*?knighted-css' {
66
export const knightedCss: string
7+
export default knightedCss
78
}
89

910
type KnightedCssStableSelectorMap = Readonly<Record<string, string>>
1011

1112
declare module '*?knighted-css&types' {
1213
export const knightedCss: string
1314
export const stableSelectors: KnightedCssStableSelectorMap
15+
export default knightedCss
1416
}
1517

1618
/**

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.8",
3+
"version": "1.0.9",
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: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@ import path from 'node:path'
44
import { createRequire } from 'node:module'
55
import { fileURLToPath, pathToFileURL } from 'node:url'
66

7-
import { init, parse } from 'es-module-lexer'
87
import { moduleType } from 'node-module-type'
98

109
import { getTsconfig, type TsConfigResult } from 'get-tsconfig'
1110
import { createMatchPath, type MatchPath } from 'tsconfig-paths'
1211

1312
import { cssWithMeta } from './css.js'
13+
import { analyzeModule } from './lexer.js'
1414
import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js'
1515
import { resolveStableNamespace } from './stableNamespace.js'
1616

@@ -117,7 +117,7 @@ export async function generateTypes(
117117
const include = normalizeIncludeOptions(options.include, rootDir)
118118
const cacheDir = path.resolve(options.outDir ?? path.join(rootDir, '.knighted-css'))
119119
const tsconfig = loadTsconfigResolutionContext(rootDir)
120-
await init
120+
121121
await fs.mkdir(cacheDir, { recursive: true })
122122

123123
const internalOptions: GenerateTypesInternalOptions = {
@@ -296,12 +296,15 @@ async function findSpecifierImports(filePath: string): Promise<ImportMatch[]> {
296296
return []
297297
}
298298
const matches: ImportMatch[] = []
299-
const [imports] = parse(source, filePath)
300-
for (const record of imports) {
301-
const specifier = record.n ?? source.slice(record.s, record.e)
302-
if (specifier && specifier.includes(SELECTOR_REFERENCE)) {
303-
matches.push({ specifier, importer: filePath })
299+
try {
300+
const { imports } = await analyzeModule(source, filePath)
301+
for (const specifier of imports) {
302+
if (specifier.includes(SELECTOR_REFERENCE)) {
303+
matches.push({ specifier, importer: filePath })
304+
}
304305
}
306+
} catch {
307+
// ignore and fall back to regex below
305308
}
306309
const requireRegex = /require\((['"])([^'"`]+?\.knighted-css[^'"`]*)\1\)/g
307310
let reqMatch: RegExpExecArray | null

packages/css/src/lexer.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import path from 'node:path'
2+
3+
import { init, parse, type ImportSpecifier } from 'es-module-lexer'
4+
import { parseSync, Visitor } from 'oxc-parser'
5+
import type {
6+
Argument,
7+
ExportAllDeclaration,
8+
ExportNamedDeclaration,
9+
Expression,
10+
ImportExpression,
11+
TSExportAssignment,
12+
TSImportEqualsDeclaration,
13+
} from 'oxc-parser'
14+
15+
export type DefaultExportSignal = 'has-default' | 'no-default' | 'unknown'
16+
17+
interface AnalyzeOptions {
18+
esParse?: typeof parse
19+
}
20+
21+
interface ModuleAnalysis {
22+
imports: string[]
23+
defaultSignal: DefaultExportSignal
24+
}
25+
26+
const JSX_EXTENSIONS = new Set(['.jsx', '.tsx'])
27+
28+
export async function analyzeModule(
29+
sourceText: string,
30+
filePath: string,
31+
options?: AnalyzeOptions,
32+
): Promise<ModuleAnalysis> {
33+
const ext = path.extname(filePath).toLowerCase()
34+
35+
if (JSX_EXTENSIONS.has(ext)) {
36+
return parseWithOxc(sourceText, filePath)
37+
}
38+
39+
const esParse = options?.esParse ?? parse
40+
41+
try {
42+
await init
43+
const [imports, exports] = esParse(sourceText, filePath)
44+
return {
45+
imports: normalizeEsImports(imports, sourceText),
46+
defaultSignal: classifyDefault(exports),
47+
}
48+
} catch {
49+
// fall through to oxc fallback
50+
}
51+
52+
return parseWithOxc(sourceText, filePath)
53+
}
54+
55+
function normalizeEsImports(
56+
records: readonly ImportSpecifier[],
57+
sourceText: string,
58+
): string[] {
59+
const imports: string[] = []
60+
61+
for (const record of records) {
62+
const raw = record.n ?? sourceText.slice(record.s, record.e)
63+
const normalized = normalizeSpecifier(raw)
64+
if (normalized) {
65+
imports.push(normalized)
66+
}
67+
}
68+
69+
return imports
70+
}
71+
72+
function classifyDefault(
73+
exports: readonly { n: string | undefined }[],
74+
): DefaultExportSignal {
75+
if (exports.some(entry => entry.n === 'default')) {
76+
return 'has-default'
77+
}
78+
if (exports.length === 0) {
79+
return 'unknown'
80+
}
81+
return 'no-default'
82+
}
83+
84+
function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis {
85+
const ext = path.extname(filePath).toLowerCase()
86+
const attempts: Array<{ path: string; sourceType: 'module' | 'unambiguous' }> = [
87+
...(ext === '.js'
88+
? [{ path: `${filePath}.tsx`, sourceType: 'module' as const }]
89+
: []),
90+
{ path: filePath, sourceType: 'module' },
91+
{ path: filePath, sourceType: 'unambiguous' },
92+
]
93+
let program
94+
95+
for (const attempt of attempts) {
96+
try {
97+
;({ program } = parseSync(attempt.path, sourceText, {
98+
sourceType: attempt.sourceType,
99+
}))
100+
break
101+
} catch {
102+
program = undefined
103+
}
104+
}
105+
106+
if (!program) {
107+
return { imports: [], defaultSignal: 'unknown' }
108+
}
109+
110+
const imports: string[] = []
111+
let defaultSignal: DefaultExportSignal = 'unknown'
112+
const addSpecifier = (raw?: string | null) => {
113+
if (!raw) {
114+
return
115+
}
116+
const normalized = normalizeSpecifier(raw)
117+
if (normalized) {
118+
imports.push(normalized)
119+
}
120+
}
121+
122+
const visitor = new Visitor({
123+
ImportDeclaration(node) {
124+
addSpecifier(node.source?.value)
125+
},
126+
ExportNamedDeclaration(node: ExportNamedDeclaration) {
127+
if (node.source) {
128+
addSpecifier(node.source.value)
129+
}
130+
if (hasDefaultSpecifier(node)) {
131+
defaultSignal = 'has-default'
132+
} else if (defaultSignal === 'unknown' && hasAnySpecifier(node)) {
133+
defaultSignal = 'no-default'
134+
}
135+
},
136+
ExportAllDeclaration(node: ExportAllDeclaration) {
137+
addSpecifier(node.source?.value)
138+
if (node.exported && isExportedAsDefault(node.exported)) {
139+
defaultSignal = 'has-default'
140+
}
141+
},
142+
ExportDefaultDeclaration() {
143+
defaultSignal = 'has-default'
144+
},
145+
TSExportAssignment(node: TSExportAssignment) {
146+
if (node.expression) {
147+
defaultSignal = 'has-default'
148+
}
149+
},
150+
TSImportEqualsDeclaration(node: TSImportEqualsDeclaration) {
151+
const specifier = extractImportEqualsSpecifier(node)
152+
if (specifier) {
153+
addSpecifier(specifier)
154+
}
155+
},
156+
ImportExpression(node: ImportExpression) {
157+
const specifier = getStringFromExpression(node.source)
158+
if (specifier) {
159+
addSpecifier(specifier)
160+
}
161+
},
162+
CallExpression(node) {
163+
if (!isRequireLikeCallee(node.callee)) {
164+
return
165+
}
166+
const specifier = getStringFromArgument(node.arguments[0])
167+
if (specifier) {
168+
addSpecifier(specifier)
169+
}
170+
},
171+
})
172+
173+
visitor.visit(program)
174+
175+
return { imports, defaultSignal }
176+
}
177+
178+
function normalizeSpecifier(raw: string): string {
179+
if (!raw) return ''
180+
const trimmed = raw.trim()
181+
if (!trimmed || trimmed.startsWith('\0')) {
182+
return ''
183+
}
184+
const querySearchOffset = trimmed.startsWith('#') ? 1 : 0
185+
const remainder = trimmed.slice(querySearchOffset)
186+
const queryMatchIndex = remainder.search(/[?#]/)
187+
const queryIndex = queryMatchIndex === -1 ? -1 : querySearchOffset + queryMatchIndex
188+
const withoutQuery = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex)
189+
if (!withoutQuery) {
190+
return ''
191+
}
192+
if (/^[a-z][\w+.-]*:/i.test(withoutQuery) && !withoutQuery.startsWith('file:')) {
193+
return ''
194+
}
195+
return withoutQuery
196+
}
197+
198+
function hasDefaultSpecifier(node: ExportNamedDeclaration): boolean {
199+
return node.specifiers?.some(spec => isExportedAsDefault(spec.exported)) ?? false
200+
}
201+
202+
function hasAnySpecifier(node: ExportNamedDeclaration): boolean {
203+
return Array.isArray(node.specifiers) && node.specifiers.length > 0
204+
}
205+
206+
function isExportedAsDefault(
207+
exported: { name?: string; value?: string } | null | undefined,
208+
): boolean {
209+
if (!exported) return false
210+
if (typeof exported.name === 'string' && exported.name === 'default') {
211+
return true
212+
}
213+
if (typeof exported.value === 'string' && exported.value === 'default') {
214+
return true
215+
}
216+
return false
217+
}
218+
219+
function extractImportEqualsSpecifier(
220+
node: TSImportEqualsDeclaration,
221+
): string | undefined {
222+
if (node.moduleReference.type === 'TSExternalModuleReference') {
223+
return node.moduleReference.expression.value
224+
}
225+
return undefined
226+
}
227+
228+
function getStringFromArgument(argument: Argument | undefined): string | undefined {
229+
if (!argument || argument.type === 'SpreadElement') {
230+
return undefined
231+
}
232+
return getStringFromExpression(argument)
233+
}
234+
235+
function getStringFromExpression(
236+
expression: Expression | null | undefined,
237+
): string | undefined {
238+
if (!expression) {
239+
return undefined
240+
}
241+
if (expression.type === 'Literal') {
242+
const literalValue = (expression as { value: unknown }).value
243+
return typeof literalValue === 'string' ? literalValue : undefined
244+
}
245+
if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) {
246+
const [first] = expression.quasis
247+
return first?.value.cooked ?? first?.value.raw ?? undefined
248+
}
249+
return undefined
250+
}
251+
252+
function isRequireLikeCallee(expression: Expression): boolean {
253+
const target = unwrapExpression(expression)
254+
if (target.type === 'Identifier') {
255+
return target.name === 'require'
256+
}
257+
if (target.type === 'MemberExpression') {
258+
const object = target.object
259+
if (object.type === 'Identifier') {
260+
return object.name === 'require'
261+
}
262+
}
263+
return false
264+
}
265+
266+
function unwrapExpression(expression: Expression): Expression {
267+
if (expression.type === 'ChainExpression') {
268+
const inner = expression.expression as Expression
269+
if (inner.type === 'CallExpression') {
270+
return unwrapExpression(inner.callee)
271+
}
272+
return unwrapExpression(inner)
273+
}
274+
if (expression.type === 'TSNonNullExpression') {
275+
return unwrapExpression(expression.expression)
276+
}
277+
return expression
278+
}

0 commit comments

Comments
 (0)