diff --git a/package-lock.json b/package-lock.json index dcccfce..ff4994e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11331,7 +11331,7 @@ }, "packages/css": { "name": "@knighted/css", - "version": "1.1.0-rc.2", + "version": "1.1.0-rc.3", "license": "MIT", "dependencies": { "es-module-lexer": "^2.0.0", @@ -11366,7 +11366,7 @@ "name": "@knighted/css-playwright-fixture", "version": "0.0.0", "dependencies": { - "@knighted/css": "1.1.0-rc.2", + "@knighted/css": "1.1.0-rc.3", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/css/package.json b/packages/css/package.json index 4f5ee83..7a373f7 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.1.0-rc.2", + "version": "1.1.0-rc.3", "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 6241b01..a4f1039 100644 --- a/packages/css/src/generateTypes.ts +++ b/packages/css/src/generateTypes.ts @@ -11,6 +11,7 @@ import { createMatchPath, type MatchPath } from 'tsconfig-paths' import { cssWithMeta, DEFAULT_EXTENSIONS } from './css.js' import { analyzeModule, type DefaultExportSignal } from './lexer.js' +import { createResolverFactory, resolveWithFactory } from './moduleResolution.js' import { buildStableSelectorsLiteral } from './stableSelectorsLiteral.js' import { resolveStableNamespace } from './stableNamespace.js' @@ -117,6 +118,10 @@ function getImportMetaUrl(): string | undefined { const SELECTOR_REFERENCE = '.knighted-css' const SELECTOR_MODULE_SUFFIX = '.knighted-css.ts' const STYLE_EXTENSIONS = DEFAULT_EXTENSIONS.map(ext => ext.toLowerCase()) +const SCRIPT_EXTENSIONS = Array.from(SUPPORTED_EXTENSIONS) +const RESOLUTION_EXTENSIONS = Array.from( + new Set([...SCRIPT_EXTENSIONS, ...STYLE_EXTENSIONS]), +) const EXTENSION_FALLBACKS: Record = { '.js': ['.ts', '.tsx', '.jsx', '.mjs', '.cjs'], '.mjs': ['.mts', '.mjs', '.js', '.ts', '.tsx'], @@ -127,7 +132,7 @@ const EXTENSION_FALLBACKS: Record = { export async function generateTypes( options: GenerateTypesOptions = {}, ): Promise { - const rootDir = path.resolve(options.rootDir ?? process.cwd()) + const rootDir = await resolveRootDir(path.resolve(options.rootDir ?? process.cwd())) const include = normalizeIncludeOptions(options.include, rootDir) const cacheDir = path.resolve(options.outDir ?? path.join(rootDir, '.knighted-css')) const tsconfig = loadTsconfigResolutionContext(rootDir) @@ -146,10 +151,23 @@ export async function generateTypes( return generateDeclarations(internalOptions) } +async function resolveRootDir(rootDir: string): Promise { + try { + return await fs.realpath(rootDir) + } catch { + return rootDir + } +} + async function generateDeclarations( options: GenerateTypesInternalOptions, ): Promise { const peerResolver = createProjectPeerResolver(options.rootDir) + const resolverFactory = createResolverFactory( + options.rootDir, + RESOLUTION_EXTENSIONS, + SCRIPT_EXTENSIONS, + ) const files = await collectCandidateFiles(options.include) const selectorModulesManifestPath = path.join(options.cacheDir, 'selector-modules.json') const previousSelectorManifest = await readManifest(selectorModulesManifestPath) @@ -176,6 +194,8 @@ async function generateDeclarations( match.importer, options.rootDir, options.tsconfig, + resolverFactory, + RESOLUTION_EXTENSIONS, ) if (!resolvedPath) { warnings.push( @@ -352,7 +372,8 @@ function stripInlineLoader(specifier: string): string { } function splitResourceAndQuery(specifier: string): { resource: string; query: string } { - const hashIndex = specifier.indexOf('#') + const hashOffset = specifier.startsWith('#') ? 1 : 0 + const hashIndex = specifier.indexOf('#', hashOffset) const trimmed = hashIndex >= 0 ? specifier.slice(0, hashIndex) : specifier const queryIndex = trimmed.indexOf('?') if (queryIndex < 0) { @@ -391,6 +412,8 @@ async function resolveImportPath( importerPath: string, rootDir: string, tsconfig?: TsconfigResolutionContext, + resolverFactory?: ReturnType, + resolutionExtensions: string[] = RESOLUTION_EXTENSIONS, ): Promise { if (!resourceSpecifier) return undefined if (resourceSpecifier.startsWith('.')) { @@ -405,6 +428,17 @@ async function resolveImportPath( if (tsconfigResolved) { return resolveWithExtensionFallback(tsconfigResolved) } + if (resolverFactory) { + const resolved = resolveWithFactory( + resolverFactory, + resourceSpecifier, + importerPath, + resolutionExtensions, + ) + if (resolved) { + return resolved + } + } const requireFromRoot = getProjectRequire(rootDir) try { return requireFromRoot.resolve(resourceSpecifier) diff --git a/packages/css/src/moduleGraph.ts b/packages/css/src/moduleGraph.ts index f6d071d..2c435e8 100644 --- a/packages/css/src/moduleGraph.ts +++ b/packages/css/src/moduleGraph.ts @@ -1,7 +1,6 @@ import path from 'node:path' import { builtinModules } from 'node:module' -import { existsSync, promises as fs, statSync } from 'node:fs' -import { fileURLToPath } from 'node:url' +import { promises as fs } from 'node:fs' import { parseSync, Visitor } from 'oxc-parser' import type { @@ -10,15 +9,16 @@ import type { ImportExpression, TSImportEqualsDeclaration, } from 'oxc-parser' -import { - ResolverFactory, - type NapiResolveOptions, - type TsconfigOptions as ResolverTsconfigOptions, -} from 'oxc-resolver' import { createMatchPath } from 'tsconfig-paths' import { getTsconfig } from 'get-tsconfig' import type { CssResolver } from './types.js' +import { + createResolverFactory, + findExistingFile, + normalizeResolverResult, + resolveWithFactory, +} from './moduleResolution.js' const SCRIPT_EXTENSIONS = ['.ts', '.tsx', '.mts', '.cts', '.js', '.jsx', '.mjs', '.cjs'] @@ -84,7 +84,10 @@ export async function collectStyleImports( cwd, resolutionExtensions, scriptExtensions, - graphOptions, + { + conditions: graphOptions?.conditions, + tsconfig: graphOptions?.tsConfig, + }, ) async function walk(filePath: string): Promise { @@ -496,103 +499,6 @@ function unwrapExpression(expression: Expression): Expression { return expression } -function normalizeResolverResult( - result: string | undefined, - cwd: string, -): string | undefined { - if (!result) { - return undefined - } - if (result.startsWith('file://')) { - try { - return fileURLToPath(new URL(result)) - } catch { - return undefined - } - } - return path.isAbsolute(result) ? result : path.resolve(cwd, result) -} - -function resolveWithFactory( - factory: ResolverFactory, - specifier: string, - importer: string, - extensions: string[], -): string | undefined { - if (specifier.startsWith('file://')) { - try { - return findExistingFile(fileURLToPath(new URL(specifier)), extensions) - } catch { - return undefined - } - } - if (/^[a-z][\w+.-]*:/i.test(specifier)) { - return undefined - } - try { - const result = factory.resolveFileSync(importer, specifier) - return result?.path - } catch { - return undefined - } -} - -function createResolverFactory( - cwd: string, - extensions: string[], - scriptExtensions: string[], - graphOptions?: ModuleGraphOptions, -): ResolverFactory { - const options: NapiResolveOptions = { - extensions, - conditionNames: graphOptions?.conditions, - } - const extensionAlias = buildExtensionAlias(scriptExtensions) - if (extensionAlias) { - options.extensionAlias = extensionAlias - } - const tsconfigOption = resolveResolverTsconfig(graphOptions?.tsConfig, cwd) - options.tsconfig = tsconfigOption ?? 'auto' - return new ResolverFactory(options) -} - -function buildExtensionAlias( - scriptExtensions: string[], -): Record | undefined { - const alias: Record = {} - const jsTargets = dedupeExtensions( - scriptExtensions.filter(ext => - ['.js', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'].includes(ext), - ), - ) - if (jsTargets.length > 0) { - for (const key of ['.js', '.mjs', '.cjs']) { - alias[key] = jsTargets - } - } - const jsxTargets = dedupeExtensions( - scriptExtensions.filter(ext => ext === '.jsx' || ext === '.tsx'), - ) - if (jsxTargets.length > 0) { - alias['.jsx'] = jsxTargets - } - return Object.keys(alias).length > 0 ? alias : undefined -} - -function resolveResolverTsconfig( - input: TsconfigLike | undefined, - cwd: string, -): ResolverTsconfigOptions | undefined { - if (!input || typeof input !== 'string') { - return undefined - } - const resolved = resolveTsconfigPath(input, cwd) - if (!resolved) { - return undefined - } - return { configFile: resolved } -} - function createTsconfigMatcher( input: TsconfigLike | undefined, cwd: string, @@ -674,46 +580,3 @@ function normalizeTsconfigCompilerOptions( : path.resolve(configDir, compilerOptions.baseUrl) return { absoluteBaseUrl, paths: normalizedPaths } } - -function resolveTsconfigPath(tsconfigPath: string, cwd: string): string | undefined { - const absolute = path.isAbsolute(tsconfigPath) - ? tsconfigPath - : path.resolve(cwd, tsconfigPath) - if (!existsSync(absolute)) { - return undefined - } - const stats = statSync(absolute) - if (stats.isDirectory()) { - const candidate = path.join(absolute, 'tsconfig.json') - return existsSync(candidate) ? candidate : undefined - } - return absolute -} - -function findExistingFile(candidate: string, extensions: string[]): string | undefined { - const candidateHasExt = hasExtension(candidate) - if (candidateHasExt && existsSync(candidate)) { - return candidate - } - if (!candidateHasExt) { - for (const ext of extensions) { - const withExt = `${candidate}${ext}` - if (existsSync(withExt)) { - return withExt - } - } - } - if (existsSync(candidate) && statSync(candidate).isDirectory()) { - for (const ext of extensions) { - const indexPath = path.join(candidate, `index${ext}`) - if (existsSync(indexPath)) { - return indexPath - } - } - } - return undefined -} - -function hasExtension(filePath: string): boolean { - return Boolean(path.extname(filePath)) -} diff --git a/packages/css/src/moduleResolution.ts b/packages/css/src/moduleResolution.ts new file mode 100644 index 0000000..e1969d8 --- /dev/null +++ b/packages/css/src/moduleResolution.ts @@ -0,0 +1,167 @@ +import path from 'node:path' +import { existsSync, statSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +import { + ResolverFactory, + type NapiResolveOptions, + type TsconfigOptions as ResolverTsconfigOptions, +} from 'oxc-resolver' + +type TsconfigLike = string | Record + +const DEFAULT_CONDITIONS = ['import', 'require', 'node', 'default'] + +export function normalizeResolverResult( + result: string | undefined, + cwd: string, +): string | undefined { + if (!result) { + return undefined + } + if (result.startsWith('file://')) { + try { + return fileURLToPath(new URL(result)) + } catch { + return undefined + } + } + return path.isAbsolute(result) ? result : path.resolve(cwd, result) +} + +export function resolveWithFactory( + factory: ResolverFactory, + specifier: string, + importer: string, + extensions: string[], +): string | undefined { + if (specifier.startsWith('file://')) { + try { + return findExistingFile(fileURLToPath(new URL(specifier)), extensions) + } catch { + return undefined + } + } + if (/^[a-z][\w+.-]*:/i.test(specifier)) { + return undefined + } + try { + const result = factory.resolveFileSync(importer, specifier) + return result?.path + } catch { + return undefined + } +} + +export function createResolverFactory( + cwd: string, + extensions: string[], + scriptExtensions: string[], + options?: { conditions?: string[]; tsconfig?: TsconfigLike }, +): ResolverFactory { + const resolveOptions: NapiResolveOptions = { + extensions, + conditionNames: options?.conditions ?? DEFAULT_CONDITIONS, + } + const extensionAlias = buildExtensionAlias(scriptExtensions) + if (extensionAlias) { + resolveOptions.extensionAlias = extensionAlias + } + const tsconfigOption = resolveResolverTsconfig(options?.tsconfig, cwd) + resolveOptions.tsconfig = tsconfigOption ?? 'auto' + return new ResolverFactory(resolveOptions) +} + +function buildExtensionAlias( + scriptExtensions: string[], +): Record | undefined { + const alias: Record = {} + const jsTargets = dedupeExtensions( + scriptExtensions.filter(ext => + ['.js', '.ts', '.tsx', '.mjs', '.cjs', '.mts', '.cts'].includes(ext), + ), + ) + if (jsTargets.length > 0) { + for (const key of ['.js', '.mjs', '.cjs']) { + alias[key] = jsTargets + } + } + const jsxTargets = dedupeExtensions( + scriptExtensions.filter(ext => ext === '.jsx' || ext === '.tsx'), + ) + if (jsxTargets.length > 0) { + alias['.jsx'] = jsxTargets + } + return Object.keys(alias).length > 0 ? alias : undefined +} + +function dedupeExtensions(extensions: string[]): string[] { + const result = new Set() + for (const ext of extensions) { + result.add(ext) + } + return Array.from(result) +} + +export function resolveResolverTsconfig( + input: TsconfigLike | undefined, + cwd: string, +): ResolverTsconfigOptions | undefined { + if (!input || typeof input !== 'string') { + return undefined + } + const resolved = resolveTsconfigPath(input, cwd) + if (!resolved) { + return undefined + } + return { configFile: resolved } +} + +export function resolveTsconfigPath( + tsconfigPath: string, + cwd: string, +): string | undefined { + const absolute = path.isAbsolute(tsconfigPath) + ? tsconfigPath + : path.resolve(cwd, tsconfigPath) + if (!existsSync(absolute)) { + return undefined + } + const stats = statSync(absolute) + if (stats.isDirectory()) { + const candidate = path.join(absolute, 'tsconfig.json') + return existsSync(candidate) ? candidate : undefined + } + return absolute +} + +export function findExistingFile( + candidate: string, + extensions: string[], +): string | undefined { + const candidateHasExt = hasExtension(candidate) + if (candidateHasExt && existsSync(candidate)) { + return candidate + } + if (!candidateHasExt) { + for (const ext of extensions) { + const withExt = `${candidate}${ext}` + if (existsSync(withExt)) { + return withExt + } + } + } + if (existsSync(candidate) && statSync(candidate).isDirectory()) { + for (const ext of extensions) { + const indexPath = path.join(candidate, `index${ext}`) + if (existsSync(indexPath)) { + return indexPath + } + } + } + return undefined +} + +export function hasExtension(filePath: string): boolean { + return Boolean(path.extname(filePath)) +} diff --git a/packages/css/test/generateTypes.test.ts b/packages/css/test/generateTypes.test.ts index a96e72f..bf31abb 100644 --- a/packages/css/test/generateTypes.test.ts +++ b/packages/css/test/generateTypes.test.ts @@ -26,7 +26,8 @@ async function setupFixtureProject(): Promise<{ cleanup: () => Promise }> { const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-generate-types-')) - const srcDir = path.join(tmpRoot, 'src') + const root = await fs.realpath(tmpRoot) + const srcDir = path.join(root, 'src') await fs.mkdir(srcDir, { recursive: true }) const fixtureDir = path.join(__dirname, 'fixtures', 'dialects', 'basic') const projectFixtureDir = path.join(srcDir, 'fixture') @@ -39,8 +40,8 @@ console.log(stableSelectors.demo) ` await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) return { - root: tmpRoot, - cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + root, + cleanup: () => fs.rm(root, { recursive: true, force: true }), } } @@ -49,7 +50,8 @@ async function setupBaseUrlFixture(): Promise<{ cleanup: () => Promise }> { const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-tsconfig-')) - const srcDir = path.join(tmpRoot, 'src') + const root = await fs.realpath(tmpRoot) + const srcDir = path.join(root, 'src') const stylesDir = path.join(srcDir, 'styles') await fs.mkdir(stylesDir, { recursive: true }) const cssPath = path.join(stylesDir, 'demo.css') @@ -69,13 +71,70 @@ console.log(stableSelectors.demo) baseUrl: './src', }, } + await fs.writeFile(path.join(root, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2)) + return { + root, + cleanup: () => fs.rm(root, { recursive: true, force: true }), + } +} + +async function setupPackageImportsFixture(): Promise<{ + root: string + cleanup: () => Promise +}> { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-imports-')) + const root = await fs.realpath(tmpRoot) + const srcDir = path.join(root, 'src') + await fs.mkdir(srcDir, { recursive: true }) + const cssPath = path.join(srcDir, 'imports.css') await fs.writeFile( - path.join(tmpRoot, 'tsconfig.json'), - JSON.stringify(tsconfig, null, 2), + cssPath, + `.demo { color: hotpink; } +.knighted-demo { color: rebeccapurple; } +`, + ) + const entrySource = `import stableSelectors from '#styles.knighted-css' +console.log(stableSelectors.demo) +` + await fs.writeFile(path.join(srcDir, 'entry.ts'), entrySource) + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify( + { + name: 'knighted-imports-fixture', + type: 'module', + imports: { + '#styles': './src/imports.css', + }, + }, + null, + 2, + ), ) return { - root: tmpRoot, - cleanup: () => fs.rm(tmpRoot, { recursive: true, force: true }), + root, + cleanup: () => fs.rm(root, { recursive: true, force: true }), + } +} + +async function setupHashImportsWorkspaceFixture(): Promise<{ + root: string + cleanup: () => Promise +}> { + const tmpRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'knighted-hash-imports-')) + const root = await fs.realpath(tmpRoot) + const sourceRoot = path.resolve( + __dirname, + '..', + '..', + 'playwright', + 'src', + 'hash-imports-workspace', + ) + await fs.cp(sourceRoot, root, { recursive: true }) + return { + root, + cleanup: () => fs.rm(root, { recursive: true, force: true }), } } @@ -276,6 +335,59 @@ test('generateTypes resolves tsconfig baseUrl specifiers', async () => { } }) +test('generateTypes resolves package.json imports specifiers', async () => { + const project = await setupPackageImportsFixture() + try { + const outDir = path.join(project.root, '.knighted-css-test') + const result = await generateTypes({ + rootDir: project.root, + include: ['src'], + outDir, + }) + assert.ok(result.selectorModulesWritten >= 1) + assert.equal(result.warnings.length, 0) + const selectorModulePath = path.join( + project.root, + 'src', + 'imports.css.knighted-css.ts', + ) + assert.equal(await pathExists(selectorModulePath), true) + } finally { + await project.cleanup() + } +}) + +test('generateTypes resolves hash-imports workspace package.json imports', async () => { + const workspace = await setupHashImportsWorkspaceFixture() + try { + const appRoot = path.join(workspace.root, 'apps', 'hash-import-demo') + const bridgeDir = path.join(appRoot, 'src', 'workspace-bridge') + await fs.writeFile( + path.join(bridgeDir, 'workspace-card.css'), + '.knighted-demo { color: dodgerblue; }\n', + ) + await fs.writeFile( + path.join(appRoot, 'src', 'types-entry.ts'), + "import selectors from '#workspace/ui/workspace-card.css.knighted-css'\n" + + 'console.log(selectors.demo)\n', + ) + + const outDir = path.join(appRoot, '.knighted-css-test') + const result = await generateTypes({ + rootDir: appRoot, + include: ['src'], + outDir, + }) + assert.ok(result.selectorModulesWritten >= 1) + assert.equal(result.warnings.length, 0) + + const selectorModulePath = path.join(bridgeDir, 'workspace-card.css.knighted-css.ts') + assert.equal(await pathExists(selectorModulePath), true) + } finally { + await workspace.cleanup() + } +}) + test('generateTypes removes stale selector manifest entries when modules vanish', async () => { const project = await setupFixtureProject() try { diff --git a/packages/playwright/package.json b/packages/playwright/package.json index fa3a9dd..4e662b7 100644 --- a/packages/playwright/package.json +++ b/packages/playwright/package.json @@ -21,7 +21,7 @@ "pretest": "npm run types && npm run build" }, "dependencies": { - "@knighted/css": "1.1.0-rc.2", + "@knighted/css": "1.1.0-rc.3", "@knighted/jsx": "^1.7.3", "lit": "^3.2.1", "react": "^19.0.0", diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts index 669d240..ad82546 100644 --- a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/render-hash-imports-demo.ts @@ -1,6 +1,7 @@ import { HASH_IMPORTS_SECTION_ID } from '../../../constants.js' import { createWorkspaceCard } from '#workspace/ui/workspace-card.js' import { knightedCss as workspaceCardCss } from '#workspace/ui/workspace-card.js?knighted-css' +import stableSelectors from '#workspace/ui/hash-imports.css.knighted-css.js' export function renderHashImportsWorkspaceDemo(root: HTMLElement): void { const mount = root ?? document.body @@ -11,6 +12,9 @@ export function renderHashImportsWorkspaceDemo(root: HTMLElement): void { const intro = document.createElement('p') intro.className = 'hash-imports-card__copy' + if (stableSelectors.demo) { + intro.classList.add(stableSelectors.demo) + } intro.textContent = '#workspace/ui/* specifiers resolve automatically because the loader passes tsconfig: auto to oxc-resolver. The npm workspace wiring mirrors how downstream apps map UI packages via package.json#imports without custom resolver code.' diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.css b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.css new file mode 100644 index 0000000..63605bc --- /dev/null +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/src/workspace-bridge/hash-imports.css @@ -0,0 +1,3 @@ +.knighted-demo { + color: #1e40af; +} diff --git a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json index 5dca4ef..a229f37 100644 --- a/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json +++ b/packages/playwright/src/hash-imports-workspace/apps/hash-import-demo/tsconfig.json @@ -2,8 +2,8 @@ "extends": "../../../../tsconfig.json", "compilerOptions": { "composite": true, - "rootDir": "./src", + "rootDir": "../..", "outDir": "./dist" }, - "include": ["src"] + "include": ["src", "../../packages/workspace-ui/src", "../../constants.ts"] }