Skip to content

Commit bbb8c3f

Browse files
feat: resolve url() inside HTML style attributes via CSS parser as option (#21157)
* feat: resolve url() inside HTML style attributes via CSS parser "as" option Add a CSS parser `as` option (default `"stylesheet"`) that selects the parse entry point. With `as: "declaration-list"` the source is parsed as a block's contents (CSS Syntax §5.4.5) instead of a full stylesheet, which is the correct model for an element's `style="..."` attribute. HTML modules now route URL-bearing `style` attributes through the CSS pipeline (via the new `html-style-attribute` dependency category, parsed with `as: "declaration-list"`), so `url()` / `image-set()` references in inline styles resolve relative to the HTML file. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): inject the top-level consumer into the CSS walk Instead of switching on an `as` string inside `grammar`, `SourceProcessor` now accepts a `consume` driver (a `TopLevelConsumer`) and defaults to the stylesheet consumer. `consumeADeclarationList` is exported as a reusable driver, and `CssParser` maps its `as` option to it — so the walk no longer hardcodes how the source is parsed. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): drive the walk through the spec parse* entry points The injectable top-level driver now routes through the §5.3 `parse*` functions instead of the §5.4 `consume*` internals: the default uses `parseAStylesheetsContents` and a `style` attribute uses the new `parseADeclarationList` ("parse a block's contents", §5.3.6). Both receive the already-built `TokenStream`, which `normalizeIntoTokenStream` returns as-is, so there is no re-tokenization. `parseAStylesheetsContents` gains the same optional streaming `onRule` sink `consumeAStylesheetsContents` already had, so the default path keeps its rule-by-rule memory profile. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): use parseABlocksContents directly for style attributes Drop the `parseADeclarationList` wrapper — its name reflects the old spec term "parse a list of declarations", which CSS Syntax renamed to "parse a block's contents". CssParser now builds the `style`-attribute top-level parser straight from `parseABlocksContents` (§5.3.6), reusing the existing token stream as before. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): pass parseAStylesheet / parseABlocksContents as the walk driver Drop the "declaration-list" terminology and the grammar's internal default. `SourceProcessor#process` now requires the caller to pass the top-level parse entry point, and CssParser passes one of the spec §5.3 functions directly: `parseAStylesheet` for a stylesheet, `parseABlocksContents` for a block's contents (the `as: "block"` mode, e.g. an HTML `style` attribute). The walk reuses the existing token stream and walks the returned nodes. Revert the now-unused streaming `onRule` extension on `parseAStylesheetsContents`. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): align `as` value with CSS spec and drop dead onRule Rename the `style` attribute parse mode from `as: "block"` to `as: "block-contents"` so the option values match the CSS Syntax §5.3 entry points ("parse a block's contents" / "parse a stylesheet"), leaving room for future spec-named modes. Also remove the now-unused `onRule` streaming param from `consumeAStylesheetsContents`. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): keep stylesheet streaming intact, make block-contents purely additive Restore the original "consume a stylesheet's contents" streaming driver (`consumeAStylesheetsContents(ts, onRule)`) for the default `as: "stylesheet"` mode — the spec parse logic is unchanged. The `as: "block-contents"` mode is now a small additive branch in the walk's grammar that parses a block's contents instead. Drops the `TopLevelParser` indirection. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * refactor(css): drive the walk through a uniform top-level consumer table Give `consumeABlocksContents` the same streaming `onNode` callback that `consumeAStylesheetsContents` already exposes, so both share a `(ts, onNode)` shape. The walk's `grammar` now dispatches `as` through a `TOP_LEVEL_CONSUMERS` map instead of a branch — a future `as` mode is one map entry, no new walk code, and every mode streams (no full AST retained). https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * chore: rename changeset file to match block-contents terminology https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * feat(html): add stylesheet-style-attribute source type, rename stylesheet-inline Let developers route a custom HTML attribute's value through the CSS pipeline as a block's contents (a declaration list, like a `style` attribute) via the new `stylesheet-style-attribute` source type, alongside `stylesheet-style` for a full inline stylesheet. Renames the experimental `stylesheet-inline` type to `stylesheet-style` so the inline pair mirrors `<style>` / `style=`. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX * test(css): strictly type the new block-contents walk tests Annotate the visitor arrays and cast the `VisitorMap` / visitor params, matching the strict test-suite typing introduced on main (#21147), so the two added `as: "block-contents"` tests pass `tsc -p tsconfig.types.test.json`. https://claude.ai/code/session_01RtuVwAXk4wSAPE85d8qadX
1 parent c387c00 commit bbb8c3f

