diff --git a/docs/loader.md b/docs/loader.md index 81b14a9..5b6554d 100644 --- a/docs/loader.md +++ b/docs/loader.md @@ -63,6 +63,15 @@ When you want `knightedCss` to reflect the **hashed class names produced by your CSS Modules pipeline**, use the companion loader `@knighted/css/loader-bridge`. It runs after your Sass/CSS modules loaders and simply wraps their output (no reprocessing). +The key distinction: + +- `@knighted/css/loader` works from **source styles** (pre-hash). It resolves imports, + compiles CSS dialects, and emits `knightedCss` before any downstream CSS Modules + hashing/compilation happens. +- `@knighted/css/loader-bridge` works from **compiled output** (post-hash). It assumes your + CSS Modules pipeline already ran and therefore must be chained _after_ loaders like + `css-loader`, `sass-loader`, or `less-loader`. + ```js // rspack.config.js or webpack.config.js export default { diff --git a/packages/css/package.json b/packages/css/package.json index b1c75fd..40f6bc1 100644 --- a/packages/css/package.json +++ b/packages/css/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/css", - "version": "1.1.0-rc.6", + "version": "1.1.0-rc.7", "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/loaderBridge.ts b/packages/css/src/loaderBridge.ts index f5d2d06..ad153e3 100644 --- a/packages/css/src/loaderBridge.ts +++ b/packages/css/src/loaderBridge.ts @@ -14,6 +14,7 @@ import { shouldEmitCombinedDefault, TYPES_QUERY_FLAG, } from './loaderInternals.js' +import { collectTransitiveStyleImports } from './styleGraph.js' export interface KnightedCssBridgeLoaderOptions { emitCssModules?: boolean @@ -25,6 +26,7 @@ type BridgeModuleLike = { } const DEFAULT_EXPORT_NAME = 'knightedCss' +const BRIDGE_STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.css.ts'] const loader: LoaderDefinitionFunction = function loader( source, @@ -37,15 +39,13 @@ export const pitch: PitchLoaderDefinitionFunction { - const cssRequests = collectCssModuleRequests(source).map(request => - buildBridgeCssRequest(request), - ) + .then(async source => { + const cssRequests = await collectBridgeStyleRequests(this, source) const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest) callback( null, @@ -59,6 +59,41 @@ export const pitch: PitchLoaderDefinitionFunction callback(error as Error)) return } + const callback = getAsyncCallback(this) + if (!callback) { + const localsRequest = buildProxyRequest(this) + const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest) + const { emitCssModules } = resolveLoaderOptions(this) + const combined = hasCombinedQuery(this.resourceQuery) + const skipSyntheticDefault = hasNamedOnlyQueryFlag(this.resourceQuery) + + if (hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG)) { + emitKnightedWarning( + this, + 'The bridge loader does not generate stableSelectors. Remove the "types" query flag.', + ) + } + + const emitDefault = combined + ? shouldEmitCombinedDefault({ + detection: 'unknown', + request: localsRequest, + skipSyntheticDefault, + }) + : false + + const resolvedUpstream = upstreamRequest || localsRequest + const resolvedLocals = upstreamRequest || localsRequest + + return createBridgeModule({ + localsRequest: resolvedLocals, + upstreamRequest: resolvedUpstream, + combined, + emitDefault, + emitCssModules, + }) + } + const localsRequest = buildProxyRequest(this) const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest) const { emitCssModules } = resolveLoaderOptions(this) @@ -83,13 +118,27 @@ export const pitch: PitchLoaderDefinitionFunction { + const cssRequests = await collectBridgeStyleRequests(this, source) + callback( + null, + createBridgeModule({ + localsRequest: resolvedLocals, + upstreamRequest: resolvedUpstream, + combined, + emitDefault, + emitCssModules, + cssRequests, + }), + ) + }) + .catch(error => callback(error as Error)) + return } ;(loader as LoaderDefinitionFunction & { pitch?: typeof pitch }).pitch = pitch @@ -106,6 +155,12 @@ function resolveLoaderOptions( } } +function getAsyncCallback( + ctx: LoaderContext, +): ((error: Error | null, result?: string) => void) | undefined { + return typeof ctx.async === 'function' ? ctx.async() : undefined +} + function readResourceSource( ctx: LoaderContext, ): Promise { @@ -124,10 +179,80 @@ function readResourceSource( }) } -function collectCssModuleRequests(source: string): string[] { +async function collectBridgeStyleRequests( + ctx: LoaderContext, + source?: string, +): Promise { + const graphImports = await collectStyleGraphImports(ctx) + const graphPaths = new Set(graphImports.map(filePath => path.resolve(filePath))) + const graphRequests = graphImports + .filter(filePath => path.resolve(filePath) !== path.resolve(ctx.resourcePath)) + .map(filePath => buildBridgeCssRequest(filePath)) + + if (!source) { + return dedupeRequests(graphRequests) + } + + const directSpecifiers = collectStyleImportSpecifiers(source) + const directRequests = directSpecifiers + .map(specifier => { + const [resource, query] = specifier.split('?') + if (query) { + return buildBridgeCssRequest(specifier) + } + const resolved = resolveStyleSpecifier(resource, ctx.resourcePath) + if (resolved && graphPaths.has(resolved)) { + return undefined + } + return buildBridgeCssRequest(specifier) + }) + .filter((request): request is string => Boolean(request)) + + return dedupeRequests([...graphRequests, ...directRequests]) +} + +async function collectStyleGraphImports( + ctx: LoaderContext, +): Promise { + const cwd = ctx.rootContext ?? path.dirname(ctx.resourcePath) + const filter = (filePath: string) => !filePath.includes('node_modules') + try { + return await collectTransitiveStyleImports(ctx.resourcePath, { + cwd, + styleExtensions: BRIDGE_STYLE_EXTENSIONS, + filter, + }) + } catch { + return [] + } +} + +function resolveStyleSpecifier(specifier: string, importer: string): string | undefined { + if (!specifier) return undefined + if (specifier.startsWith('.')) { + return path.resolve(path.dirname(importer), specifier) + } + if (path.isAbsolute(specifier)) { + return path.resolve(specifier) + } + return undefined +} + +function dedupeRequests(requests: string[]): string[] { + const seen = new Set() + const output: string[] = [] + for (const request of requests) { + if (seen.has(request)) continue + seen.add(request) + output.push(request) + } + return output +} + +function collectStyleImportSpecifiers(source: string): string[] { const matches = new Set() const importPattern = - /(?:import|export)\s+(?:[^'"\n]+\s+from\s+)?['"]([^'"\n]+?\.module\.(?:css|scss|sass|less)(?:\?[^'"\n]+)?)['"]/g + /(?:import|export)\s+(?:[^'"\n]+\s+from\s+)?['"]([^'"\n]+?\.(?:css|scss|sass|less|css\.ts)(?:\?[^'"\n]+)?)['"]/g let match: RegExpExecArray | null while ((match = importPattern.exec(source))) { if (match[1]) { @@ -167,11 +292,15 @@ function createCombinedJsBridgeModule(options: CombinedJsBridgeOptions): string const upstreamLiteral = JSON.stringify(options.upstreamRequest) const cssImports = options.cssRequests.map((request, index) => { const literal = JSON.stringify(request) - return `import { knightedCss as __knightedCss${index}, knightedCssModules as __knightedCssModules${index} } from ${literal};` + return `import * as __knightedStyle${index} from ${literal};` }) - const cssValues = options.cssRequests.map((_, index) => `__knightedCss${index}`) - const cssModulesValues = options.cssRequests.map( - (_, index) => `__knightedCssModules${index}`, + const cssValues = options.cssRequests.map( + (_, index) => `__knightedStyle${index}.knightedCss`, + ) + const cssModulesValues = options.cssRequests.map((request, index) => + isCssModuleRequest(request) + ? `__knightedStyle${index}.knightedCssModules` + : 'undefined', ) const lines = [ `import * as __knightedUpstream from ${upstreamLiteral};`, @@ -259,26 +388,42 @@ interface BridgeModuleOptions { combined: boolean emitDefault: boolean emitCssModules: boolean + cssRequests?: string[] } function createBridgeModule(options: BridgeModuleOptions): string { const localsLiteral = JSON.stringify(options.localsRequest) const upstreamLiteral = JSON.stringify(options.upstreamRequest) + const cssRequests = options.cssRequests ?? [] + const cssImports = cssRequests.map((request, index) => { + const literal = JSON.stringify(request) + return `import * as __knightedStyle${index} from ${literal};` + }) + const cssValues = cssRequests.map((_, index) => `__knightedStyle${index}.knightedCss`) + const cssModulesValues = cssRequests.map((request, index) => + isCssModuleRequest(request) + ? `__knightedStyle${index}.knightedCssModules` + : 'undefined', + ) const lines = [ `import * as __knightedLocals from ${localsLiteral};`, `import * as __knightedUpstream from ${upstreamLiteral};`, + ...cssImports, `const __knightedDefault =\ntypeof __knightedUpstream.default !== 'undefined'\n ? __knightedUpstream.default\n : __knightedUpstream;`, `const __knightedResolveCss = ${resolveCssText.toString()};`, `const __knightedResolveCssModules = ${resolveCssModules.toString()};`, `const __knightedUpstreamLocals =\n __knightedResolveCssModules(__knightedUpstream, __knightedUpstream);`, `const __knightedLocalsExport =\n __knightedUpstreamLocals ??\n __knightedResolveCssModules(__knightedLocals, __knightedLocals) ??\n __knightedLocals;`, - `const __knightedCss = __knightedResolveCss(__knightedDefault, __knightedUpstream);`, + `const __knightedBaseCss = __knightedResolveCss(__knightedDefault, __knightedUpstream);`, + `const __knightedCss = [__knightedBaseCss, ${cssValues.join(', ')}].filter(Boolean).join('\\n');`, `export const ${DEFAULT_EXPORT_NAME} = __knightedCss;`, ] if (options.emitCssModules) { lines.push( - `const __knightedCssModules = __knightedLocalsExport ?? __knightedResolveCssModules(\n __knightedDefault,\n __knightedUpstream,\n);`, + `const __knightedCssModules = Object.assign({}, ...[__knightedLocalsExport ?? __knightedResolveCssModules(\n __knightedDefault,\n __knightedUpstream,\n), ${cssModulesValues.join( + ', ', + )}].filter(Boolean));`, 'export const knightedCssModules = __knightedCssModules;', ) } @@ -305,6 +450,12 @@ function buildUpstreamRequest(remainingRequest?: string): string { return request } +function isCssModuleRequest(request: string): boolean { + const [resource] = request.split('?') + const lower = resource.toLowerCase() + return /\.module\.(css|scss|sass|less|css\.ts)$/.test(lower) +} + function buildProxyRequest(ctx: LoaderContext): string { const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery) const rawRequest = getRawRequest(ctx) @@ -449,7 +600,7 @@ function emitKnightedWarning( } export const __loaderBridgeInternals = { - collectCssModuleRequests, + collectStyleImportSpecifiers, buildBridgeCssRequest, createCombinedJsBridgeModule, isJsLikeResource, diff --git a/packages/css/src/moduleGraph.ts b/packages/css/src/moduleGraph.ts index 2c435e8..df2d925 100644 --- a/packages/css/src/moduleGraph.ts +++ b/packages/css/src/moduleGraph.ts @@ -278,7 +278,7 @@ function extractModuleSpecifiers( return specifiers } -function normalizeSpecifier(raw: string): string { +export function normalizeSpecifier(raw: string): string { if (!raw) return '' const trimmed = raw.trim() if (!trimmed || trimmed.startsWith('\0')) { diff --git a/packages/css/src/styleGraph.ts b/packages/css/src/styleGraph.ts new file mode 100644 index 0000000..d3daf2e --- /dev/null +++ b/packages/css/src/styleGraph.ts @@ -0,0 +1,293 @@ +import path from 'node:path' +import { promises as fs } from 'node:fs' + +import type { CssResolver } from './types.js' +import { + collectStyleImports, + normalizeSpecifier, + type ModuleGraphOptions, +} from './moduleGraph.js' +import { + createResolverFactory, + findExistingFile, + normalizeResolverResult, + resolveWithFactory, +} from './moduleResolution.js' +import { + createPkgResolver, + ensureSassPath, + resolveAliasSpecifier, + shouldNormalizeSpecifier, +} from './sassInternals.js' + +const DEFAULT_STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.css.ts'] + +export interface StyleGraphOptions { + cwd: string + styleExtensions?: string[] + filter?: (filePath: string) => boolean + resolver?: CssResolver + moduleGraph?: ModuleGraphOptions +} + +export async function collectTransitiveStyleImports( + entryPath: string, + options: StyleGraphOptions, +): Promise { + const cwd = path.resolve(options.cwd) + const extensions = normalizeExtensions( + options.styleExtensions ?? DEFAULT_STYLE_EXTENSIONS, + ) + const filter = + typeof options.filter === 'function' + ? options.filter + : (filePath: string) => !filePath.includes('node_modules') + + const entryIsStyle = isStyleExtension(entryPath, extensions) + let initialStyles: string[] + + if (entryIsStyle) { + initialStyles = [path.resolve(entryPath)] + } else { + initialStyles = await collectStyleImports(entryPath, { + cwd, + styleExtensions: extensions, + filter, + resolver: options.resolver, + graphOptions: options.moduleGraph, + }) + } + + const queue = [...initialStyles] + const ordered: string[] = [] + const seen = new Set() + + for (const file of initialStyles) { + const resolved = path.resolve(file) + if (seen.has(resolved)) continue + seen.add(resolved) + ordered.push(resolved) + } + + const resolverFactory = createResolverFactory(cwd, extensions, extensions, { + conditions: options.moduleGraph?.conditions, + tsconfig: options.moduleGraph?.tsConfig, + }) + const sassResolver = createPkgResolver(cwd) + + while (queue.length > 0) { + const filePath = queue.shift() + if (!filePath) continue + const absolutePath = path.resolve(filePath) + let source: string + try { + source = await fs.readFile(absolutePath, 'utf8') + } catch { + continue + } + const dialect = getStyleDialect(absolutePath) + if (!dialect) continue + const specifiers = collectStyleSpecifiers(source, dialect) + + for (const raw of specifiers) { + const normalized = normalizeSpecifier(raw) + if (!normalized) continue + if (isExternalSpecifier(normalized)) continue + + const resolved = await resolveStyleSpecifier({ + specifier: normalized, + importer: absolutePath, + dialect, + extensions, + cwd, + resolver: options.resolver, + resolverFactory, + sassResolver, + }) + + if (!resolved) continue + const normalizedResolved = path.resolve(resolved) + if (!filter(normalizedResolved)) continue + if (seen.has(normalizedResolved)) continue + seen.add(normalizedResolved) + ordered.push(normalizedResolved) + queue.push(normalizedResolved) + } + } + + return ordered +} + +function normalizeExtensions(extensions: string[]): string[] { + const result = new Set() + for (const ext of extensions) { + if (!ext) continue + const normalized = ext.startsWith('.') ? ext.toLowerCase() : `.${ext.toLowerCase()}` + result.add(normalized) + } + return Array.from(result) +} + +function isStyleExtension(filePath: string, extensions: string[]): boolean { + const lower = filePath.toLowerCase() + return extensions.some(ext => lower.endsWith(ext)) +} + +type StyleDialect = 'css' | 'sass' | 'less' + +function getStyleDialect(filePath: string): StyleDialect | undefined { + const lower = filePath.toLowerCase() + if (lower.endsWith('.scss') || lower.endsWith('.sass')) return 'sass' + if (lower.endsWith('.less')) return 'less' + if (lower.endsWith('.css')) return 'css' + return undefined +} + +function collectStyleSpecifiers(source: string, dialect: StyleDialect): string[] { + if (dialect === 'sass') { + return collectSassSpecifiers(source) + } + if (dialect === 'less') { + return collectLessSpecifiers(source) + } + return collectCssSpecifiers(source) +} + +function collectCssSpecifiers(source: string): string[] { + const results: string[] = [] + const rx = /@import\s+(?:url\(\s*)?(?:['"])([^'"\n\r]+)(?:['"])\s*\)?/gi + let match: RegExpExecArray | null + while ((match = rx.exec(source)) !== null) { + if (match[1]) results.push(match[1]) + } + return results +} + +function collectLessSpecifiers(source: string): string[] { + const results: string[] = [] + const rx = + /@import\s*(?:\([^)]*\)\s*)?(?:url\(\s*)?(?:['"])([^'"\n\r]+)(?:['"])\s*\)?/gi + let match: RegExpExecArray | null + while ((match = rx.exec(source)) !== null) { + if (match[1]) results.push(match[1]) + } + return results +} + +function collectSassSpecifiers(source: string): string[] { + const results: string[] = [] + const rx = /@(?:use|forward)\s+(?:['"])([^'"\n\r]+)(?:['"])/gi + let match: RegExpExecArray | null + while ((match = rx.exec(source)) !== null) { + if (match[1]) results.push(match[1]) + } + const importRx = /@import\s+([^;]+);/gi + while ((match = importRx.exec(source)) !== null) { + const chunk = match[1] + if (!chunk) continue + const quoted = chunk.match(/['"]([^'"]+)['"]/g) ?? [] + for (const entry of quoted) { + const cleaned = entry.slice(1, -1) + if (cleaned) results.push(cleaned) + } + } + return results +} + +function isExternalSpecifier(specifier: string): boolean { + return /^(?:https?:|data:|blob:)/i.test(specifier) +} + +async function resolveStyleSpecifier({ + specifier, + importer, + dialect, + extensions, + cwd, + resolver, + resolverFactory, + sassResolver, +}: { + specifier: string + importer: string + dialect: StyleDialect + extensions: string[] + cwd: string + resolver?: CssResolver + resolverFactory: ReturnType + sassResolver: ReturnType +}): Promise { + if (dialect === 'sass') { + return resolveSassSpecifier({ + specifier, + importer, + cwd, + resolver, + sassResolver, + }) + } + + const resolvedByResolver = await resolveWithCustomResolver( + specifier, + importer, + cwd, + resolver, + extensions, + ) + if (resolvedByResolver) return resolvedByResolver + + if (specifier.startsWith('.')) { + return findExistingFile(path.resolve(path.dirname(importer), specifier), extensions) + } + if (path.isAbsolute(specifier)) { + return findExistingFile(specifier, extensions) + } + return resolveWithFactory(resolverFactory, specifier, importer, extensions) +} + +async function resolveWithCustomResolver( + specifier: string, + importer: string, + cwd: string, + resolver: CssResolver | undefined, + extensions: string[], +): Promise { + if (!resolver) return undefined + const resolved = normalizeResolverResult( + await resolver(specifier, { cwd, from: importer }), + cwd, + ) + if (!resolved) return undefined + return findExistingFile(resolved, extensions) ?? resolved +} + +async function resolveSassSpecifier({ + specifier, + importer, + cwd, + resolver, + sassResolver, +}: { + specifier: string + importer: string + cwd: string + resolver?: CssResolver + sassResolver: ReturnType +}): Promise { + if (resolver && shouldNormalizeSpecifier(specifier)) { + const resolved = await resolveAliasSpecifier(specifier, resolver, cwd, importer) + if (resolved) return resolved + } + if (specifier.startsWith('pkg:')) { + const resolved = await sassResolver(specifier.slice(4), importer) + return resolved ? (ensureSassPath(resolved) ?? resolved) : undefined + } + if (specifier.startsWith('.')) { + return ensureSassPath(path.resolve(path.dirname(importer), specifier)) + } + if (path.isAbsolute(specifier)) { + return ensureSassPath(specifier) + } + const resolved = await sassResolver(specifier, importer) + return resolved ? (ensureSassPath(resolved) ?? resolved) : undefined +} diff --git a/packages/css/test/fixtures/bridge-graph/components/button.module.scss b/packages/css/test/fixtures/bridge-graph/components/button.module.scss new file mode 100644 index 0000000..dcffeaa --- /dev/null +++ b/packages/css/test/fixtures/bridge-graph/components/button.module.scss @@ -0,0 +1,3 @@ +.button { + background: hotpink; +} diff --git a/packages/css/test/fixtures/bridge-graph/components/button.module.scss.d.ts b/packages/css/test/fixtures/bridge-graph/components/button.module.scss.d.ts new file mode 100644 index 0000000..7e38105 --- /dev/null +++ b/packages/css/test/fixtures/bridge-graph/components/button.module.scss.d.ts @@ -0,0 +1,5 @@ +declare const classes: { + readonly [key: string]: string +} + +export default classes diff --git a/packages/css/test/fixtures/bridge-graph/components/button.tsx b/packages/css/test/fixtures/bridge-graph/components/button.tsx new file mode 100644 index 0000000..814d6e2 --- /dev/null +++ b/packages/css/test/fixtures/bridge-graph/components/button.tsx @@ -0,0 +1,3 @@ +import styles from './button.module.scss' + +export const Button = () => diff --git a/packages/css/test/fixtures/bridge-graph/entry.tsx b/packages/css/test/fixtures/bridge-graph/entry.tsx new file mode 100644 index 0000000..33e32f9 --- /dev/null +++ b/packages/css/test/fixtures/bridge-graph/entry.tsx @@ -0,0 +1,3 @@ +import { Button } from './components/button.js' + +export const Entry = () =>