diff --git a/.gitignore b/.gitignore index f3f1d4d..03f9f2a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ coverage/ playwright-report/ test-results/ blob-report/ +.knighted-css/ diff --git a/docs/how-it-works.md b/docs/how-it-works.md index b98c8ff..f0ab074 100644 --- a/docs/how-it-works.md +++ b/docs/how-it-works.md @@ -5,6 +5,7 @@ - The loader walks the module graph and gathers style-producing files (`.css`, `.scss`, `.sass`, `.less`, `.css.ts`, etc.). - It walks the module graph with a built-in depth-first resolver so imports are visited in source order. - 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. +- 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. - CSS from those files is concatenated in that discovery order and returned as `knightedCss` for injection (e.g., Lit ` css`` `, SSR, SSG). - We do **not** sort or reorder; first-seen order is kept, so the CSS cascade mirrors the original import sequence. diff --git a/package-lock.json b/package-lock.json index 2f6d7c4..2abb500 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11337,7 +11337,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.0.8", + "version": "1.0.9", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11372,7 +11372,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.0.8", + "@knighted/css": "1.0.9", "@knighted/jsx": "^1.6.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/loader-queries.d.ts b/packages/css/loader-queries.d.ts index f4832cd..b601908 100644 --- a/packages/css/loader-queries.d.ts +++ b/packages/css/loader-queries.d.ts @@ -4,6 +4,7 @@ */ declare module '*?knighted-css' { export const knightedCss: string + export default knightedCss } type KnightedCssStableSelectorMap = Readonly> @@ -11,6 +12,7 @@ type KnightedCssStableSelectorMap = Readonly> declare module '*?knighted-css&types' { export const knightedCss: string export const stableSelectors: KnightedCssStableSelectorMap + export default knightedCss } /** diff --git a/packages/css/package.json b/packages/css/package.json index 1bbd6f4..404b56a 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.0.8", + "version": "1.0.9", "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 56e3648..c05b569 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -4,13 +4,13 @@ import path from 'node:path' import { createRequire } from 'node:module' import { fileURLToPath, pathToFileURL } from 'node:url' -import { init, parse } from 'es-module-lexer' import { moduleType } from 'node-module-type' import { getTsconfig, type TsConfigResult } from 'get-tsconfig' import { createMatchPath, type MatchPath } from 'tsconfig-paths' import { cssWithMeta } from './css.js' +import { analyzeModule } from './lexer.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' @@ -117,7 +117,7 @@ export async function generateTypes( const include = normalizeIncludeOptions(options.include, rootDir) const cacheDir = path.resolve(options.outDir ?? path.join(rootDir, '.knighted-css')) const tsconfig = loadTsconfigResolutionContext(rootDir) - await init + await fs.mkdir(cacheDir, { recursive: true }) const internalOptions: GenerateTypesInternalOptions = { @@ -296,12 +296,15 @@ async function findSpecifierImports(filePath: string): Promise { 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(SELECTOR_REFERENCE)) { - matches.push({ specifier, importer: filePath }) + try { + const { imports } = await analyzeModule(source, filePath) + for (const specifier of imports) { + if (specifier.includes(SELECTOR_REFERENCE)) { + matches.push({ specifier, importer: filePath }) + } } + } catch { + // ignore and fall back to regex below } const requireRegex = /require\((['"])([^'"`]+?\.knighted-css[^'"`]*)\1\)/g let reqMatch: RegExpExecArray | null diff --git a/packages/css/src/lexer.ts b/packages/css/src/lexer.ts new file mode 100644 index 0000000..9e2be7f --- /dev/null +++ b/packages/css/src/lexer.ts @@ -0,0 +1,278 @@ +import path from 'node:path' + +import { init, parse, type ImportSpecifier } from 'es-module-lexer' +import { parseSync, Visitor } from 'oxc-parser' +import type { + Argument, + ExportAllDeclaration, + ExportNamedDeclaration, + Expression, + ImportExpression, + TSExportAssignment, + TSImportEqualsDeclaration, +} from 'oxc-parser' + +export type DefaultExportSignal = 'has-default' | 'no-default' | 'unknown' + +interface AnalyzeOptions { + esParse?: typeof parse +} + +interface ModuleAnalysis { + imports: string[] + defaultSignal: DefaultExportSignal +} + +const JSX_EXTENSIONS = new Set(['.jsx', '.tsx']) + +export async function analyzeModule( + sourceText: string, + filePath: string, + options?: AnalyzeOptions, +): Promise { + const ext = path.extname(filePath).toLowerCase() + + if (JSX_EXTENSIONS.has(ext)) { + return parseWithOxc(sourceText, filePath) + } + + const esParse = options?.esParse ?? parse + + try { + await init + const [imports, exports] = esParse(sourceText, filePath) + return { + imports: normalizeEsImports(imports, sourceText), + defaultSignal: classifyDefault(exports), + } + } catch { + // fall through to oxc fallback + } + + return parseWithOxc(sourceText, filePath) +} + +function normalizeEsImports( + records: readonly ImportSpecifier[], + sourceText: string, +): string[] { + const imports: string[] = [] + + for (const record of records) { + const raw = record.n ?? sourceText.slice(record.s, record.e) + const normalized = normalizeSpecifier(raw) + if (normalized) { + imports.push(normalized) + } + } + + return imports +} + +function classifyDefault( + exports: readonly { n: string | undefined }[], +): DefaultExportSignal { + if (exports.some(entry => entry.n === 'default')) { + return 'has-default' + } + if (exports.length === 0) { + return 'unknown' + } + return 'no-default' +} + +function parseWithOxc(sourceText: string, filePath: string): ModuleAnalysis { + const ext = path.extname(filePath).toLowerCase() + const attempts: Array<{ path: string; sourceType: 'module' | 'unambiguous' }> = [ + ...(ext === '.js' + ? [{ path: `${filePath}.tsx`, sourceType: 'module' as const }] + : []), + { path: filePath, sourceType: 'module' }, + { path: filePath, sourceType: 'unambiguous' }, + ] + let program + + for (const attempt of attempts) { + try { + ;({ program } = parseSync(attempt.path, sourceText, { + sourceType: attempt.sourceType, + })) + break + } catch { + program = undefined + } + } + + if (!program) { + return { imports: [], defaultSignal: 'unknown' } + } + + const imports: string[] = [] + let defaultSignal: DefaultExportSignal = 'unknown' + const addSpecifier = (raw?: string | null) => { + if (!raw) { + return + } + const normalized = normalizeSpecifier(raw) + if (normalized) { + imports.push(normalized) + } + } + + const visitor = new Visitor({ + ImportDeclaration(node) { + addSpecifier(node.source?.value) + }, + ExportNamedDeclaration(node: ExportNamedDeclaration) { + if (node.source) { + addSpecifier(node.source.value) + } + if (hasDefaultSpecifier(node)) { + defaultSignal = 'has-default' + } else if (defaultSignal === 'unknown' && hasAnySpecifier(node)) { + defaultSignal = 'no-default' + } + }, + ExportAllDeclaration(node: ExportAllDeclaration) { + addSpecifier(node.source?.value) + if (node.exported && isExportedAsDefault(node.exported)) { + defaultSignal = 'has-default' + } + }, + ExportDefaultDeclaration() { + defaultSignal = 'has-default' + }, + TSExportAssignment(node: TSExportAssignment) { + if (node.expression) { + defaultSignal = 'has-default' + } + }, + TSImportEqualsDeclaration(node: TSImportEqualsDeclaration) { + const specifier = extractImportEqualsSpecifier(node) + if (specifier) { + addSpecifier(specifier) + } + }, + ImportExpression(node: ImportExpression) { + const specifier = getStringFromExpression(node.source) + if (specifier) { + addSpecifier(specifier) + } + }, + CallExpression(node) { + if (!isRequireLikeCallee(node.callee)) { + return + } + const specifier = getStringFromArgument(node.arguments[0]) + if (specifier) { + addSpecifier(specifier) + } + }, + }) + + visitor.visit(program) + + return { imports, defaultSignal } +} + +function normalizeSpecifier(raw: string): string { + if (!raw) return '' + const trimmed = raw.trim() + if (!trimmed || trimmed.startsWith('\0')) { + return '' + } + const querySearchOffset = trimmed.startsWith('#') ? 1 : 0 + const remainder = trimmed.slice(querySearchOffset) + const queryMatchIndex = remainder.search(/[?#]/) + const queryIndex = queryMatchIndex === -1 ? -1 : querySearchOffset + queryMatchIndex + const withoutQuery = queryIndex === -1 ? trimmed : trimmed.slice(0, queryIndex) + if (!withoutQuery) { + return '' + } + if (/^[a-z][\w+.-]*:/i.test(withoutQuery) && !withoutQuery.startsWith('file:')) { + return '' + } + return withoutQuery +} + +function hasDefaultSpecifier(node: ExportNamedDeclaration): boolean { + return node.specifiers?.some(spec => isExportedAsDefault(spec.exported)) ?? false +} + +function hasAnySpecifier(node: ExportNamedDeclaration): boolean { + return Array.isArray(node.specifiers) && node.specifiers.length > 0 +} + +function isExportedAsDefault( + exported: { name?: string; value?: string } | null | undefined, +): boolean { + if (!exported) return false + if (typeof exported.name === 'string' && exported.name === 'default') { + return true + } + if (typeof exported.value === 'string' && exported.value === 'default') { + return true + } + return false +} + +function extractImportEqualsSpecifier( + node: TSImportEqualsDeclaration, +): string | undefined { + if (node.moduleReference.type === 'TSExternalModuleReference') { + return node.moduleReference.expression.value + } + return undefined +} + +function getStringFromArgument(argument: Argument | undefined): string | undefined { + if (!argument || argument.type === 'SpreadElement') { + return undefined + } + return getStringFromExpression(argument) +} + +function getStringFromExpression( + expression: Expression | null | undefined, +): string | undefined { + if (!expression) { + return undefined + } + if (expression.type === 'Literal') { + const literalValue = (expression as { value: unknown }).value + return typeof literalValue === 'string' ? literalValue : undefined + } + if (expression.type === 'TemplateLiteral' && expression.expressions.length === 0) { + const [first] = expression.quasis + return first?.value.cooked ?? first?.value.raw ?? undefined + } + return undefined +} + +function isRequireLikeCallee(expression: Expression): boolean { + const target = unwrapExpression(expression) + if (target.type === 'Identifier') { + return target.name === 'require' + } + if (target.type === 'MemberExpression') { + const object = target.object + if (object.type === 'Identifier') { + return object.name === 'require' + } + } + return false +} + +function unwrapExpression(expression: Expression): Expression { + if (expression.type === 'ChainExpression') { + const inner = expression.expression as Expression + if (inner.type === 'CallExpression') { + return unwrapExpression(inner.callee) + } + return unwrapExpression(inner) + } + if (expression.type === 'TSNonNullExpression') { + return unwrapExpression(expression.expression) + } + return expression +} diff --git a/packages/css/src/moduleInfo.ts b/packages/css/src/moduleInfo.ts index 3368474..bfa333e 100644 --- a/packages/css/src/moduleInfo.ts +++ b/packages/css/src/moduleInfo.ts @@ -1,9 +1,11 @@ import { readFile } from 'node:fs/promises' import path from 'node:path' -import { init, parse } from 'es-module-lexer' +import type { parse } from 'es-module-lexer' -export type ModuleDefaultSignal = 'has-default' | 'no-default' | 'unknown' +import { analyzeModule, type DefaultExportSignal } from './lexer.js' + +export type ModuleDefaultSignal = DefaultExportSignal type LexerOverrides = { parse?: typeof parse @@ -20,16 +22,8 @@ const DETECTABLE_EXTENSIONS = new Set([ '.cts', ]) -let lexerInit: Promise | undefined let lexerOverrides: LexerOverrides | undefined -function ensureLexerInitialized(): Promise { - if (!lexerInit) { - lexerInit = init - } - return lexerInit -} - export async function detectModuleDefaultExport( filePath: string, ): Promise { @@ -45,15 +39,10 @@ export async function detectModuleDefaultExport( } try { - await ensureLexerInitialized() - const [, exports] = (lexerOverrides?.parse ?? parse)(source, filePath) - if (exports.some(entry => entry.n === 'default')) { - return 'has-default' - } - if (exports.length === 0) { - return 'unknown' - } - return 'no-default' + const { defaultSignal } = await analyzeModule(source, filePath, { + esParse: lexerOverrides?.parse, + }) + return defaultSignal } catch { return 'unknown' } @@ -62,8 +51,5 @@ export async function detectModuleDefaultExport( export const __moduleInfoInternals = { setLexerOverrides(overrides?: LexerOverrides) { lexerOverrides = overrides - if (!overrides) { - lexerInit = undefined - } }, } diff --git a/packages/css/test/fixtures/combined/jsx-default.tsx b/packages/css/test/fixtures/combined/jsx-default.tsx new file mode 100644 index 0000000..3d1a64a --- /dev/null +++ b/packages/css/test/fixtures/combined/jsx-default.tsx @@ -0,0 +1,5 @@ +export default function JsxDefault() { + return
hello
+} + +export const named = 'demo' diff --git a/packages/css/test/fixtures/combined/raw-jsx.js b/packages/css/test/fixtures/combined/raw-jsx.js new file mode 100644 index 0000000..edf1718 --- /dev/null +++ b/packages/css/test/fixtures/combined/raw-jsx.js @@ -0,0 +1,7 @@ +export default function RawJsx() { + return ( +
+

raw jsx

+
+ ) +} diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index a00752c..a45c67b 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -438,6 +438,37 @@ test('generateTypes warns when selector sources fall outside the project root', } }) +test('generateTypes discovers selector imports in tsx via oxc fallback', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-generate-types-tsx-')) + try { + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + + const cssPath = path.join(srcDir, 'button.css') + await fs.writeFile(cssPath, '.knighted-btn { color: rebeccapurple; }\n') + + const entryPath = path.join(srcDir, 'entry.tsx') + await fs.writeFile( + entryPath, + "import selectors from './button.css.knighted-css'\n" + + 'export default function Button() {\n' + + ' return \n' + + '}\n', + ) + + const result = await generateTypes({ rootDir: root, include: ['src'] }) + assert.ok(result.selectorModulesWritten >= 1) + assert.equal(result.warnings.length, 0) + + const selectorModulePath = path.join(srcDir, 'button.css.knighted-css.ts') + assert.equal(await pathExists(selectorModulePath), true) + const selectorModule = await fs.readFile(selectorModulePath, 'utf8') + assert.match(selectorModule, /"btn": "knighted-btn"/) + } finally { + await fs.rm(root, { recursive: true, force: true }) + } +}) + test('runGenerateTypesCli executes generation and reports summaries', async () => { const project = await setupFixtureProject() try { diff --git a/packages/css/test/lexer.test.ts b/packages/css/test/lexer.test.ts new file mode 100644 index 0000000..dd167d3 --- /dev/null +++ b/packages/css/test/lexer.test.ts @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import path from 'node:path' +import test from 'node:test' + +import { analyzeModule } from '../src/lexer.ts' + +test('analyzeModule falls back to oxc for mixed syntax and collects normalized imports', async () => { + const source = `const literal = require('./styles/literal.css?inline#hash') +const optional = require?.('./styles/optional.css') +const nonNull = require!('./styles/non-null.css') +import styles = require('./styles/import-equals.css') +await import(\`./styles/template.css\`) +import '#hash/map.css?inline' +import 'https://cdn.knighted.dev/remote.css' +import '\0ignored' +export = literal +void optional +void nonNull +void styles +` + + const filePath = path.join(process.cwd(), 'entry.js') + const result = await analyzeModule(source, filePath, { + esParse: () => { + throw new Error('force-oxc') + }, + }) + + assert.equal(result.defaultSignal, 'has-default') + assert.deepEqual(result.imports.sort(), [ + '#hash/map.css', + './styles/import-equals.css', + './styles/literal.css', + './styles/non-null.css', + './styles/optional.css', + './styles/template.css', + ]) +}) + +test('analyzeModule tracks named-only exports and member requires', async () => { + const source = `export { named } from './styles/named.css?inline' +export { value as 'default' } from './styles/quoted-default.css' +export { another } from './styles/another.css' +const resolved = require.resolve('./styles/resolved.css') +const spread = require(...['./styles/spread.css']) +import 'http://example.com/remote.css' +void resolved +void spread +` + + const result = await analyzeModule(source, 'entry.ts', { + esParse: () => { + throw new Error('force-oxc') + }, + }) + + assert.equal(result.defaultSignal, 'has-default') + assert.deepEqual(result.imports.sort(), [ + './styles/another.css', + './styles/named.css', + './styles/quoted-default.css', + './styles/resolved.css', + ]) +}) diff --git a/packages/css/test/moduleGraph.test.ts b/packages/css/test/moduleGraph.test.ts index 646bfc2..bd4d214 100644 --- a/packages/css/test/moduleGraph.test.ts +++ b/packages/css/test/moduleGraph.test.ts @@ -251,3 +251,161 @@ void Button await project.cleanup() } }) + +test('collectStyleImports tolerates missing tsconfig files and absolute baseUrls', async () => { + const project = await createProject('knighted-module-graph-tsconfig-gaps-') + try { + await project.writeFile('styles/abs.css', '.abs { color: silver; }') + await project.writeFile( + 'entry.ts', + `import '@abs/abs.css' +`, + ) + + const missingBaseUrl = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + graphOptions: { + tsConfig: { + compilerOptions: { + paths: { + '@noop/*': ['styles/*'], + }, + }, + }, + }, + }) + + assert.deepEqual(missingBaseUrl, []) + + const missing = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + graphOptions: { + tsConfig: project.file('tsconfig.missing'), + }, + }) + + assert.deepEqual(missing, []) + + const missingCached = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + graphOptions: { + tsConfig: project.file('tsconfig.missing'), + }, + }) + + assert.deepEqual(missingCached, []) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + graphOptions: { + tsConfig: { + compilerOptions: { + baseUrl: project.root, + paths: { + '@empty/*': [], + '@abs/*': ['styles/*'], + '@string/*': 'styles/*', + }, + }, + }, + }, + }) + + assert.deepEqual(styles, [project.file('styles/abs.css')]) + } finally { + await project.cleanup() + } +}) + +test('collectStyleImports normalizes resolver results, file URLs, and template literals', async () => { + const project = await createProject('knighted-module-graph-resolver-normalize-') + try { + const resolverStyle = await project.writeFile( + 'styles/from-resolver.css', + '.resolver { color: lime; }', + ) + const mappedStyle = await project.writeFile( + 'styles/from-tsconfig.css', + '.mapped { color: olive; }', + ) + const templateStyle = await project.writeFile( + 'styles/from-template.css', + '.template { color: navy; }', + ) + const optionalStyle = await project.writeFile( + 'styles/from-optional.css', + '.optional { color: teal; }', + ) + const fileUrlStyle = await project.writeFile( + 'styles/from-file-url.css', + '.file { color: maroon; }', + ) + const directoryIndexStyle = await project.writeFile( + 'styles/from-file-url-dir/index.css', + '.dir { color: brown; }', + ) + + const resolver: CssResolver = async specifier => { + if (specifier === '@resolver/style') { + return './styles/from-resolver.css' + } + if (specifier === '@resolver/invalid') { + return 'file://:bad' + } + return undefined + } + + const entrySource = `import '@resolver/style' + import '@resolver/invalid' + import '@tsconfig/mapped' + import('file://${pathToFileURL(fileUrlStyle).pathname}') + import(\`./styles/from-template.css\`) + import('file://:bad') + import('file://${pathToFileURL(path.dirname(directoryIndexStyle)).pathname}') + const optional = require?.('./styles/from-optional.css') + import 'node:fs' + void optional + ` + await project.writeFile('entry.ts', entrySource) + + const styles = await collectStyleImports(project.file('entry.ts'), { + cwd: project.root, + styleExtensions: ['.css'], + filter: () => true, + resolver, + graphOptions: { + tsConfig: { + compilerOptions: { + baseUrl: '.', + paths: { + '@tsconfig/mapped': ['styles/from-tsconfig.css'], + '@resolver/invalid': ['styles/from-tsconfig.css'], + }, + }, + }, + }, + }) + + assert.deepEqual( + await realpathAll(styles), + await realpathAll([ + resolverStyle, + mappedStyle, + fileUrlStyle, + templateStyle, + directoryIndexStyle, + optionalStyle, + ]), + ) + } finally { + await project.cleanup() + } +}) diff --git a/packages/css/test/moduleInfo.test.ts b/packages/css/test/moduleInfo.test.ts index 68476e9..28ee080 100644 --- a/packages/css/test/moduleInfo.test.ts +++ b/packages/css/test/moduleInfo.test.ts @@ -3,6 +3,8 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import test from 'node:test' +import { parse } from 'es-module-lexer' + import { __moduleInfoInternals, detectModuleDefaultExport } from '../src/moduleInfo.ts' const __filename = fileURLToPath(import.meta.url) @@ -39,6 +41,51 @@ test('returns unknown when source file cannot be read', async () => { assert.equal(signal, 'unknown') }) +test('detects default export in tsx via oxc fallback', async () => { + const target = path.join(fixturesDir, 'jsx-default.tsx') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'has-default') +}) + +test('skips es-module-lexer for tsx and still detects default', async () => { + __moduleInfoInternals.setLexerOverrides({ + parse() { + throw new Error('should not be called for tsx') + }, + }) + try { + const target = path.join(fixturesDir, 'jsx-default.tsx') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'has-default') + } finally { + __moduleInfoInternals.setLexerOverrides() + } +}) + +test('falls back to oxc when es-module-lexer chokes on raw jsx', async () => { + const target = path.join(fixturesDir, 'raw-jsx.js') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'has-default') +}) + +test('non-jsx files still use es-module-lexer path', async () => { + let called = false + __moduleInfoInternals.setLexerOverrides({ + parse(source, id) { + called = true + return parse(source, id) + }, + }) + try { + const target = path.join(fixturesDir, 'default-export.ts') + const signal = await detectModuleDefaultExport(target) + assert.equal(signal, 'has-default') + assert.equal(called, true) + } finally { + __moduleInfoInternals.setLexerOverrides() + } +}) + test('falls back to unknown when lexer parse throws', async () => { __moduleInfoInternals.setLexerOverrides({ parse() { diff --git a/packages/playwright/package.json b/packages/playwright/package.json index 6719585..fe1dd24 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -5,6 +5,7 @@ "type": "module", "scripts": { "build": "npm run build:rspack && npm run build:webpack && npm run build:ssr", + "types": "knighted-css-generate-types --root . --include src", "build:rspack": "npx rspack --config rspack.config.js", "build:webpack": "npx webpack --config webpack.config.js", "build:ssr": "npx tsx scripts/render-ssr-preview.ts", @@ -12,10 +13,10 @@ "preview": "npm run build && npx http-server . -p 4174", "serve": "npx http-server dist -p 4174", "test": "npx playwright test", - "pretest": "npm run build" + "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.0.8", + "@knighted/css": "1.0.9", "@knighted/jsx": "^1.6.1", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts index 9454565..91f5559 100644 --- a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts +++ b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.css.knighted-css.ts @@ -1,14 +1,14 @@ -// Generated by @knighted/css/generate-types (demo stub) -// Do not edit without running the selector manifest script. +// Generated by @knighted/css/generate-types +// Do not edit. export const stableSelectors = { - 'combined-types-shell': 'knighted-combined-types-shell', 'combined-types-badge': 'knighted-combined-types-badge', 'combined-types-copy': 'knighted-combined-types-copy', 'combined-types-footer': 'knighted-combined-types-footer', + 'combined-types-shell': 'knighted-combined-types-shell', } as const -export type CombinedTypesStableSelectors = typeof stableSelectors -export type CombinedTypesSelectorToken = keyof typeof stableSelectors +export type KnightedCssStableSelectors = typeof stableSelectors +export type KnightedCssStableSelectorToken = keyof typeof stableSelectors export default stableSelectors diff --git a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.tsx b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.tsx index b02d6fa..b0eb8b7 100644 --- a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.tsx +++ b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card-entry.tsx @@ -1,6 +1,6 @@ import './combined-types-card-entry.css' -import type { CombinedTypesStableSelectors } from './combined-types-card-entry.css.knighted-css.js' +import type { KnightedCssStableSelectors as CombinedTypesStableSelectors } from './combined-types-card-entry.css.knighted-css.js' import stableSelectors from './combined-types-card-entry.css.knighted-css.js' export const COMBINED_TYPES_TEST_ID = 'dialect-combined-types' diff --git a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card.tsx b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card.tsx index a619ccd..516ecfe 100644 --- a/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card.tsx +++ b/packages/playwright/src/lit-react/cards/combined-types-card/combined-types-card.tsx @@ -1,7 +1,7 @@ import { asKnightedCssCombinedModule } from '@knighted/css/loader-helpers' import * as combinedModule from './combined-types-card-entry.js?knighted-css&combined&types' -import type { CombinedTypesStableSelectors } from './combined-types-card-entry.css.knighted-css.js' +import type { KnightedCssStableSelectors as CombinedTypesStableSelectors } from './combined-types-card-entry.css.knighted-css.js' const { default: CombinedTypesCardEntry, diff --git a/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css new file mode 100644 index 0000000..9ddc52c --- /dev/null +++ b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css @@ -0,0 +1,45 @@ +.fallback-card, +.knighted-fallback-card { + background: linear-gradient(135deg, #dbeafe 0%, #bfdbfe 100%); + color: #0f172a; + border: 1px solid #3b82f6; + border-radius: 14px; + padding: 1.25rem; + box-shadow: 0 8px 28px rgba(15, 23, 42, 0.12); + display: grid; + gap: 0.5rem; +} + +.fallback-card__badge, +.knighted-fallback-card__badge { + display: inline-block; + padding: 0.2rem 0.5rem; + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: #0f172a; + background: #facc15; + border-radius: 999px; + box-shadow: 0 2px 10px rgba(15, 23, 42, 0.18); +} + +.fallback-card__copy, +.knighted-fallback-card__copy { + margin: 0; + line-height: 1.4; +} + +.fallback-card__token, +.knighted-fallback-card__token { + display: inline-flex; + align-items: center; + gap: 0.3rem; + padding: 0.35rem 0.5rem; + background: #0f172a; + color: #e2e8f0; + border-radius: 8px; + font-family: + ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', + 'Courier New', monospace; +} diff --git a/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts new file mode 100644 index 0000000..6b8a68a --- /dev/null +++ b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.css.knighted-css.ts @@ -0,0 +1,14 @@ +// Generated by @knighted/css/generate-types +// Do not edit. + +export const stableSelectors = { + 'fallback-card': 'knighted-fallback-card', + 'fallback-card__badge': 'knighted-fallback-card__badge', + 'fallback-card__copy': 'knighted-fallback-card__copy', + 'fallback-card__token': 'knighted-fallback-card__token', +} as const + +export type KnightedCssStableSelectors = typeof stableSelectors +export type KnightedCssStableSelectorToken = keyof typeof stableSelectors + +export default stableSelectors diff --git a/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.tsx b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.tsx new file mode 100644 index 0000000..8aabefe --- /dev/null +++ b/packages/playwright/src/lit-react/cards/fallback-card/fallback-card.tsx @@ -0,0 +1,30 @@ +import type { KnightedCssStableSelectors } from './fallback-card.css.knighted-css.js' +import stableSelectors from './fallback-card.css.knighted-css.js' +import fallbackCardCss from './fallback-card.css?knighted-css' + +export const FALLBACK_CARD_TEST_ID = 'dialect-fallback-oxc' + +export { fallbackCardCss } + +export function FallbackCard() { + const runtimeSelectors = stableSelectors as Readonly + return ( +
+ OXC fallback +

+ This card exists to hit the oxc-parser fallback during selector type generation on + a TSX module. +

+ + stable selector: + {runtimeSelectors['fallback-card']} + +
+ ) +} diff --git a/packages/playwright/src/lit-react/showcase.tsx b/packages/playwright/src/lit-react/showcase.tsx index 77670e1..3a2fd63 100644 --- a/packages/playwright/src/lit-react/showcase.tsx +++ b/packages/playwright/src/lit-react/showcase.tsx @@ -34,6 +34,11 @@ import { COMBINED_TYPES_TEST_ID, combinedTypesCardCss, } from './cards/combined-types-card/combined-types-card.js' +import { + FallbackCard, + FALLBACK_CARD_TEST_ID, + fallbackCardCss, +} from './cards/fallback-card/fallback-card.js' import { NamedOnlyCard, NAMED_ONLY_TEST_ID, @@ -85,6 +90,11 @@ const cards: DialectCard[] = [ css: combinedTypesCardCss, Component: CombinedTypesCard, }, + { + id: FALLBACK_CARD_TEST_ID, + css: fallbackCardCss, + Component: FallbackCard, + }, { id: NAMED_ONLY_TEST_ID, css: namedOnlyCardCss, diff --git a/packages/playwright/test/lit-react.spec.ts b/packages/playwright/test/lit-react.spec.ts index d612df9..6931fa1 100644 --- a/packages/playwright/test/lit-react.spec.ts +++ b/packages/playwright/test/lit-react.spec.ts @@ -14,6 +14,7 @@ const dialectCases = [ { id: 'dialect-combined', property: 'background-image' }, { id: 'dialect-nested-combined', property: 'background-image' }, { id: 'dialect-combined-types', property: 'border-color' }, + { id: 'dialect-fallback-oxc', property: 'background-image' }, { id: 'dialect-named-only', property: 'background-image' }, ] @@ -201,6 +202,19 @@ test.describe('Lit + React wrapper demo', () => { expect(metrics.footerText).toContain(metrics.runtimeShell) }) + test('fallback card surfaces stable selector from generated types', async ({ + page, + }) => { + const card = page.getByTestId('dialect-fallback-oxc') + await expect(card).toBeVisible() + + const tokenText = await card + .getByTestId('fallback-stable-shell') + .textContent({ timeout: 5000 }) + + expect(tokenText?.trim() ?? '').toContain('knighted-fallback-card') + }) + test('named-only combined import disables the synthetic default', async ({ page }) => { const card = page.getByTestId('dialect-named-only') await expect(card).toBeVisible()