27 files changed

Lines changed: 483 additions & 103 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"webpack": minor
3+
---
4+
5+
Add CSS parser `as` option and resolve `url()` inside HTML `style` attributes.

declarations/WebpackOptions.d.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -803,6 +803,10 @@ export type AssetParserDataUrlFunction =
803803
* Enable/disable renaming of `@keyframes`.
804804
*/
805805
export type CssParserAnimation = boolean;
806+
/**
807+
* Configure how the CSS source is parsed: as a full stylesheet (default) or as a block's contents (e.g. the content of an HTML `style` attribute).
808+
*/
809+
export type CssParserAs = "stylesheet" | "block-contents";
806810
/**
807811
* Enable/disable renaming of `@container` names.
808812
*/
@@ -3240,6 +3244,10 @@ export interface CssAutoOrModuleParserOptions {
32403244
* Enable/disable renaming of `@keyframes`.
32413245
*/
32423246
animation?: CssParserAnimation;
3247+
/**
3248+
* Configure how the CSS source is parsed: as a full stylesheet (default) or as a block's contents (e.g. the content of an HTML `style` attribute).
3249+
*/
3250+
as?: CssParserAs;
32433251
/**
32443252
* Enable/disable renaming of `@container` names.
32453253
*/
@@ -3343,6 +3351,10 @@ export interface CssModuleParserOptions {
33433351
* Enable/disable renaming of `@keyframes`.
33443352
*/
33453353
animation?: CssParserAnimation;
3354+
/**
3355+
* Configure how the CSS source is parsed: as a full stylesheet (default) or as a block's contents (e.g. the content of an HTML `style` attribute).
3356+
*/
3357+
as?: CssParserAs;
33463358
/**
33473359
* Enable/disable renaming of `@container` names.
33483360
*/
@@ -3384,6 +3396,10 @@ export interface CssModuleParserOptions {
33843396
* Parser options for css modules.
33853397
*/
33863398
export interface CssParserOptions {
3399+
/**
3400+
* Configure how the CSS source is parsed: as a full stylesheet (default) or as a block's contents (e.g. the content of an HTML `style` attribute).
3401+
*/
3402+
as?: CssParserAs;
33873403
/**
33883404
* Configure how CSS content is exported as default.
33893405
*/
@@ -3574,15 +3590,16 @@ export interface HtmlParserOptions {
35743590
*/
35753591
tag?: string;
35763592
/**
3577-
* How the attribute value should be parsed and bundled. `src` extracts a single URL as a plain asset; `srcset` parses a `srcset`-style list of candidate URLs as plain assets; `script` and `script-module` emit a classic / ES-module chunk entry like `<script src>` and `<script type="module" src>`; `stylesheet` emits a CSS chunk entry like `<link rel="stylesheet">`; `stylesheet-inline` treats the attribute value as inline CSS text and bundles it through the CSS pipeline (the attribute's content is replaced with the processed CSS at render time, like an inline `<style>` body).
3593+
* How the attribute value should be parsed and bundled. `src` extracts a single URL as a plain asset; `srcset` parses a `srcset`-style list of candidate URLs as plain assets; `script` and `script-module` emit a classic / ES-module chunk entry like `<script src>` and `<script type="module" src>`; `stylesheet` emits a CSS chunk entry like `<link rel="stylesheet">`; `stylesheet-style` treats the attribute value as a full stylesheet (like a `<style>` body) and `stylesheet-style-attribute` as a CSS block's contents (a declaration list, like a `style` attribute) — both bundle it through the CSS pipeline and replace the attribute's content with the processed CSS at render time.
35783594
*/
35793595
type:
35803596
| "src"
35813597
| "srcset"
35823598
| "script"
35833599
| "script-module"
35843600
| "stylesheet"
3585-
| "stylesheet-inline";
3601+
| "stylesheet-style"
3602+
| "stylesheet-style-attribute";
35863603
}
35873604
)[]
35883605
| boolean;

lib/config/defaults.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1300,6 +1300,16 @@ const applyModuleDefaults = (
13001300
},
13011301
resolve
13021302
});
1303+
// A `style="..."` attribute is a CSS block's contents, not a
1304+
// stylesheet, so parse it as one (`as: "block-contents"`).
1305+
rules.push({
1306+
dependency: "html-style-attribute",
1307+
parser: {
1308+
exportType: "text",
1309+
as: "block-contents"
1310+
},
1311+
resolve
1312+
});
13031313
}
13041314
}
13051315

