Skip to content

Commit dd3eab1

Browse files
fix: dependency graph resolution for transitive styles in loader bridge. (#66)
1 parent 32d5edc commit dd3eab1

22 files changed

Lines changed: 931 additions & 30 deletions

docs/loader.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,15 @@ When you want `knightedCss` to reflect the **hashed class names produced by your
6363
CSS Modules pipeline**, use the companion loader `@knighted/css/loader-bridge`. It runs
6464
after your Sass/CSS modules loaders and simply wraps their output (no reprocessing).
6565

66+
The key distinction:
67+
68+
- `@knighted/css/loader` works from **source styles** (pre-hash). It resolves imports,
69+
compiles CSS dialects, and emits `knightedCss` before any downstream CSS Modules
70+
hashing/compilation happens.
71+
- `@knighted/css/loader-bridge` works from **compiled output** (post-hash). It assumes your
72+
CSS Modules pipeline already ran and therefore must be chained _after_ loaders like
73+
`css-loader`, `sass-loader`, or `less-loader`.
74+
6675
```js
6776
// rspack.config.js or webpack.config.js
6877
export default {

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.1.0-rc.6",
3+
"version": "1.1.0-rc.7",
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/loaderBridge.ts

Lines changed: 172 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
shouldEmitCombinedDefault,
1515
TYPES_QUERY_FLAG,
1616
} from './loaderInternals.js'
17+
import { collectTransitiveStyleImports } from './styleGraph.js'
1718

1819
export interface KnightedCssBridgeLoaderOptions {
1920
emitCssModules?: boolean
@@ -25,6 +26,7 @@ type BridgeModuleLike = {
2526
}
2627

2728
const DEFAULT_EXPORT_NAME = 'knightedCss'
29+
const BRIDGE_STYLE_EXTENSIONS = ['.css', '.scss', '.sass', '.less', '.css.ts']
2830

2931
const loader: LoaderDefinitionFunction<KnightedCssBridgeLoaderOptions> = function loader(
3032
source,
@@ -37,15 +39,13 @@ export const pitch: PitchLoaderDefinitionFunction<KnightedCssBridgeLoaderOptions
3739
const resolvedRemainingRequest = resolveRemainingRequest(this, remainingRequest)
3840

3941
if (isJsLikeResource(this.resourcePath) && hasCombinedQuery(this.resourceQuery)) {
40-
const callback = this.async()
42+
const callback = getAsyncCallback(this)
4143
if (!callback) {
4244
return createCombinedJsBridgeModuleSync(resolvedRemainingRequest)
4345
}
4446
readResourceSource(this)
45-
.then(source => {
46-
const cssRequests = collectCssModuleRequests(source).map(request =>
47-
buildBridgeCssRequest(request),
48-
)
47+
.then(async source => {
48+
const cssRequests = await collectBridgeStyleRequests(this, source)
4949
const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest)
5050
callback(
5151
null,
@@ -59,6 +59,41 @@ export const pitch: PitchLoaderDefinitionFunction<KnightedCssBridgeLoaderOptions
5959
.catch(error => callback(error as Error))
6060
return
6161
}
62+
const callback = getAsyncCallback(this)
63+
if (!callback) {
64+
const localsRequest = buildProxyRequest(this)
65+
const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest)
66+
const { emitCssModules } = resolveLoaderOptions(this)
67+
const combined = hasCombinedQuery(this.resourceQuery)
68+
const skipSyntheticDefault = hasNamedOnlyQueryFlag(this.resourceQuery)
69+
70+
if (hasQueryFlag(this.resourceQuery, TYPES_QUERY_FLAG)) {
71+
emitKnightedWarning(
72+
this,
73+
'The bridge loader does not generate stableSelectors. Remove the "types" query flag.',
74+
)
75+
}
76+
77+
const emitDefault = combined
78+
? shouldEmitCombinedDefault({
79+
detection: 'unknown',
80+
request: localsRequest,
81+
skipSyntheticDefault,
82+
})
83+
: false
84+
85+
const resolvedUpstream = upstreamRequest || localsRequest
86+
const resolvedLocals = upstreamRequest || localsRequest
87+
88+
return createBridgeModule({
89+
localsRequest: resolvedLocals,
90+
upstreamRequest: resolvedUpstream,
91+
combined,
92+
emitDefault,
93+
emitCssModules,
94+
})
95+
}
96+
6297
const localsRequest = buildProxyRequest(this)
6398
const upstreamRequest = buildUpstreamRequest(resolvedRemainingRequest)
6499
const { emitCssModules } = resolveLoaderOptions(this)
@@ -83,13 +118,27 @@ export const pitch: PitchLoaderDefinitionFunction<KnightedCssBridgeLoaderOptions
83118
const resolvedUpstream = upstreamRequest || localsRequest
84119
const resolvedLocals = upstreamRequest || localsRequest
85120

86-
return createBridgeModule({
87-
localsRequest: resolvedLocals,
88-
upstreamRequest: resolvedUpstream,
89-
combined,
90-
emitDefault,
91-
emitCssModules,
92-
})
121+
const collectSource = isJsLikeResource(this.resourcePath)
122+
? readResourceSource(this)
123+
: Promise.resolve(undefined)
124+
125+
collectSource
126+
.then(async source => {
127+
const cssRequests = await collectBridgeStyleRequests(this, source)
128+
callback(
129+
null,
130+
createBridgeModule({
131+
localsRequest: resolvedLocals,
132+
upstreamRequest: resolvedUpstream,
133+
combined,
134+
emitDefault,
135+
emitCssModules,
136+
cssRequests,
137+
}),
138+
)
139+
})
140+
.catch(error => callback(error as Error))
141+
return
93142
}
94143
;(loader as LoaderDefinitionFunction & { pitch?: typeof pitch }).pitch = pitch
95144

@@ -106,6 +155,12 @@ function resolveLoaderOptions(
106155
}
107156
}
108157

158+
function getAsyncCallback(
159+
ctx: LoaderContext<KnightedCssBridgeLoaderOptions>,
160+
): ((error: Error | null, result?: string) => void) | undefined {
161+
return typeof ctx.async === 'function' ? ctx.async() : undefined
162+
}
163+
109164
function readResourceSource(
110165
ctx: LoaderContext<KnightedCssBridgeLoaderOptions>,
111166
): Promise<string> {
@@ -124,10 +179,80 @@ function readResourceSource(
124179
})
125180
}
126181

127-
function collectCssModuleRequests(source: string): string[] {
182+
async function collectBridgeStyleRequests(
183+
ctx: LoaderContext<KnightedCssBridgeLoaderOptions>,
184+
source?: string,
185+
): Promise<string[]> {
186+
const graphImports = await collectStyleGraphImports(ctx)
187+
const graphPaths = new Set(graphImports.map(filePath => path.resolve(filePath)))
188+
const graphRequests = graphImports
189+
.filter(filePath => path.resolve(filePath) !== path.resolve(ctx.resourcePath))
190+
.map(filePath => buildBridgeCssRequest(filePath))
191+
192+
if (!source) {
193+
return dedupeRequests(graphRequests)
194+
}
195+
196+
const directSpecifiers = collectStyleImportSpecifiers(source)
197+
const directRequests = directSpecifiers
198+
.map(specifier => {
199+
const [resource, query] = specifier.split('?')
200+
if (query) {
201+
return buildBridgeCssRequest(specifier)
202+
}
203+
const resolved = resolveStyleSpecifier(resource, ctx.resourcePath)
204+
if (resolved && graphPaths.has(resolved)) {
205+
return undefined
206+
}
207+
return buildBridgeCssRequest(specifier)
208+
})
209+
.filter((request): request is string => Boolean(request))
210+
211+
return dedupeRequests([...graphRequests, ...directRequests])
212+
}
213+
214+
async function collectStyleGraphImports(
215+
ctx: LoaderContext<KnightedCssBridgeLoaderOptions>,
216+
): Promise<string[]> {
217+
const cwd = ctx.rootContext ?? path.dirname(ctx.resourcePath)
218+
const filter = (filePath: string) => !filePath.includes('node_modules')
219+
try {
220+
return await collectTransitiveStyleImports(ctx.resourcePath, {
221+
cwd,
222+
styleExtensions: BRIDGE_STYLE_EXTENSIONS,
223+
filter,
224+
})
225+
} catch {
226+
return []
227+
}
228+
}
229+
230+
function resolveStyleSpecifier(specifier: string, importer: string): string | undefined {
231+
if (!specifier) return undefined
232+
if (specifier.startsWith('.')) {
233+
return path.resolve(path.dirname(importer), specifier)
234+
}
235+
if (path.isAbsolute(specifier)) {
236+
return path.resolve(specifier)
237+
}
238+
return undefined
239+
}
240+
241+
function dedupeRequests(requests: string[]): string[] {
242+
const seen = new Set<string>()
243+
const output: string[] = []
244+
for (const request of requests) {
245+
if (seen.has(request)) continue
246+
seen.add(request)
247+
output.push(request)
248+
}
249+
return output
250+
}
251+
252+
function collectStyleImportSpecifiers(source: string): string[] {
128253
const matches = new Set<string>()
129254
const importPattern =
130-
/(?:import|export)\s+(?:[^'"\n]+\s+from\s+)?['"]([^'"\n]+?\.module\.(?:css|scss|sass|less)(?:\?[^'"\n]+)?)['"]/g
255+
/(?:import|export)\s+(?:[^'"\n]+\s+from\s+)?['"]([^'"\n]+?\.(?:css|scss|sass|less|css\.ts)(?:\?[^'"\n]+)?)['"]/g
131256
let match: RegExpExecArray | null
132257
while ((match = importPattern.exec(source))) {
133258
if (match[1]) {
@@ -167,11 +292,15 @@ function createCombinedJsBridgeModule(options: CombinedJsBridgeOptions): string
167292
const upstreamLiteral = JSON.stringify(options.upstreamRequest)
168293
const cssImports = options.cssRequests.map((request, index) => {
169294
const literal = JSON.stringify(request)
170-
return `import { knightedCss as __knightedCss${index}, knightedCssModules as __knightedCssModules${index} } from ${literal};`
295+
return `import * as __knightedStyle${index} from ${literal};`
171296
})
172-
const cssValues = options.cssRequests.map((_, index) => `__knightedCss${index}`)
173-
const cssModulesValues = options.cssRequests.map(
174-
(_, index) => `__knightedCssModules${index}`,
297+
const cssValues = options.cssRequests.map(
298+
(_, index) => `__knightedStyle${index}.knightedCss`,
299+
)
300+
const cssModulesValues = options.cssRequests.map((request, index) =>
301+
isCssModuleRequest(request)
302+
? `__knightedStyle${index}.knightedCssModules`
303+
: 'undefined',
175304
)
176305
const lines = [
177306
`import * as __knightedUpstream from ${upstreamLiteral};`,
@@ -259,26 +388,42 @@ interface BridgeModuleOptions {
259388
combined: boolean
260389
emitDefault: boolean
261390
emitCssModules: boolean
391+
cssRequests?: string[]
262392
}
263393

264394
function createBridgeModule(options: BridgeModuleOptions): string {
265395
const localsLiteral = JSON.stringify(options.localsRequest)
266396
const upstreamLiteral = JSON.stringify(options.upstreamRequest)
397+
const cssRequests = options.cssRequests ?? []
398+
const cssImports = cssRequests.map((request, index) => {
399+
const literal = JSON.stringify(request)
400+
return `import * as __knightedStyle${index} from ${literal};`
401+
})
402+
const cssValues = cssRequests.map((_, index) => `__knightedStyle${index}.knightedCss`)
403+
const cssModulesValues = cssRequests.map((request, index) =>
404+
isCssModuleRequest(request)
405+
? `__knightedStyle${index}.knightedCssModules`
406+
: 'undefined',
407+
)
267408
const lines = [
268409
`import * as __knightedLocals from ${localsLiteral};`,
269410
`import * as __knightedUpstream from ${upstreamLiteral};`,
411+
...cssImports,
270412
`const __knightedDefault =\ntypeof __knightedUpstream.default !== 'undefined'\n ? __knightedUpstream.default\n : __knightedUpstream;`,
271413
`const __knightedResolveCss = ${resolveCssText.toString()};`,
272414
`const __knightedResolveCssModules = ${resolveCssModules.toString()};`,
273415
`const __knightedUpstreamLocals =\n __knightedResolveCssModules(__knightedUpstream, __knightedUpstream);`,
274416
`const __knightedLocalsExport =\n __knightedUpstreamLocals ??\n __knightedResolveCssModules(__knightedLocals, __knightedLocals) ??\n __knightedLocals;`,
275-
`const __knightedCss = __knightedResolveCss(__knightedDefault, __knightedUpstream);`,
417+
`const __knightedBaseCss = __knightedResolveCss(__knightedDefault, __knightedUpstream);`,
418+
`const __knightedCss = [__knightedBaseCss, ${cssValues.join(', ')}].filter(Boolean).join('\\n');`,
276419
`export const ${DEFAULT_EXPORT_NAME} = __knightedCss;`,
277420
]
278421

279422
if (options.emitCssModules) {
280423
lines.push(
281-
`const __knightedCssModules = __knightedLocalsExport ?? __knightedResolveCssModules(\n __knightedDefault,\n __knightedUpstream,\n);`,
424+
`const __knightedCssModules = Object.assign({}, ...[__knightedLocalsExport ?? __knightedResolveCssModules(\n __knightedDefault,\n __knightedUpstream,\n), ${cssModulesValues.join(
425+
', ',
426+
)}].filter(Boolean));`,
282427
'export const knightedCssModules = __knightedCssModules;',
283428
)
284429
}
@@ -305,6 +450,12 @@ function buildUpstreamRequest(remainingRequest?: string): string {
305450
return request
306451
}
307452

453+
function isCssModuleRequest(request: string): boolean {
454+
const [resource] = request.split('?')
455+
const lower = resource.toLowerCase()
456+
return /\.module\.(css|scss|sass|less|css\.ts)$/.test(lower)
457+
}
458+
308459
function buildProxyRequest(ctx: LoaderContext<KnightedCssBridgeLoaderOptions>): string {
309460
const sanitizedQuery = buildSanitizedQuery(ctx.resourceQuery)
310461
const rawRequest = getRawRequest(ctx)
@@ -449,7 +600,7 @@ function emitKnightedWarning(
449600
}
450601

451602
export const __loaderBridgeInternals = {
452-
collectCssModuleRequests,
603+
collectStyleImportSpecifiers,
453604
buildBridgeCssRequest,
454605
createCombinedJsBridgeModule,
455606
isJsLikeResource,

packages/css/src/moduleGraph.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -278,7 +278,7 @@ function extractModuleSpecifiers(
278278
return specifiers
279279
}
280280

281-
function normalizeSpecifier(raw: string): string {
281+
export function normalizeSpecifier(raw: string): string {
282282
if (!raw) return ''
283283
const trimmed = raw.trim()
284284
if (!trimmed || trimmed.startsWith('\0')) {

0 commit comments

Comments
 (0)