From 55e9ce4030de388dd22e2d9ff1364158e291c843 Mon Sep 17 00:00:00 2001 From: soreavis <263610811+soreavis@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:34:42 +0200 Subject: [PATCH] Preserve source files on CSS prepended from composed dependencies When a CSS module uses `composes: x from "./other.css"`, the CSS of that other file is copied into the output. It was copied as plain text, so PostCSS no longer knew which file each copied rule came from. Tools that run after postcss-modules need exactly that information. A bundler's URL rewriter, for example, has to turn `url(./image.png)` into a working path - and to do that it must know the directory of the file the rule was written in. With the information gone, those paths silently break. The copied CSS still produces exactly the same output as before. But now each dependency is also parsed once more under its own filename, and the file information from that parse is attached to the copied rules, matched one-to-one by position. If broken CSS makes two files' rules merge at a boundary, the one-to-one match no longer holds - in that case nothing is attached, which is the old behavior, rather than attaching the wrong file. Custom loaders that don't provide the new `finalSources` getter are unaffected. See #149. --- src/FileSystemLoader.js | 12 ++++++---- src/pluginFactory.js | 51 +++++++++++++++++++++++++++++++++++++++-- test/test.js | 18 +++++++++++++++ 3 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/FileSystemLoader.js b/src/FileSystemLoader.js index 09091a1..d319ae1 100644 --- a/src/FileSystemLoader.js +++ b/src/FileSystemLoader.js @@ -116,10 +116,10 @@ export default class FileSystemLoader { }); } - get finalSource() { + get finalSources() { const traces = this.traces; const sources = this.sources; - let written = new Set(); + const written = new Set(); return Object.keys(traces) .sort(traceKeySorter) @@ -130,8 +130,12 @@ export default class FileSystemLoader { } written.add(filename); - return sources[filename]; + return { file: filename, source: sources[filename] }; }) - .join(""); + .filter(Boolean); + } + + get finalSource() { + return this.finalSources.map((entry) => entry.source).join(""); } } diff --git a/src/pluginFactory.js b/src/pluginFactory.js index 376f69c..d815f1b 100644 --- a/src/pluginFactory.js +++ b/src/pluginFactory.js @@ -84,8 +84,55 @@ export function makePlugin(opts) { if (rule.selector.trim() === "") rule.remove(); }); - const out = loader.finalSource; - if (out) css.prepend(out); + // Prepend the traced dependencies exactly as before, then re-parse + // each dependency with its own `from` and graft those sources onto + // the prepended nodes, pairing them by position. The pairing holds + // because each dependency is complete CSS, so parsing the + // concatenation yields the same node sequence as parsing each part. + // If malformed CSS merges nodes across a file boundary the counts + // differ and the graft is skipped, leaving the nodes unattributed + // as before. Plugins that run after this one (e.g. url rewriters) + // need `source.input.file` to resolve relative paths against the + // file the CSS actually came from — see #149. + const sources = Array.isArray(loader.finalSources) ? loader.finalSources : null; + if (sources) { + const joined = sources.map((entry) => entry.source).join(""); + if (joined) { + const countBefore = css.nodes.length; + css.prepend(joined); + const prepended = css.nodes.slice(0, css.nodes.length - countBefore); + const sourceSeq = []; + for (const { file, source } of sources) { + postcss.parse(source, { from: file }).walk((node) => { + sourceSeq.push(node.source); + }); + } + let count = 0; + for (const node of prepended) { + count += 1; + if (typeof node.walk === "function") { + node.walk(() => { + count += 1; + }); + } + } + if (count === sourceSeq.length) { + let i = 0; + for (const node of prepended) { + node.source = sourceSeq[i++]; + if (typeof node.walk === "function") { + node.walk((child) => { + child.source = sourceSeq[i++]; + }); + } + } + } + } + } else { + // Custom loaders may not implement `finalSources`. + const out = loader.finalSource; + if (out) css.prepend(out); + } if (opts.localsConvention) { const reducer = makeLocalsConventionReducer(opts.localsConvention, inputFile); diff --git a/test/test.js b/test/test.js index 545e3e9..aa19c09 100644 --- a/test/test.js +++ b/test/test.js @@ -129,6 +129,24 @@ p { expect(result.css).toEqual(source.replace("green", "blue")); }); +it("keeps the source file on CSS prepended from composed dependencies", async () => { + const sourceFile = path.join(fixturesPath, "in", "composes.css"); + const source = fs.readFileSync(sourceFile).toString(); + const mixinsFile = path.join(fixturesPath, "in", "composes.mixins.css"); + + const result = await postcss([plugin({ generateScopedName, getJSON: () => {} })]).process( + source, + { from: sourceFile }, + ); + + const fileByDecl = {}; + result.root.walkDecls((decl) => { + fileByDecl[`${decl.prop}: ${decl.value}`] = decl.source.input.file; + }); + expect(fileByDecl["font-size: 40px"]).toEqual(mixinsFile); + expect(fileByDecl["color: green"]).toEqual(sourceFile); +}); + it("saves JSON next to CSS by default", async () => { const sourceFile = path.join(fixturesPath, "in", "saveJSON.css"); const source = fs.readFileSync(sourceFile).toString();