lib/css/CssGenerator.js

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -908,14 +908,15 @@ class CssGenerator extends Generator {
908908
continue;
909909
}
910910

911-
// Inline `<style>` blocks in HTML modules read the merged CSS
912-
// text directly via the `css-text` source type — they don't go
913-
// through the JS-string wrapper that other consumers use.
914-
// Matched by dependency category so the CSS package doesn't
915-
// have to import HtmlInlineStyleDependency.
911+
// Inline `<style>` blocks and `style="..."` attributes in HTML
912+
// modules read the merged CSS text directly via the `css-text`
913+
// source type — they don't go through the JS-string wrapper that
914+
// other consumers use. Matched by dependency category so the CSS
915+
// package doesn't have to import HtmlInlineStyleDependency.
916916
if (
917917
connection.dependency &&
918-
connection.dependency.category === "html-style"
918+
(connection.dependency.category === "html-style" ||
919+
connection.dependency.category === "html-style-attribute")
919920
) {
920921
hasCssText = true;
921922
continue;

lib/css/CssParser.js

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,7 @@ class CssParser extends Parser {
745745
this.defaultMode =
746746
typeof options.defaultMode !== "undefined" ? options.defaultMode : "pure";
747747
this.options = {
748+
as: "stylesheet",
748749
url: true,
749750
import: true,
750751
namedExports: true,
@@ -3408,9 +3409,15 @@ class CssParser extends Parser {
34083409
}
34093410
}
34103411
};
3412+
// `as` selects the top-level production (§5.3): a `style` attribute is a
3413+
// block's contents, everything else a full stylesheet (the default).
34113414
new SourceProcessor()
34123415
.use(/** @type {VisitorMap} */ (visitors))
3413-
.process(source, { locConverter, comment });
3416+
.process(source, {
3417+
locConverter,
3418+
comment,
3419+
as: /** @type {"stylesheet" | "block-contents"} */ (this.options.as)
3420+
});
34143421

34153422
/** @type {BuildInfo} */
34163423
(module.buildInfo).strict = true;

lib/css/walkCssTokens.js

Lines changed: 45 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2101,10 +2101,15 @@ const declarationStartLikely = (ts) => {
21012101

21022102
/**
21032103
* Consume a block's contents, CSS Syntax Level 3 [§5.4.5](https://drafts.csswg.org/css-syntax/#consume-block-contents). Per tabatkins/parse-css.js reference impl: returns separate `decls` and `rules` flat lists, both preserved on EOF / `}` (the spec text's "Return rules" single-list model drops trailing decls because there's no implicit flush before EOF / `}`).
2104+
*
2105+
* `onNode` is the same streaming extension `consumeAStylesheetsContents` exposes:
2106+
* when given, each consumed declaration / rule is handed to it immediately (in
2107+
* source order) instead of being collected, so the returned lists are empty.
21042108
* @param {TokenStream} ts token stream
2105-
* @returns {{ decls: Declaration[], rules: Rule[] }} consumed decls + rules (stops at the enclosing `}` / EOF, left in the stream)
2109+
* @param {((node: Declaration | Rule) => void)=} onNode optional per-node sink (streaming); nodes are not collected when given
2110+
* @returns {{ decls: Declaration[], rules: Rule[] }} consumed decls + rules (both empty when `onNode` is given; stops at the enclosing `}` / EOF, left in the stream)
21062111
*/
2107-
const consumeABlocksContents = (ts) => {
2112+
const consumeABlocksContents = (ts, onNode) => {
21082113
/** @type {Declaration[]} */
21092114
const decls = [];
21102115
/** @type {Rule[]} */
@@ -2128,7 +2133,10 @@ const consumeABlocksContents = (ts) => {
21282133
// Consume an at-rule from input, with nested set to true. If a rule was returned, append it to rules.
21292134
else if (t.type === TT_AT_KEYWORD) {
21302135
const atRule = consumeAnAtRule(ts, true);
2131-
if (atRule) rules.push(atRule);
2136+
if (atRule) {
2137+
if (onNode) onNode(atRule);
2138+
else rules.push(atRule);
2139+
}
21322140
}
21332141
// anything else
21342142
// Mark input. Consume a declaration from input, with nested set to true.
@@ -2140,14 +2148,18 @@ const consumeABlocksContents = (ts) => {
21402148
ts.mark();
21412149
const decl = consumeADeclaration(ts, true);
21422150
if (decl) {
2143-
decls.push(decl);
2151+
if (onNode) onNode(decl);
2152+
else decls.push(decl);
21442153
ts.discardMark();
21452154
continue;
21462155
}
21472156
ts.restoreMark();
21482157
}
21492158
const rule = consumeAQualifiedRule(ts, TT_SEMICOLON, true);
2150-
if (rule) rules.push(rule);
2159+
if (rule) {
2160+
if (onNode) onNode(rule);
2161+
else rules.push(rule);
2162+
}
21512163
}
21522164
}
21532165
};
@@ -2599,12 +2611,31 @@ const unescapeIdentifier = makeCacheable(_unescapeIdentifier);
25992611
* @typedef {CompiledVisitorBucket[]} CompiledVisitorMap a sparse array indexed by node type
26002612
*/
26012613

2614+
/**
2615+
* A CSS Syntax §5.4 top-level consumer that streams each top-level node it
2616+
* produces to `onNode` (in source order) rather than collecting it. Every entry
2617+
* in `TOP_LEVEL_CONSUMERS` shares this shape, so the walk's `grammar` drives any
2618+
* `as` mode through one call — a future mode is just another map entry.
2619+
* @typedef {(ts: TokenStream, onNode: (node: Rule | Declaration) => void) => void} TopLevelConsumer
2620+
*/
2621+
2622+
/**
2623+
* `as` value → the §5.4 consumer that streams its top-level nodes. Keyed by the
2624+
* public `CssParserOptions.as` enum.
2625+
* @type {Record<string, TopLevelConsumer>}
2626+
*/
2627+
const TOP_LEVEL_CONSUMERS = {
2628+
stylesheet: /** @type {TopLevelConsumer} */ (consumeAStylesheetsContents),
2629+
"block-contents": consumeABlocksContents
2630+
};
2631+
26022632
/**
26032633
* @typedef {object} GrammarContext
26042634
* @property {CompiledVisitorMap} visitors compiled visitor map
26052635
* @property {LocConverter} locConverter shared loc converter
26062636
* @property {((input: string, start: number, end: number) => number)=} comment comment-token callback
26072637
* @property {boolean=} recurseBlocks walk into block bodies' nested rules (default true)
2638+
* @property {("stylesheet" | "block-contents")=} as which top-level production to consume the source as (see `TOP_LEVEL_CONSUMERS`): `"stylesheet"` (default) or `"block-contents"` (a block's contents, e.g. an HTML `style` attribute)
26082639
*/
26092640

26102641
/**
@@ -2709,13 +2740,13 @@ const grammar = (input, ctx) => {
27092740
}
27102741
};
27112742

2712-
// Consume a stylesheet's contents (§5.4.1) and walk each top-level rule the
2713-
// moment it's parsed (via the `onRule` sink) rather than collecting them
2714-
// first — so the whole stylesheet AST is never held at once; peak heap is
2715-
// ~one top-level rule's subtree, since each walked rule is unreferenced
2716-
// before the next is parsed.
2743+
// Stream each top-level node (selected by `as`) to the walker the moment it's
2744+
// consumed, rather than collecting them first — so the whole AST is never
2745+
// held at once; peak heap is ~one top-level node's subtree.
27172746
const ts = new TokenStream(input, 0, locConverter, comment);
2718-
consumeAStylesheetsContents(ts, (rule) => walkRule(rule, null));
2747+
const consume =
2748+
TOP_LEVEL_CONSUMERS[ctx.as || "stylesheet"] || consumeAStylesheetsContents;
2749+
consume(ts, (node) => walkRule(node, null));
27192750
};
27202751

27212752
/**
@@ -2764,15 +2795,16 @@ class SourceProcessor {
27642795
* Run the grammar over `input`, firing visitors in source order. No
27652796
* AST retained.
27662797
* @param {string} input source text
2767-
* @param {{ locConverter?: LocConverter, comment?: (input: string, start: number, end: number) => number, recurseBlocks?: boolean }=} ctx reuse a `locConverter`, forward a `comment` callback, or set `recurseBlocks: false` to stop at top-level rules
2798+
* @param {{ locConverter?: LocConverter, comment?: (input: string, start: number, end: number) => number, recurseBlocks?: boolean, as?: ("stylesheet" | "block-contents") }=} ctx reuse a `locConverter`, forward a `comment` callback, set `recurseBlocks: false` to stop at top-level rules, or set `as: "block-contents"` to parse a block's contents (e.g. an HTML `style` attribute) instead of a full stylesheet
27682799
*/
27692800
process(input, ctx = {}) {
27702801
const locConverter = ctx.locConverter || new LocConverter(input);
27712802
grammar(input, {
27722803
visitors: this._visitors,
27732804
locConverter,
27742805
comment: ctx.comment,
2775-
recurseBlocks: ctx.recurseBlocks
2806+
recurseBlocks: ctx.recurseBlocks,
2807+
as: ctx.as
27762808
});
27772809
}
27782810
}

lib/dependencies/HtmlInlineStyleDependency.js

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -22,30 +22,33 @@ const ModuleDependency = require("./ModuleDependency");
2222
/** @typedef {import("../util/runtime").RuntimeSpec} RuntimeSpec */
2323

2424
/**
25-
* Represents an inline `<style>...</style>` block in an HTML module. The
26-
* tag's content is fed into webpack's CSS pipeline as a virtual CSS module
27-
* with `exportType: "text"` so `url()` and `\@import` references are
28-
* resolved relative to the HTML file. At render time the original content
29-
* range is replaced with the processed CSS text read from the CSS module's
30-
* code generation data.
25+
* Represents inline CSS in an HTML module — either a `<style>...</style>`
26+
* block (a stylesheet) or an element's `style="..."` attribute (a CSS
27+
* block's contents). The content is fed into webpack's CSS pipeline as a
28+
* virtual CSS module with `exportType: "text"` so `url()` and `\@import`
29+
* references are resolved relative to the HTML file. At render time the
30+
* original content range is replaced with the processed CSS text read from
31+
* the CSS module's code generation data.
3132
*/
3233
class HtmlInlineStyleDependency extends ModuleDependency {
3334
/**
3435
* Creates an instance of HtmlInlineStyleDependency.
3536
* @param {string} request virtual request resolving to the inline CSS (data URI)
36-
* @param {Range} range range of the inline CSS content (between `<style>` and `</style>`)
37+
* @param {Range} range range of the inline CSS content (between `<style>` and `</style>`, or the `style` attribute value)
38+
* @param {boolean=} attribute true when the source is a `style="..."` attribute (a block's contents) rather than a `<style>` block (stylesheet)
3739
*/
38-
constructor(request, range) {
40+
constructor(request, range, attribute = false) {
3941
super(request);
4042
this.range = range;
43+
this.attribute = attribute;
4144
}
4245

4346
get type() {
4447
return "html inline style";
4548
}
4649

4750
get category() {
48-
return "html-style";
51+
return this.attribute ? "html-style-attribute" : "html-style";
4952
}
5053

5154
/**
@@ -68,6 +71,7 @@ class HtmlInlineStyleDependency extends ModuleDependency {
6871
*/
6972
serialize(context) {
7073
super.serialize(context);
74+
context.write(this.attribute);
7175
}
7276

7377
/**
@@ -76,6 +80,7 @@ class HtmlInlineStyleDependency extends ModuleDependency {
7680
*/
7781
deserialize(context) {
7882
super.deserialize(context);
83+
this.attribute = context.read();
7984
}
8085
}
8186

@@ -106,6 +111,10 @@ HtmlInlineStyleDependency.Template = class HtmlInlineStyleDependencyTemplate ext
106111
}
107112
}
108113

114+
// A `style` attribute is a single value — drop the trailing newline the
115+
// CSS generator appends so the rewritten attribute stays on one line.
116+
if (dep.attribute) cssText = cssText.replace(/\s+$/, "");
117+
109118
source.replace(dep.range[0], dep.range[1] - 1, cssText);
110119
}
111120
};

0 commit comments

Comments
 (0)