Source code
Revision control
Copy as Markdown
Other Tools
/*
Author Tobias Koppers @sokra
*/
"use strict";
const path = require("path");
const { CSS_MODULE_TYPE_AUTO } = require("../ModuleTypeConstants");
const Parser = require("../Parser");
const ConstDependency = require("../dependencies/ConstDependency");
const CssIcssExportDependency = require("../dependencies/CssIcssExportDependency");
const CssIcssImportDependency = require("../dependencies/CssIcssImportDependency");
const CssIcssSymbolDependency = require("../dependencies/CssIcssSymbolDependency");
const CssImportDependency = require("../dependencies/CssImportDependency");
const CssUrlDependency = require("../dependencies/CssUrlDependency");
const StaticExportsDependency = require("../dependencies/StaticExportsDependency");
const CommentCompilationWarning = require("../errors/CommentCompilationWarning");
const ModuleDependencyWarning = require("../errors/ModuleDependencyWarning");
const UnsupportedFeatureWarning = require("../errors/UnsupportedFeatureWarning");
const WebpackError = require("../errors/WebpackError");
const ResourceHintPlugin = require("../prefetch/ResourceHintPlugin");
const LocConverter = require("../util/LocConverter");
const { parseResource } = require("../util/identifier");
const {
createMagicCommentContext,
parseCommentOptionsInRange
} = require("../util/magicComment");
const topologicalSort = require("../util/topologicalSort");
const {
A,
NodeType,
SourceProcessor,
buildSkipSet,
equalsLowerCase,
isDashedIdentifier,
isWhitespace,
normalizeUrl,
rangeEquals,
rangeEqualsLowerCase,
toLowerCaseIfNeeded,
unescapeIdentifier
} = require("./syntax");
// `SourceProcessor` drives the parse and hands already-built AST nodes to the visitors; positions are read from those nodes' ranges rather than re-scanning the source.
/** @typedef {import("../Module").BuildInfo} BuildInfo */
/** @typedef {import("../Module").BuildMeta} BuildMeta */
/** @typedef {import("./CssModule").CssModuleBuildInfo} CssModuleBuildInfo */
/** @typedef {import("./CssModule").CssModuleBuildMeta} CssModuleBuildMeta */
/** @typedef {import("../Parser").ParserState} ParserState */
/** @typedef {import("../Parser").PreparsedAst} PreparsedAst */
/** @typedef {import("./syntax").AtRule} AtRule */
/** @typedef {import("./syntax").Declaration} Declaration */
/** @typedef {import("./syntax").FunctionNode} FunctionNode */
/** @typedef {import("./syntax").Node} AstNode */
/** @typedef {import("./syntax").QualifiedRule} QualifiedRule */
/** @typedef {import("./syntax").Rule} Rule */
/** @typedef {import("./syntax").SimpleBlock} SimpleBlock */
/** @typedef {import("./syntax").Token} Token */
/** @typedef {import("./syntax").UrlToken} UrlToken */
/** @typedef {import("./syntax").HashToken} HashToken */
/** @typedef {import("./syntax").VisitorMap} VisitorMap */
/** @typedef {import("../../declarations/WebpackOptions").CssAutoOrModuleParserOptions} CssAutoOrModuleParserOptions */
/** @typedef {import("../../declarations/WebpackOptions").CssModuleParserOptions} CssModuleParserOptions */
/** @typedef {import("./CssModule")} CssModule */
/** @typedef {import("./CssModule").Inheritance} Inheritance */
/** @typedef {[number, number]} Range */
/** @typedef {{ line: number, column: number }} Position */
/** @typedef {{ from: string, items: ({ localName: string, importName: string })[] }} ValueAtRuleImport */
/** @typedef {{ localName: string, value: string }} ValueAtRuleValue */
const CC_COLON = ":".charCodeAt(0);
const CC_HYPHEN_MINUS = "-".charCodeAt(0);
const CC_SEMICOLON = ";".charCodeAt(0);
const CC_TAB = "\t".charCodeAt(0);
const CC_SPACE = " ".charCodeAt(0);
const CC_LINE_FEED = "\n".charCodeAt(0);
const CC_CARRIAGE_RETURN = "\r".charCodeAt(0);
const CC_FORM_FEED = "\f".charCodeAt(0);
const CC_LEFT_CURLY = "{".charCodeAt(0);
const CC_LOWER_V = "v".charCodeAt(0);
const CC_UPPER_V = "V".charCodeAt(0);
// A parsed CSS comment. `loc` is computed on demand — only magic-comment error
// warnings read it, so comment-heavy CSS skips the per-comment line/col work.
// Comments are kept in a flat per-parse `comments` side array (not AST nodes); `loc` is derived lazily via `rangeLoc` only where needed (magic-comment errors).
/** @typedef {{ value: string, range: Range }} Comment */
// Newlines (CSS Syntax 3 §3.3) — listed explicitly since there's no preprocessing stage.
// Pure-mode markers: `cssmodules-pure-ignore` opts a single rule out of the purity check, `cssmodules-pure-no-check` (before the first rule) opts the whole file out.
const PURE_IGNORE_RE = /^\s*cssmodules-pure-ignore(?:\s|$)/;
const PURE_NO_CHECK_RE = /^\s*cssmodules-pure-no-check(?:\s|$)/;
const IMAGE_SET_FUNCTION = /^(?:-\w+-)?image-set$/i;
const OPTIONALLY_VENDOR_PREFIXED_KEYFRAMES_AT_RULE = /^@(?:-\w+-)?keyframes$/;
const VENDOR_PREFIX = /^-\w+-/;
const COMPOSES_PROPERTY = /^(?:composes|compose-with)$/i;
// Functional view-transition pseudo-elements whose `(<name> .class…)` argument names are scoped like `view-transition-name`/`-class` values.
const VIEW_TRANSITION_PART_PSEUDO =
/^view-transition-(?:group|image-pair|old|new)$/i;
const IS_MODULES = /\.modules?\.[^.]+$/i;
// `@font-face` src extension → preload `type`; the emission guesses `as="font"`.
const FONT_MIME_TYPES = new Map([
["woff2", "font/woff2"],
["woff", "font/woff"],
["ttf", "font/ttf"],
["otf", "font/otf"],
["eot", "application/vnd.ms-fontobject"]
]);
/**
* @param {string} request font url (may carry a query/hash)
* @returns {string | undefined} preload `type` for a known font extension
*/
const fontMimeType = (request) => {
const match = /\.([a-z0-9]+)(?:[?#]|$)/i.exec(request);
return match ? FONT_MIME_TYPES.get(match[1].toLowerCase()) : undefined;
};
// Skip options for a non-CSS-Modules parse: drop the selector prelude (never
// walked without modules) plus value / function-arg leaves nothing reads (the
// `Ident` visitor no-ops, the `Declaration` visitor returns early, no ICSS).
// `url` / functions / strings / blocks / commas are kept — they carry url()
// rewrites and image-set fences. At-rule preludes are kept (`@media` / `@import`
// are read). CSS-Modules parses skip nothing: selectors are walked and ICSS
// `:export { k: v }` captures each value's byte range from its first / last node.
const SKIP_NON_MODULES = {
types: buildSkipSet([
NodeType.Number,
NodeType.Dimension,
NodeType.Percentage,
NodeType.Ident,
NodeType.Hash,
NodeType.Colon,
NodeType.Delim,
// Nothing reads value/arg whitespace either — consumers use
// `nextNonWhitespace` / type checks that tolerate its absence.
NodeType.Whitespace
]),
selectorPrelude: true
};
// Like SKIP_NON_MODULES but keeps selector preludes and the `Colon` / `Ident`
// tokens inside function-arg lists, so `@custom-selector` `:--name` references
// (including nested ones like `:is(:--name)`) survive a non-modules parse.
const SKIP_NON_MODULES_KEEP_SELECTORS = {
types: buildSkipSet([
NodeType.Number,
NodeType.Dimension,
NodeType.Percentage,
NodeType.Hash,
NodeType.Delim,
NodeType.Whitespace
]),
selectorPrelude: false
};
const CSS_COMMENT = /\/\*((?!\*\/)[\s\S]*?)\*\//g;
// `@value` recognizers (postcss-modules-values shape): the import form `<names> from <source>`, and the `<importName> as <localName>` alias inside it.
const VALUE_IMPORT_FORM = /from(\/\*|\s)(?:[\s\S]+)$/i;
const VALUE_AS_ALIAS = /\s+as\s+/;
// `@value name value`: end of the name run (first non-space followed by space).
const VALUE_NAME_BOUNDARY = /\S\s/;
const ONLY_WHITESPACE = /^\s+$/;
// Relative request prefix (`./` or `../`) — `isSelfReferenceRequest` per `from`.
const RELATIVE_REQUEST = /^\.{1,2}\//;
/**
* Returns matches.
* @param {RegExp} regexp a regexp
* @param {string} str a string
* @returns {RegExpExecArray[]} matches
*/
const matchAll = (regexp, str) => {
/** @type {RegExpExecArray[]} */
const result = [];
/** @type {null | RegExpExecArray} */
let match;
while ((match = regexp.exec(str)) !== null) {
result.push(match);
}
return result;
};
/**
* Range-keyed index over a known-properties table: ASCII-case-folded 31-hash
* of the name bytes → canonical key(s). Lets the Declaration visitor answer
* "is this a known property" (and get the canonical lowercase name) without
* slicing the property name out of the source per declaration.
* @type {WeakMap<Map<string, Record<string, number>>, Map<number, string | string[]>>}
*/
const KNOWN_PROPERTY_INDEX_CACHE = new WeakMap();
/**
* Gets (or builds) the hash index for a known-properties table.
* @param {Map<string, Record<string, number>>} knownProperties known properties table
* @returns {Map<number, string | string[]>} hash → canonical name(s)
*/
const getKnownPropertyIndex = (knownProperties) => {
let index = KNOWN_PROPERTY_INDEX_CACHE.get(knownProperties);
if (index === undefined) {
index = new Map();
for (const name of knownProperties.keys()) {
let h = name.length;
for (let i = 0; i < name.length; i++) {
h = ((h << 5) - h + name.charCodeAt(i)) | 0;
}
const hit = index.get(h);
if (hit === undefined) index.set(h, name);
else if (typeof hit === "string") index.set(h, [hit, name]);
else hit.push(name);
}
KNOWN_PROPERTY_INDEX_CACHE.set(knownProperties, index);
}
return index;
};
/**
* Canonical known-property name for a source range (ASCII case-insensitive), without slicing.
* @param {Map<number, string | string[]>} index hash index from `getKnownPropertyIndex`
* @param {string} input source
* @param {number} start name start
* @param {number} end name end (exclusive)
* @returns {string | undefined} canonical lowercase name, or undefined when unknown
*/
const knownPropertyForRange = (index, input, start, end) => {
let h = end - start;
for (let i = start; i < end; i++) {
let c = input.charCodeAt(i);
if (c >= 65 && c <= 90) c |= 0x20;
h = ((h << 5) - h + c) | 0;
}
const hit = index.get(h);
if (hit === undefined) return undefined;
if (typeof hit === "string") {
return rangeEqualsLowerCase(input, start, end, hit) ? hit : undefined;
}
for (let i = 0; i < hit.length; i++) {
if (rangeEqualsLowerCase(input, start, end, hit[i])) return hit[i];
}
return undefined;
};
/** @type {Record<string, number>} */
const PREDEFINED_COUNTER_STYLES = {
decimal: 1,
"decimal-leading-zero": 1,
"arabic-indic": 1,
armenian: 1,
"upper-armenian": 1,
"lower-armenian": 1,
bengali: 1,
cambodian: 1,
khmer: 1,
"cjk-decimal": 1,
devanagari: 1,
georgian: 1,
gujarati: 1,
/* cspell:disable-next-line */
gurmukhi: 1,
hebrew: 1,
kannada: 1,
lao: 1,
malayalam: 1,
mongolian: 1,
myanmar: 1,
oriya: 1,
persian: 1,
"lower-roman": 1,
"upper-roman": 1,
tamil: 1,
telugu: 1,
thai: 1,
tibetan: 1,
"lower-alpha": 1,
"lower-latin": 1,
"upper-alpha": 1,
"upper-latin": 1,
"lower-greek": 1,
hiragana: 1,
/* cspell:disable-next-line */
"hiragana-iroha": 1,
katakana: 1,
/* cspell:disable-next-line */
"katakana-iroha": 1,
disc: 1,
circle: 1,
square: 1,
"disclosure-open": 1,
"disclosure-closed": 1,
"cjk-earthly-branch": 1,
"cjk-heavenly-stem": 1,
"japanese-informal": 1,
"japanese-formal": 1,
"korean-hangul-formal": 1,
/* cspell:disable-next-line */
"korean-hanja-informal": 1,
/* cspell:disable-next-line */
"korean-hanja-formal": 1,
"simp-chinese-informal": 1,
"simp-chinese-formal": 1,
"trad-chinese-informal": 1,
"trad-chinese-formal": 1,
"cjk-ideographic": 1,
"ethiopic-numeric": 1
};
/** @type {Record<string, number>} */
const GLOBAL_VALUES = {
// Global values
initial: Infinity,
inherit: Infinity,
unset: Infinity,
revert: Infinity,
"revert-layer": Infinity
};
/** @type {Record<string, number>} */
const GRID_AREA_OR_COLUMN_OR_ROW = {
auto: Infinity,
span: Infinity,
...GLOBAL_VALUES
};
/** @type {Record<string, number>} */
const GRID_AUTO_COLUMNS_OR_ROW = {
"min-content": Infinity,
"max-content": Infinity,
auto: Infinity,
...GLOBAL_VALUES
};
/** @type {Record<string, number>} */
const GRID_AUTO_FLOW = {
row: 1,
column: 1,
dense: 1,
...GLOBAL_VALUES
};
/** @type {Record<string, number>} */
const GRID_TEMPLATE_AREAS = {
// Special
none: 1,
...GLOBAL_VALUES
};
/** @type {Record<string, number>} */
const GRID_TEMPLATE_COLUMNS_OR_ROWS = {
none: 1,
subgrid: 1,
masonry: 1,
"max-content": Infinity,
"min-content": Infinity,
auto: Infinity,
...GLOBAL_VALUES
};
/** @type {Record<string, number>} */
const GRID_TEMPLATE = {
...GRID_TEMPLATE_AREAS,
...GRID_TEMPLATE_COLUMNS_OR_ROWS
};
/** @type {Record<string, number>} */
const GRID = {
"auto-flow": 1,
dense: 1,
...GRID_AUTO_COLUMNS_OR_ROW,
...GRID_AUTO_FLOW,
...GRID_TEMPLATE_AREAS,
...GRID_TEMPLATE_COLUMNS_OR_ROWS
};
/**
* Gets known properties.
* @param {{ animation?: boolean, container?: boolean, customIdents?: boolean, grid?: boolean }=} options options
* @returns {Map<string, Record<string, number>>} list of known properties
*/
const buildKnownProperties = (options = {}) => {
/** @type {Map<string, Record<string, number>>} */
const knownProperties = new Map();
if (options.animation) {
knownProperties.set("animation", {
// animation-direction
normal: 1,
reverse: 1,
alternate: 1,
"alternate-reverse": 1,
// animation-fill-mode
forwards: 1,
backwards: 1,
both: 1,
// animation-iteration-count
infinite: 1,
// animation-play-state
paused: 1,
running: 1,
// animation-timing-function
ease: 1,
"ease-in": 1,
"ease-out": 1,
"ease-in-out": 1,
linear: 1,
"step-end": 1,
"step-start": 1,
// Special
none: Infinity, // No matter how many times you write none, it will never be an animation name
...GLOBAL_VALUES
});
knownProperties.set("animation-name", {
// Special
none: Infinity, // No matter how many times you write none, it will never be an animation name
...GLOBAL_VALUES
});
}
if (options.container) {
knownProperties.set("container", {
// container-type
normal: 1,
size: 1,
"inline-size": 1,
"scroll-state": 1,
// Special
none: Infinity,
...GLOBAL_VALUES
});
knownProperties.set("container-name", {
// Special
none: Infinity,
...GLOBAL_VALUES
});
}
if (options.customIdents) {
knownProperties.set("list-style", {
// list-style-position
inside: 1,
outside: 1,
// list-style-type
...PREDEFINED_COUNTER_STYLES,
// Special
none: Infinity,
...GLOBAL_VALUES
});
knownProperties.set("list-style-type", {
// list-style-type
...PREDEFINED_COUNTER_STYLES,
// Special
none: Infinity,
...GLOBAL_VALUES
});
knownProperties.set("system", {
cyclic: 1,
numeric: 1,
alphabetic: 1,
symbolic: 1,
additive: 1,
fixed: 1,
extends: 1,
...PREDEFINED_COUNTER_STYLES
});
knownProperties.set("fallback", {
...PREDEFINED_COUNTER_STYLES
});
knownProperties.set("speak-as", {
auto: 1,
bullets: 1,
numbers: 1,
words: 1,
"spell-out": 1,
...PREDEFINED_COUNTER_STYLES
});
knownProperties.set("view-transition-name", {
none: Infinity,
auto: Infinity,
"match-element": Infinity,
...GLOBAL_VALUES
});
knownProperties.set("view-transition-group", {
normal: Infinity,
contain: Infinity,
nearest: Infinity,
...GLOBAL_VALUES
});
knownProperties.set("view-transition-class", {
none: Infinity,
...GLOBAL_VALUES
});
}
if (options.grid) {
knownProperties.set("grid", GRID);
knownProperties.set("grid-area", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-column", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-column-end", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-column-start", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-row", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-row-end", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-row-start", GRID_AREA_OR_COLUMN_OR_ROW);
knownProperties.set("grid-template", GRID_TEMPLATE);
knownProperties.set("grid-template-areas", GRID_TEMPLATE_AREAS);
knownProperties.set("grid-template-columns", GRID_TEMPLATE_COLUMNS_OR_ROWS);
knownProperties.set("grid-template-rows", GRID_TEMPLATE_COLUMNS_OR_ROWS);
}
return knownProperties;
};
/** @type {Map<number, Map<string, Record<string, number>>>} */
const KNOWN_PROPERTIES_CACHE = new Map();
/**
* Memoized {@link buildKnownProperties}: the table depends only on the four
* boolean options (≤16 combinations) and is read-only, while the same parser is
* reused across modules — so build each combination once and share it instead
* of rebuilding the Map (and its value objects) per parsed module.
* @param {{ animation?: boolean, container?: boolean, customIdents?: boolean, grid?: boolean }=} options options
* @returns {Map<string, Record<string, number>>} known properties table
*/
const getKnownProperties = (options = {}) => {
const key =
(options.animation ? 1 : 0) |
(options.container ? 2 : 0) |
(options.customIdents ? 4 : 0) |
(options.grid ? 8 : 0);
let table = KNOWN_PROPERTIES_CACHE.get(key);
if (table === undefined) {
table = buildKnownProperties(options);
KNOWN_PROPERTIES_CACHE.set(key, table);
}
return table;
};
// Byte-level source-cursor scans for computing replacement / strip ranges on raw source after parsing.
/**
* Skip trailing whitespace + at most one newline (CRLF-aware).
* @param {string} input source
* @param {number} pos position
* @returns {number} position past whitespace + one newline
*/
const skipWhiteLine = (input, pos) => {
for (;;) {
const cc = input.charCodeAt(pos);
if (cc === CC_SPACE || cc === CC_TAB) {
pos++;
continue;
}
if (
cc === CC_LINE_FEED ||
cc === CC_CARRIAGE_RETURN ||
cc === CC_FORM_FEED
) {
pos++;
}
// Treat CRLF as one newline: a CR followed by LF advances past the LF.
if (cc === CC_CARRIAGE_RETURN && input.charCodeAt(pos) === CC_LINE_FEED) {
pos++;
}
break;
}
return pos;
};
/**
* Whether the ident byte-range is a `@container` prelude keyword (`none`/`and`/`or`/`not`, lowercase only) — byte comparison avoids slicing a transient string per prelude ident.
* @param {string} input source
* @param {number} start start offset
* @param {number} end end offset
* @returns {boolean} true for a container keyword
*/
const isContainerKeyword = (input, start, end) => {
switch (end - start) {
case 2:
return input.startsWith("or", start);
case 3:
return input.startsWith("and", start) || input.startsWith("not", start);
case 4:
return input.startsWith("none", start);
default:
return false;
}
};
/**
* @param {string} input source
* @param {number} pos position
* @returns {number} position of the next `{`, or EOF if none
*/
const findLeftCurly = (input, pos) => {
while (pos < input.length) {
if (input.charCodeAt(pos) === CC_LEFT_CURLY) return pos;
pos++;
}
return pos;
};
/**
* Defines the css parser own options type used by this module.
* @typedef {object} CssParserOwnOptions
* @property {("pure" | "global" | "local" | "auto")=} defaultMode default mode
*/
/** @typedef {CssAutoOrModuleParserOptions & CssParserOwnOptions} CssParserOptions */
/**
* Pure-mode at-rules whose prelude is selector-checked, so their body is opaque to the enclosing rule's declaration accounting.
* @param {string} name at-rule name including the leading `@`, lower-cased
* @returns {boolean} true for `@keyframes` / `@counter-style` / `@container` / `@scope`
*/
const isPureBodyAtRule = (name) =>
OPTIONALLY_VENDOR_PREFIXED_KEYFRAMES_AT_RULE.test(name) ||
name === "@counter-style" ||
name === "@container" ||
name === "@scope";
/**
* Scan a rule body once: does it hold a direct declaration counted against the enclosing rule (a declaration, or one in a transparent conditional-group at-rule like `@media`/`@supports`/…) and does it hold a nested block (qualified rule or any block-bearing at-rule)?
* @param {Declaration[] | null} declarations rule-body declarations
* @param {Rule[] | null} childRules rule-body child rules
* @returns {{ hasDirectDecl: boolean, hasNestedBlock: boolean }} scan result
*/
const scanRuleBody = (declarations, childRules) => {
let hasDirectDecl = Boolean(declarations && declarations.length > 0);
let hasNestedBlock = false;
if (childRules) {
for (const child of childRules) {
const t = A.type(child);
if (t === NodeType.QualifiedRule) {
hasNestedBlock = true;
} else if (t === NodeType.AtRule) {
const atDecls = A.declarations(child);
const atChildRules = A.childRules(child);
if (!atDecls && !atChildRules) continue;
hasNestedBlock = true;
if (
!hasDirectDecl &&
!isPureBodyAtRule(`@${toLowerCaseIfNeeded(A.name(child))}`) &&
scanRuleBody(atDecls, atChildRules).hasDirectDecl
) {
hasDirectDecl = true;
}
}
}
}
return { hasDirectDecl, hasNestedBlock };
};
/**
* Parses value at rule params.
* @param {string} str value at-rule params
* @returns {ValueAtRuleImport | ValueAtRuleValue} parsed result
*/
const parseValueAtRuleParams = (str) => {
if (VALUE_IMPORT_FORM.test(str)) {
str = str.replace(CSS_COMMENT, " ").trim().replace(/;$/, "");
const fromIdx = str.lastIndexOf("from");
const path = str
.slice(fromIdx + 5)
.trim()
.replace(/['"]/g, "");
let content = str.slice(0, fromIdx).trim();
if (content.startsWith("(") && content.endsWith(")")) {
content = content.slice(1, -1);
}
return {
from: path,
items: content.split(",").map((item) => {
item = item.trim();
if (item.includes(":")) {
const [local, remote] = item.split(":");
return { localName: local.trim(), importName: remote.trim() };
}
const asParts = item.split(VALUE_AS_ALIAS);
if (asParts.length === 2) {
return {
localName: asParts[1].trim(),
importName: asParts[0].trim()
};
}
return { localName: item, importName: item };
})
};
}
/** @type {string} */
let localName;
/** @type {string} */
let value;
const idx = str.indexOf(":");
if (idx !== -1) {
localName = str.slice(0, idx).replace(CSS_COMMENT, "").trim();
value = str.slice(idx + 1);
} else {
const mask = str.replace(CSS_COMMENT, (m) => " ".repeat(m.length));
const idx = mask.search(VALUE_NAME_BOUNDARY) + 1;
localName = str.slice(0, idx).replace(CSS_COMMENT, "").trim();
value = str.slice(idx + (str[idx] === " " ? 1 : 0));
}
if (
value.length > 0 &&
!ONLY_WHITESPACE.test(value.replace(CSS_COMMENT, ""))
) {
value = value.trim();
}
return { localName, value };
};
/**
* Index of the next non-whitespace child at or after `from`, or `nodes.length`.
* @param {AstNode[]} nodes node list
* @param {number} from start index (inclusive)
* @returns {number} index of the next non-whitespace node
*/
const nextNonWhitespace = (nodes, from) => {
let i = from;
while (i < nodes.length && A.type(nodes[i]) === NodeType.Whitespace) i++;
return i;
};
/** @typedef {{ urlNode: (AstNode | undefined), layerNode: (AstNode | undefined), supportsNode: (FunctionNode | undefined) }} ImportPrelude */
/**
* Scan an `@import` prelude in spec order — URL → `layer` / `layer(…)`? → `supports(…)`? — stopping at the first media-query token (the caller slices the media query out separately).
* @param {AstNode[]} prelude the at-rule prelude nodes
* @returns {ImportPrelude} the recognized prefix parts (any may be undefined)
*/
const parseImportPrelude = (prelude) => {
/** @type {AstNode | undefined} */
let urlNode;
/** @type {AstNode | undefined} */
let layerNode;
/** @type {FunctionNode | undefined} */
let supportsNode;
for (const cv of prelude) {
const t = A.type(cv);
if (t === NodeType.Whitespace) continue;
if (!urlNode) {
if (t === NodeType.Url || t === NodeType.String) {
urlNode = cv;
continue;
}
if (
t === NodeType.Function &&
equalsLowerCase(A.unescapedName(cv), "url")
) {
urlNode = cv;
continue;
}
if (t === NodeType.Ident) {
// CSS Modules: bare ident is a `@value` reference.
urlNode = cv;
continue;
}
break;
}
if (!layerNode && !supportsNode) {
if (t === NodeType.Ident) {
if (equalsLowerCase(A.unescaped(cv), "layer")) {
layerNode = cv;
continue;
}
} else if (
t === NodeType.Function &&
equalsLowerCase(A.unescapedName(cv), "layer")
) {
layerNode = cv;
continue;
}
}
if (
!supportsNode &&
t === NodeType.Function &&
equalsLowerCase(A.unescapedName(cv), "supports")
) {
supportsNode = /** @type {FunctionNode} */ (cv);
continue;
}
// First media-query token — stop scanning for the prefix.
break;
}
return { urlNode, layerNode, supportsNode };
};
/**
* Recognize the request of an ICSS `:import("path")` prelude — the `import(…)` function's args, or the spaced `:import (…)` simple block. Pure — the caller emits the "expected string" warning from `errorPos`.
* @param {AstNode} second the `import(…)` function / first prelude node after the `:`
* @param {QualifiedRule} rule the `:import` rule
* @param {string} source full CSS source, for the path slice
* @returns {{ request: string } | { errorPos: number }} the unquoted request, or the position for the parse warning
*/
const parseIcssImportRequest = (second, rule, source) => {
/** @type {AstNode[] | undefined} */
let args;
if (A.type(second) === NodeType.Function) {
args = A.children(second);
} else {
for (const p of A.prelude(rule)) {
if (A.type(p) === NodeType.SimpleBlock && A.blockToken(p) === "(") {
args = A.children(p);
break;
}
}
}
// The first non-whitespace value inside `(...)` must be a string.
const innerStrToken =
args && args.find((v) => A.type(v) !== NodeType.Whitespace);
if (!innerStrToken || A.type(innerStrToken) !== NodeType.String) {
const errorPos =
A.type(second) === NodeType.Function
? A.nameEnd(second) + 1
: A.end(second);
return { errorPos };
}
return {
request: source.slice(A.start(innerStrToken) + 1, A.end(innerStrToken) - 1)
};
};
class CssParser extends Parser {
/**
* Creates an instance of CssParser.
* @param {CssParserOptions=} options options
*/
constructor(options = {}) {
super();
this.defaultMode =
typeof options.defaultMode !== "undefined" ? options.defaultMode : "pure";
this.options = {
as: "stylesheet",
url: true,
import: true,
namedExports: true,
animation: true,
container: true,
customIdents: true,
customMedia: true,
customSelectors: true,
dashedIdents: true,
function: true,
grid: true,
...options
};
this.magicCommentContext = createMagicCommentContext();
}
/**
* Processes the provided state.
* @param {ParserState} state parser state
* @param {string} message warning message
* @param {LocConverter} locConverter location converter
* @param {number} start start offset
* @param {number} end end offset
*/
_emitWarning(state, message, locConverter, start, end) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
state.current.addWarning(
new ModuleDependencyWarning(state.module, new WebpackError(message), {
start: { line: sl, column: sc },
end: { line: el, column: ec }
})
);
}
/**
* Emits a build error for the provided range.
* @param {ParserState} state parser state
* @param {string} message error message
* @param {LocConverter} locConverter location converter
* @param {number} start start offset
* @param {number} end end offset
*/
_emitError(state, message, locConverter, start, end) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
const err = new WebpackError(message);
err.module = state.module;
err.loc = {
start: { line: sl, column: sc },
end: { line: el, column: ec }
};
state.module.addError(err);
}
/**
* Parses the provided source and updates the parser state.
* @param {string | Buffer | PreparsedAst} source the source to parse
* @param {ParserState} state the parser state
* @returns {ParserState} the parser state
*/
parse(source, state) {
if (Buffer.isBuffer(source)) {
source = source.toString("utf8");
} else if (typeof source === "object") {
throw new Error("webpackAst is unexpected for the CssParser");
}
if (source[0] === "\uFEFF") {
source = source.slice(1);
}
// Per-parse comment side-array — kept local (like HtmlParser) so nothing is retained on the reused parser instance between modules.
/** @type {Comment[]} */
const comments = [];
const urlHints = this.options.urlHints;
const fontPreload = this.options.fontPreload;
/**
* Apply `parser.css.urlHints` defaults + `webpackPrefetch` /
* `webpackPreload` / `webpackFetchPriority` / `webpackAs` /
* `webpackType` / `webpackMedia` magic-comment overrides to a fresh
* `CssUrlDependency`. Magic comments win over the project-wide default.
* @param {CssUrlDependency} dep dep
* @param {string} request asset request
* @param {Record<string, EXPECTED_ANY> | null | undefined} options parsed comment options
* @param {import("../Dependency").DependencyLocation} loc location for warnings
* @returns {void}
*/
const applyResourceHintDefaults = (dep, request, options, loc) => {
// `fontPreload` heuristic: seed `preload`/`as`/`type` for the first url
// of each `@font-face` (the nearest enclosing at-rule) as the lowest
// default, so `urlHints` rules and magic comments below still override.
if (fontPreload && atRuleStateStack.length > 0) {
const top = atRuleStateStack[atRuleStateStack.length - 1];
if (top.name === "@font-face" && !top.fontPreloaded) {
top.fontPreloaded = true;
dep.preload = true;
dep.asAttribute = "font";
const type = fontMimeType(request);
if (type) dep.typeAttribute = type;
}
}
ResourceHintPlugin.applyResourceHints(
dep,
urlHints,
request,
options,
state.module,
loc
);
};
const module = state.module;
// All `:export`-style exports are collected into a single
// `CssIcssExportDependency` per module (emitted at parse end) instead of one
// `Dependency` per export — far fewer retained objects on CSS-heavy builds.
/** @type {import("../dependencies/CssIcssExportDependency").CssExportEntry[]} */
const cssExportEntries = [];
/**
* @param {number} sl start line
* @param {number} sc start column
* @param {number} el end line
* @param {number} ec end column
* @param {string} name export name
* @param {import("../dependencies/CssIcssExportDependency").Value} value value or [localName, importName, request?]
* @param {Range=} range source range to replace, when present
* @param {boolean=} interpolate whether the value needs interpolation
* @param {import("../dependencies/CssIcssExportDependency").ExportMode=} exportMode export mode
* @param {import("../dependencies/CssIcssExportDependency").ExportType=} exportType export type
* @returns {void}
*/
const addCssExport = (
sl,
sc,
el,
ec,
name,
value,
range,
interpolate = false,
exportMode = CssIcssExportDependency.EXPORT_MODE.REPLACE,
exportType = CssIcssExportDependency.EXPORT_TYPE.NORMAL
) => {
// Flat location numbers — the nested loc objects were the parser's
// hottest allocation (3 objects per exported name).
cssExportEntries.push({
name,
value,
range,
interpolate,
exportMode,
exportType,
locStartLine: sl,
locStartColumn: sc,
locEndLine: el,
locEndColumn: ec
});
};
const parsedModuleResource = parseResource(
/** @type {string} */ (module.getResource())
);
const mode =
this.defaultMode === "auto" &&
module.type === CSS_MODULE_TYPE_AUTO &&
IS_MODULES.test(parsedModuleResource.path)
? "local"
: this.defaultMode;
const isModules = mode === "global" || mode === "local";
/** @type {Map<string, boolean>} */
const selfReferenceCache = new Map();
/**
* Whether a relative `from "<request>"` resolves back to the current module (matching query/fragment too).
* Memoized per parse — `composes … from "./x.css"` repeats the same request many times per file.
* @param {string} request request string from `from "<request>"`
* @returns {boolean} true if request resolves to the current module
*/
const isSelfReferenceRequest = (request) => {
const cached = selfReferenceCache.get(request);
if (cached !== undefined) return cached;
const result = isSelfReferenceRequestUncached(request);
selfReferenceCache.set(request, result);
return result;
};
/**
* Uncached `isSelfReferenceRequest`.
* @param {string} request request string from `from "<request>"`
* @returns {boolean} true if request resolves to the current module
*/
const isSelfReferenceRequestUncached = (request) => {
if (!RELATIVE_REQUEST.test(request)) return false;
if (!module.context) return false;
const parsedRequest = parseResource(request);
if (parsedRequest.query !== parsedModuleResource.query) return false;
if (parsedRequest.fragment !== parsedModuleResource.fragment) {
return false;
}
try {
return (
path.resolve(module.context, parsedRequest.path) ===
parsedModuleResource.path
);
} catch (_err) {
return false;
}
};
const knownProperties = getKnownProperties({
animation: this.options.animation,
container: this.options.container,
customIdents: this.options.customIdents,
grid: this.options.grid
});
const knownPropertyIndex = getKnownPropertyIndex(knownProperties);
/** @type {CssModuleBuildMeta} */
(module.buildMeta).isCssModule = isModules;
if (/** @type {CssModule} */ (module).exportType === "style") {
/** @type {CssModuleBuildMeta} */
(module.buildMeta).needIdInConcatenation = true;
}
const locConverter = new LocConverter(source);
/**
* Source location of a byte range. `LocConverter#get` mutates and returns itself, so snapshot between the two calls.
* @param {number} start start offset
* @param {number} end end offset
* @returns {{ start: Position, end: Position }} the source location
*/
const rangeLoc = (start, end) => {
const s = locConverter.get(start);
const sl = s.line;
const sc = s.column;
const e = locConverter.get(end);
return {
start: { line: sl, column: sc },
end: { line: e.line, column: e.column }
};
};
/**
* Set a dependency's source location from a byte range.
* @param {ConstDependency | CssUrlDependency | CssImportDependency | CssIcssImportDependency | CssIcssSymbolDependency} dep dependency with `setLoc`
* @param {number} start start offset
* @param {number} end end offset
*/
const setDepLoc = (dep, start, end) => {
const s = locConverter.get(start);
const sl = s.line;
const sc = s.column;
const e = locConverter.get(end);
dep.setLoc(sl, sc, e.line, e.column);
};
/**
* Apply the magic comments in `range`: warn on any compilation error, validate `webpackIgnore`, and return both the parsed options (for resource-hint / other magic-comment consumers) and whether the resource is ignored.
* @param {Range} range byte range to scan for magic comments
* @param {number} warnStart start offset of the loc for an invalid-`webpackIgnore` warning (computed lazily)
* @param {number} warnEnd end offset of that loc
* @returns {{ ignored: boolean, options: Record<string, EXPECTED_ANY> | null }} parsed options and `webpackIgnore` flag
*/
const magicCommentsIn = (range, warnStart, warnEnd) => {
/** @type {{ options: Record<string, EXPECTED_ANY> | null, errors: (Error & { comment: Comment })[] | null }} */
const { options, errors } = parseCommentOptionsInRange(
/** @type {(Comment & { range: [number, number], value: string })[]} */ (
comments
),
range,
this.magicCommentContext
);
if (errors) {
for (const e of errors) {
state.module.addWarning(
new CommentCompilationWarning(
`Compilation error while processing magic comment(-s): /*${e.comment.value}*/: ${e.message}`,
rangeLoc(e.comment.range[0], e.comment.range[1])
)
);
}
}
let ignored = false;
if (options && options.webpackIgnore !== undefined) {
if (typeof options.webpackIgnore !== "boolean") {
// Loc is computed lazily here — it's only needed for this rare
// warning, not on every checked `url()` / `@import`.
state.module.addWarning(
new UnsupportedFeatureWarning(
`\`webpackIgnore\` expected a boolean, but received: ${options.webpackIgnore}.`,
rangeLoc(warnStart, warnEnd)
)
);
} else {
ignored = options.webpackIgnore;
}
}
return { ignored, options };
};
/**
* Backwards-compatible boolean shortcut of {@link magicCommentsIn} for call sites that only need the ignore flag.
* @param {Range} range byte range to scan for magic comments
* @param {number} warnStart start offset of the loc for an invalid-`webpackIgnore` warning
* @param {number} warnEnd end offset of that loc
* @returns {boolean} true when `webpackIgnore: true`
*/
const webpackIgnored = (range, warnStart, warnEnd) =>
magicCommentsIn(range, warnStart, warnEnd).ignored;
// Closure-scope alias for `source` used by AST-walking helpers for substring extraction.
const input = source;
// `@custom-media` / `@custom-selector` are build-time only (no engine ships them), so they're resolved by file-local substitution. The `includes` gates keep files without them at zero overhead; definitions may follow their uses (names are stylesheet-global), so uses are collected during the walk and resolved after it.
const mayHaveCustomMedia =
this.options.customMedia && input.includes("@custom-media");
const mayHaveCustomSelectors =
this.options.customSelectors && input.includes("@custom-selector");
/** @type {Map<string, { text: string, kind: ("condition" | "type" | "unsupported") }> | undefined} */
let customMediaDefs;
/** @type {{ name: string, start: number, end: number, invalid: boolean, leading: boolean }[] | undefined} */
let customMediaUses;
/** @type {Map<string, string> | undefined} */
let customSelectorDefs;
/** @type {{ name: string, start: number, end: number }[] | undefined} */
let customSelectorUses;
/**
* Unescape a CSS identifier from a source byte range — for value spans not
* backed by a single token (string contents, `--` dashed-ident bodies,
* composed names). Token-backed names use `A.unescaped(node)` instead.
* @param {number} start start offset
* @param {number} end end offset
* @returns {string} the unescaped identifier
*/
const unescapeRange = (start, end) =>
unescapeIdentifier(input.slice(start, end));
let lastTokenEndForComments = 0;
/** Generates unique `__ICSS_IMPORT_${n}__` placeholders per ICSS import. */
const nextIcssImportName = (() => {
let n = 0;
return () => `__ICSS_IMPORT_${n++}__`;
})();
// All pure-mode state and helpers live on `pure`. When `pure.enabled` is false, the methods are no-ops, so callers can use them unconditionally.
const pure = {
enabled: isModules && Boolean(this.options.pure),
/** Whether the current rule's prelude has so far seen any impure comma-separated selector (set by `finalizeSelector`). */
ruleImpure: false,
/** Whether the current comma-separated selector has carried a local class / id (cleared by `finalizeSelector`). */
segmentLocal: false,
/** File-level kill switch from a top-of-file `cssmodules-pure-no-check` comment. */
noCheck: false,
/** Single-shot ignore from a `cssmodules-pure-ignore` comment — consumed by the next rule frame. */
ignorePending: false,
/** Has any top-level rule been processed (locks `noCheck`)? */
seenTopLevelRule: false,
/**
* Inherited per open block: `ancestorHadLocal` (nested rules inherit purity from a local-bearing ancestor) and `skipChildren` (a check-suppressing ancestor like `@keyframes`).
* @type {{ ancestorHadLocal: boolean, skipChildren: boolean }[]}
*/
stack: [],
/**
* Whether any ancestor (self inclusive) was pure — for ancestor-inheritance and `&`-resolution.
* @returns {boolean} true if any ancestor provided a local
*/
ancestorHadLocal() {
const top = this.stack[this.stack.length - 1];
return top ? top.ancestorHadLocal : false;
},
/**
* Record that the current comma-separated selector carries a local class / id (no-op when pure-mode is off).
*/
markLocal() {
if (this.enabled) this.segmentLocal = true;
},
/**
* Close the current comma-separated selector segment (or whole prelude at `{`): if it had no local and no ancestor compensates, the rule is impure (no-op when pure-mode is off).
*/
finalizeSelector() {
if (!this.enabled) return;
if (!this.segmentLocal && !this.ancestorHadLocal()) {
this.ruleImpure = true;
}
this.segmentLocal = false;
},
/**
* Mark that a top-level rule has been processed; locks `noCheck` (no-op when pure-mode is off).
*/
markSeenTopLevelRule() {
if (this.enabled) this.seenTopLevelRule = true;
},
/**
* Report a pure-mode violation covering the entire rule prelude.
* @param {number} start prelude start offset
* @param {number} end prelude end offset (`{` position)
*/
report: (start, end) => {
const slice = source.slice(start, end);
const lead = /** @type {RegExpExecArray} */ (
/^(?:\s|\/\*[\s\S]*?\*\/)*/.exec(slice)
)[0].length;
const trail = /** @type {RegExpExecArray} */ (/\s*$/.exec(slice))[0]
.length;
const from = start + lead;
const to = end - trail;
if (to <= from) return;
this._emitError(
state,
`Selector "${source.slice(
from,
to
)}" is not pure (pure selectors must contain at least one local class or id)`,
locConverter,
from,
to
);
},
/**
* Rule entry: report an impure leaf-ish rule (prelude purity is known, body already parsed), push the inherited-context frame, reset per-rule selector flags.
* @param {{ isRulePrelude: boolean, treatAsLeaf: boolean, ownSkip: boolean, declarations: Declaration[] | null, childRules: Rule[] | null, preludeStart: number, preludeEnd: number }} opts entry options
*/
enterBlock(opts) {
if (!this.enabled) return;
const {
isRulePrelude,
treatAsLeaf,
ownSkip,
declarations,
childRules,
preludeStart,
preludeEnd
} = opts;
const top = this.stack[this.stack.length - 1];
const skipOwn = top ? top.skipChildren : false;
const reportable =
!this.noCheck &&
!this.ignorePending &&
!skipOwn &&
isRulePrelude &&
this.ruleImpure;
if (reportable) {
const hasBody = Boolean(declarations || childRules);
let leaf = treatAsLeaf || !hasBody;
if (!leaf && hasBody) {
const { hasDirectDecl, hasNestedBlock } = scanRuleBody(
declarations,
childRules
);
leaf = hasDirectDecl || !hasNestedBlock;
}
if (leaf) this.report(preludeStart, preludeEnd);
}
this.stack.push({
ancestorHadLocal:
this.ancestorHadLocal() || (isRulePrelude && !this.ruleImpure),
skipChildren: ownSkip || skipOwn
});
this.ignorePending = false;
this.segmentLocal = false;
this.ruleImpure = false;
},
/**
* Drop the inherited-context frame (no-op when pure-mode is off).
*/
exitBlock() {
if (this.enabled) this.stack.pop();
},
/**
* Apply a comment's pure-mode side effect: `ignorePending` for `cssmodules-pure-ignore`, or the file-level `noCheck` for `cssmodules-pure-no-check` before the first top-level rule.
* @param {string} value comment body (without the surrounding delimiters)
*/
applyComment(value) {
if (PURE_IGNORE_RE.test(value)) {
this.ignorePending = true;
} else if (PURE_NO_CHECK_RE.test(value) && !this.seenTopLevelRule) {
this.noCheck = true;
}
}
};
/** @typedef {{ value?: string, importName?: string, localName?: string, request?: string }} IcssDefinition */
/** @type {Map<string, IcssDefinition>} */
const icssDefinitions = new Map();
// `composes: … from "<file>"` load-order graph (postcss-modules-extract-imports#138); topologically sorted at end-of-parse to tag each file's first composes-import with `sourceOrder`.
/** @type {Map<string, Set<string>>} */
const composesGraph = new Map();
/** @type {Map<string, CssIcssImportDependency>} */
const composesFirstFileImport = new Map();
// Per-rule CSS-Modules state, saved on the rule's state stack at enter and restored at exit. `composesPrevFile` / `composesFiles` are only meaningful inside qualified rules (composes can't appear in at-rule preludes).
const currentRule = {
/** Did this rule's prelude declare a local-mode anchor selector? */
hasLocalAnchor: false,
/** Local class / id names in source order (composes reads `[0]` as the anchor). */
/** @type {string[]} */
localIdentifiers: [],
/** Previous `composes: … from "…"` file in this rule (for the load-order graph edges). */
/** @type {string | undefined} */
composesPrevFile: undefined,
/** All files this rule has composed from (so an edge is added only once per file pair); lazily created — null until the rule's first `composes: … from`. */
/** @type {Set<string> | null} */
composesFiles: null
};
/**
* Whether the module's default mode is local (callers here have no `:local`/`:global` wrapper in scope, so it reduces to the default mode).
* @returns {boolean} true when the module's default mode is `local`
*/
const isLocalMode = () => mode === "local";
/**
* Effective local mode: persistent `:local`/`:global` from `modeData` if any, else the module's default.
* @returns {boolean} true when the effective mode is local
*/
const isEffectivelyLocal = () =>
modeData ? modeData === "local" : mode === "local";
/**
* Comment visitor (`NodeType.Comment`): push every comment (in source order) onto the local `comments`, read back by `advanceCommentCursor` (pure-mode flags) and `parseCommentOptionsInRange` (magic comments).
* @param {import("./syntax").CssPath} path walk path at the comment node
*/
const commentVisitor = (path) => {
const node = path.node;
const start = A.start(node);
const end = A.end(node);
comments.push({
value: source.slice(start + 2, end - 2),
range: [start, end]
});
};
/**
* Advance past every comment closing at/before `until` (in source order) and apply its pure-mode side effect: `pure.ignorePending` (next rule) or the file-level `pure.noCheck` (only before the first top-level rule). The cursor is closed over so it isn't visible at parser scope.
* @returns {(until: number) => void} the cursor-advance function
*/
const advanceCommentCursor = (() => {
let cursor = 0;
/** @param {number} until source position to advance the cursor to */
return (until) => {
if (!pure.enabled) return;
while (cursor < comments.length) {
const c = comments[cursor];
if (c.range[1] > until) return;
pure.applyComment(c.value);
cursor++;
}
};
})();
// CSS modules stuff
/**
* Returns resolved reexport (localName and importName).
* @param {string} value value to resolve
* @param {string=} localName override local name
* @param {boolean=} isCustomProperty true when it is custom property, otherwise false
* @returns {string | [string, string] | [string, string, string]} resolved reexport (`localName`, `importName` and optional `request` of the active `@value` import)
*/
const getReexport = (value, localName, isCustomProperty) => {
// No `@value` / `:import` / composes definitions: skip the `--` key
// concat + map probe (the common case for plain CSS-Modules files).
const reexport =
icssDefinitions.size === 0
? undefined
: icssDefinitions.get(isCustomProperty ? `--${value}` : value);
if (reexport) {
if (reexport.importName) {
const resolvedLocalName =
reexport.localName || (isCustomProperty ? `--${value}` : value);
return reexport.request
? [resolvedLocalName, reexport.importName, reexport.request]
: [resolvedLocalName, reexport.importName];
}
if (isCustomProperty) {
return /** @type {string} */ (reexport.value).slice(2);
}
return /** @type {string} */ (reexport.value);
}
if (localName) {
return [localName, value];
}
return value;
};
/**
* Process import or export, reusing the already-parsed rule nodes.
* @param {0 | 1} type import or export
* @param {AstNode} second the `import(…)` function / `export` ident node from the prelude
* @param {QualifiedRule} rule the `:import` / `:export` qualified rule
* @returns {number} position after parse
*/
const processImportOrExport = (type, second, rule) => {
/** @type {string | undefined} */
let request;
if (type === 0) {
const parsed = parseIcssImportRequest(second, rule, source);
if ("errorPos" in parsed) {
const { errorPos } = parsed;
this._emitWarning(
state,
`Unexpected '${source[errorPos]}' at ${errorPos} during parsing of ':import' (expected string)`,
locConverter,
errorPos,
errorPos
);
return errorPos;
}
request = parsed.request;
}
/**
* Creates a dep from the provided name.
* @param {string} name name
* @param {string} value value
* @param {number} start start of position
* @param {number} end end of position
*/
const createDep = (name, value, start, end) => {
if (type === 0) {
const dep = new CssIcssImportDependency(
/** @type {string} */
(request),
[0, 0],
/** @type {"local" | "global"} */
(mode),
value,
name
);
setDepLoc(dep, start, end);
module.addDependency(dep);
icssDefinitions.set(name, {
importName: value,
request: /** @type {string} */ (request)
});
} else if (type === 1) {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
addCssExport(sl, sc, el, ec, name, getReexport(value));
}
};
// Body `{ name: value; … }` is parsed eagerly (§5.4.4) — emit a dep per declaration.
const ruleDecls = A.declarations(rule);
if (!ruleDecls || A.blockStart(rule) === -1) return A.end(second);
for (const decl of ruleDecls) {
const vals = A.children(decl);
if (vals.length === 0) continue;
const rawStart = A.start(vals[0]);
const rawEnd = A.end(vals[vals.length - 1]);
createDep(
source.slice(A.nameStart(decl), A.nameEnd(decl)),
source.slice(rawStart, rawEnd),
A.nameEnd(decl),
rawEnd
);
}
return skipWhiteLine(source, A.blockEnd(rule));
};
/**
* Emit a `CssIcssSymbolDependency` rewrite for an ident resolving to an `@value`-defined ICSS symbol (source-order semantics hold since the walker handles each `@value` before later references).
* @param {string} name ICSS symbol name
* @param {number} start start position
* @param {number} end end position
* @returns {number} `end` of the rewritten ident range
*/
const emitICSSSymbol = (name, start, end) => {
const def =
/** @type {IcssDefinition} */
(icssDefinitions.get(name));
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
const dep = new CssIcssSymbolDependency(
def.localName || name,
[start, end],
def.value,
def.importName,
def.request
);
dep.setLoc(sl, sc, el, ec);
module.addDependency(dep);
return end;
};
/**
* Process a `local(...)` / `global(...)` pseudo-function: strip the call (and a leading legacy `:`) via a presentational dep, then emit `local()`'s inner top-level idents as ICSS exports.
* @param {FunctionNode} fn parsed local/global function node
* @param {1 | 2} type 1 = local, 2 = global
*/
const processLocalOrGlobalFunction = (fn, type) => {
// Replace `local(` / `global(` (and a leading `:` for the `:local(`/`:global(` selector form) with empty.
const fnStart = A.start(fn);
const isColon = input.charCodeAt(fnStart - 1) === CC_COLON;
const openEnd = A.nameEnd(fn) + 1;
module.addPresentationalDependency(
new ConstDependency("", [isColon ? fnStart - 1 : fnStart, openEnd])
);
if (type === 1) {
for (const cv of A.children(fn)) {
if (A.type(cv) !== NodeType.Ident) continue;
let identifier = A.unescaped(cv);
// Cursor reads instead of `A.loc` — no location objects allocated.
const { line: sl, column: sc } = locConverter.get(A.start(cv));
const { line: el, column: ec } = locConverter.get(A.end(cv));
const isDashedIdent = isDashedIdentifier(identifier);
if (isDashedIdent) identifier = identifier.slice(2);
addCssExport(
sl,
sc,
el,
ec,
identifier,
getReexport(identifier),
[A.start(cv), A.end(cv)],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE,
isDashedIdent
? CssIcssExportDependency.EXPORT_TYPE.CUSTOM_VARIABLE
: CssIcssExportDependency.EXPORT_TYPE.NORMAL
);
}
}
// Replace the closing `)`.
module.addPresentationalDependency(
new ConstDependency("", [A.end(fn) - 1, A.end(fn)])
);
};
/**
* Localize the prelude name of `@keyframes` / `@counter-style` / `@container`: `options.string` takes the first string, `options.identifier` the first ident (a `RegExp` skips matching keywords), `:local()`/`:global()` count as found; top-level `var()`/`style()` dashed idents are always ICSS-processed.
* @param {AtRule} atRule parsed at-rule
* @param {{ string?: boolean, identifier?: boolean | RegExp }} options which prelude value kinds count as the local name
* @returns {number} position after handling
*/
const processLocalAtRule = (atRule, options) => {
let found = false;
for (const cv of A.prelude(atRule)) {
const cvType = A.type(cv);
if (cvType === NodeType.Whitespace) continue;
if (cvType === NodeType.String) {
if (!found && options.string) {
const value = A.unescaped(cv);
const { line: sl, column: sc } = locConverter.get(A.start(cv));
const { line: el, column: ec } = locConverter.get(A.end(cv));
addCssExport(
sl,
sc,
el,
ec,
value,
value,
[A.start(cv), A.end(cv)],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE
);
found = true;
pure.markLocal();
}
continue;
}
if (cvType === NodeType.Ident) {
if (!found && options.identifier) {
const identifier = A.unescaped(cv);
if (
options.identifier instanceof RegExp &&
options.identifier.test(identifier)
) {
continue;
}
const { line: sl, column: sc } = locConverter.get(A.start(cv));
const { line: el, column: ec } = locConverter.get(A.end(cv));
addCssExport(
sl,
sc,
el,
ec,
identifier,
getReexport(identifier),
[A.start(cv), A.end(cv)],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE,
CssIcssExportDependency.EXPORT_TYPE.NORMAL
);
found = true;
pure.markLocal();
}
continue;
}
if (cvType === NodeType.Function) {
const fn = /** @type {FunctionNode} */ (cv);
const fname = A.unescapedName(fn);
const type = equalsLowerCase(fname, "local")
? 1
: equalsLowerCase(fname, "global")
? 2
: undefined;
if (!found && type) {
found = true;
if (type === 1) pure.markLocal();
processLocalOrGlobalFunction(fn, type);
continue;
}
if (
this.options.dashedIdents &&
isLocalMode() &&
(equalsLowerCase(fname, "var") || equalsLowerCase(fname, "style"))
) {
processDashedIdentInVarFunction(fn);
}
}
}
return A.end(atRule);
};
/**
* Emit the ICSS export declaring this module exports the given custom property.
* @param {number} identStart start of the `--<name>` ident
* @param {number} identEnd end of the ident (exclusive)
*/
const emitDashedIdentExport = (identStart, identEnd) => {
const identifier = unescapeRange(identStart + 2, identEnd);
const { line: sl, column: sc } = locConverter.get(identStart);
const { line: el, column: ec } = locConverter.get(identEnd);
addCssExport(
sl,
sc,
el,
ec,
identifier,
getReexport(identifier, undefined, true),
[identStart, identEnd],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE,
CssIcssExportDependency.EXPORT_TYPE.CUSTOM_VARIABLE
);
};
/**
* Emit `--<name> from "<path>"` as an ICSS import + export, stripping ` from "<path>"` so the runtime sees just `--<name>` (dep ranges end at `sourceEnd - 1`, the closing quote).
* @param {number} identStart start of the `--<name>` ident
* @param {number} identEnd end of the ident
* @param {number} fromIdentStart start of the `from` keyword (lower bound of the strip)
* @param {number} sourceEnd position past the closing quote of the source string
* @param {string} pathContent unquoted path between the source's quotes
*/
const emitDashedIdentImport = (
identStart,
identEnd,
fromIdentStart,
sourceEnd,
pathContent
) => {
const identifier = unescapeRange(identStart + 2, identEnd);
const { line: sl, column: sc } = locConverter.get(identStart);
const { line: el, column: ec } = locConverter.get(sourceEnd - 1);
const localName = nextIcssImportName();
const importDep = new CssIcssImportDependency(
pathContent,
[identStart, sourceEnd - 1],
/** @type {"local" | "global"} */ (mode),
identifier,
localName
);
importDep.setLoc(sl, sc, el, ec);
module.addDependency(importDep);
addCssExport(
sl,
sc,
el,
ec,
identifier,
getReexport(identifier, localName, true),
[identStart, sourceEnd - 1],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE,
CssIcssExportDependency.EXPORT_TYPE.CUSTOM_VARIABLE
);
module.addPresentationalDependency(
new ConstDependency("", [fromIdentStart, sourceEnd])
);
};
/**
* Strip ` from global` and emit no ICSS export (an explicitly-global custom property isn't a CSS-Modules name).
* @param {number} identEnd end of the `--<name>` ident
* @param {number} sourceEnd position past the `global` ident
*/
const emitDashedIdentFromGlobal = (identEnd, sourceEnd) => {
module.addPresentationalDependency(
new ConstDependency("", [identEnd, sourceEnd])
);
};
/**
* Scope a dashed-ident inside `var(…)` / `style(…)`: emit the first (dashed) ident and its optional `from <ident|string>` suffix.
* @param {FunctionNode} fn parsed `var`/`style` function node
*/
// Per-`var()`/`style()` dashed-ident scan + dispatch. Warm path (custom-property-heavy CSS has thousands of these), so it dispatches inline rather than allocating a descriptor per call.
const processDashedIdentInVarFunction = (fn) => {
/** @type {Token | undefined} */
let identNode;
let identIdx = -1;
const fv = A.children(fn);
for (let i = 0; i < fv.length; i++) {
const cv = fv[i];
if (A.type(cv) === NodeType.Whitespace) continue;
if (A.type(cv) === NodeType.Ident) {
identNode = /** @type {Token} */ (cv);
identIdx = i;
}
break;
}
if (!identNode) return;
const identStart = A.start(identNode);
const identEnd = A.end(identNode);
// `isDashedIdentifier` on the value without slicing it: a literal `--`
// prefix of length >= 3 (escaped dashes don't match, same as the string
// form, since the raw bytes aren't `--`).
if (
identEnd - identStart < 3 ||
input.charCodeAt(identStart) !== CC_HYPHEN_MINUS ||
input.charCodeAt(identStart + 1) !== CC_HYPHEN_MINUS
) {
return;
}
let j = identIdx + 1;
while (j < fv.length && A.type(fv[j]) === NodeType.Whitespace) {
j++;
}
if (
j >= fv.length ||
A.type(fv[j]) !== NodeType.Ident ||
!rangeEqualsLowerCase(input, A.start(fv[j]), A.end(fv[j]), "from")
) {
emitDashedIdentExport(identStart, identEnd);
return;
}
const fromIdent = fv[j];
j++;
while (j < fv.length && A.type(fv[j]) === NodeType.Whitespace) {
j++;
}
if (j >= fv.length) return;
const src = fv[j];
if (
A.type(src) === NodeType.Ident &&
rangeEquals(input, A.start(src), A.end(src), "global")
) {
emitDashedIdentFromGlobal(identEnd, A.end(src));
return;
}
if (A.type(src) === NodeType.String) {
emitDashedIdentImport(
identStart,
identEnd,
A.start(fromIdent),
A.end(src),
input.slice(A.start(src) + 1, A.end(src) - 1)
);
}
};
/**
* The child list of an AST node (a function/block `value` or a rule `prelude`) for sibling lookahead.
* @param {AstNode | null} parent parent node
* @returns {AstNode[] | undefined} the child list, or undefined
*/
const childrenOf = (parent) => {
if (!parent) return undefined;
switch (A.type(parent)) {
case NodeType.Function:
case NodeType.SimpleBlock:
case NodeType.Declaration:
return A.children(parent);
case NodeType.AtRule:
case NodeType.QualifiedRule:
return A.prelude(parent);
default:
return undefined;
}
};
// `allowImport` mirrors `allowImportAtRule`: true until the first top-level block-bearing rule.
let allowImport = true;
// Persistent CSS-Modules mode for a top-level rule: set by bare `:local` / `:global`, leaks into sibling rules, reset at each top-level `}`.
/** @type {"local" | "global" | undefined} */
let modeData;
// Suppress localizing the next qualified rule's selectors after a `;`-terminated at-rule.
let suppressNextRulePrelude = false;
// Dashed-ident (custom-property) scoping state — mutated across function nesting (saved/restored via `dashed.stack`).
const dashed = {
/** Is dashed-ident scoping active in the current value context? */
active: false,
/** Should top-level dashed-ident exports be emitted at this nesting level? */
emit: false,
/** LIFO save/restore of `active` + `emit` across function nesting, bit-packed (bit 0 = active, bit 1 = emit) to avoid a per-function-token object allocation. */
/** @type {number[]} */
stack: [],
/** Push the current scope; call before descending into a `Function` body. */
push() {
this.stack.push((this.active ? 1 : 0) | (this.emit ? 2 : 0));
},
/** Pop the saved scope; call when leaving a `Function` body. */
pop() {
const s = /** @type {number} */ (this.stack.pop());
this.active = (s & 1) !== 0;
this.emit = (s & 2) !== 0;
}
};
// Nearest enclosing declaration / at-rule / qualified-rule, set by each structural enter; the Url / Function / Ident / Comma visitors read it (via `urlActive` / `localGlobalActive` / `icssActive`) to decide value handling from the node hierarchy instead of carrying precomputed flags.
/** @type {AstNode | undefined} */
let currentStructural;
// Cached on each structural enter and read per value token, so the hot Function / Ident / Url visitors don't re-derive the property / at-rule name on every visit.
let currentAtRuleName = "";
// Set by `handleImportAtRule` for a malformed `@import` so its prelude still emits orphan url() deps; read by `urlActive`. Reset per at-rule enter (replaces an ad-hoc property on the node).
let currentUrlRecovery = false;
/** Whether the current Declaration's property is a localizable known property. */
let currentDeclIsKnownProperty = false;
/** Whether the current `composes:` declaration owns the whole value (suppresses value rewrites). */
let currentDeclComposesSkip = false;
/**
* Per-at-rule scope frames; `exit` reads `hasBlock` to pick the block-cleanup vs `suppressNextRulePrelude` branch.
* @type {{ savedAnchor: boolean, savedLocalIdentifiers: string[], name: string, hasBlock: boolean, endsWithSemicolon: boolean, fontPreloaded: boolean }[]}
*/
const atRuleStateStack = [];
/**
* Strip a bare `:local` / `:global` marker (modules only): the marker plus one adjacent whitespace (a comment between ends the run, since comments aren't AST nodes).
* @param {AstNode} colon the `:` node
* @param {AstNode} ident the `local` / `global` ident node
* @param {AstNode} after the node following the ident (may be `undefined` at runtime)
* @returns {boolean} whether whitespace follows the marker
*/
const stripBareMarker = (colon, ident, after) => {
const afterIsWhitespace = Boolean(
after && A.type(after) === NodeType.Whitespace
);
const identEnd = A.end(ident);
const stripEnd =
afterIsWhitespace && A.start(after) === identEnd
? A.end(after)
: identEnd;
if (isModules) {
module.addPresentationalDependency(
new ConstDependency("", [A.start(colon), stripEnd])
);
}
return afterIsWhitespace;
};
/**
* Strip a `:local(…)` / `:global(…)` wrapper (modules only) with two source-level deps: the leading `:name(` up to the first arg, and the trailing `)` (`:local` also eats whitespace before it).
* @param {AstNode} colon the `:` node
* @param {FunctionNode} fn the `local(…)` / `global(…)` function node
* @param {boolean} isLocal whether the marker is `:local(`
* @returns {void}
*/
const stripFunctionMarker = (colon, fn, isLocal) => {
if (!isModules) return;
const fnEnd = A.end(fn);
let stripLeadEnd = fnEnd - 1;
for (const arg of A.children(fn)) {
if (A.type(arg) !== NodeType.Whitespace) {
stripLeadEnd = A.start(arg);
break;
}
}
module.addPresentationalDependency(
new ConstDependency("", [A.start(colon), stripLeadEnd])
);
let trailStart = fnEnd - 1; // position of `)`
if (isLocal) {
while (
trailStart > 0 &&
isWhitespace(source.charCodeAt(trailStart - 1))
) {
trailStart--;
}
}
module.addPresentationalDependency(
new ConstDependency("", [trailStart, fnEnd])
);
};
/**
* Emit the ICSS export for an attribute selector `[class="foo"]` / `[class~="foo"]` (not a composes anchor) by walking the `[…]` block's parsed children. No-op for any other attribute shape.
* @param {SimpleBlock} block the `[…]` block
* @returns {void}
*/
const handleAttributeSelector = (block) => {
const attrParts = A.children(block);
let ai = 0;
while (
ai < attrParts.length &&
A.type(attrParts[ai]) === NodeType.Whitespace
) {
ai++;
}
const attrNameNode = attrParts[ai];
if (!attrNameNode || A.type(attrNameNode) !== NodeType.Ident) return;
const attrName = A.unescaped(attrNameNode);
if (!equalsLowerCase(attrName, "class")) return;
ai++;
while (
ai < attrParts.length &&
A.type(attrParts[ai]) === NodeType.Whitespace
) {
ai++;
}
// `=` or `~=` (two `Delim` tokens for the latter).
const op1 = attrParts[ai];
if (!op1 || A.type(op1) !== NodeType.Delim) return;
const op1v = A.value(op1);
if (op1v === "~") {
ai++;
const op2 = attrParts[ai];
if (!op2 || A.type(op2) !== NodeType.Delim || A.value(op2) !== "=") {
return;
}
} else if (op1v !== "=") {
return;
}
ai++;
while (
ai < attrParts.length &&
A.type(attrParts[ai]) === NodeType.Whitespace
) {
ai++;
}
const attrValueNode = attrParts[ai];
if (!attrValueNode) return;
/** @type {number} */
let classNameStart;
/** @type {number} */
let classNameEnd;
if (A.type(attrValueNode) === NodeType.String) {
classNameStart = A.start(attrValueNode) + 1;
classNameEnd = A.end(attrValueNode) - 1;
} else if (A.type(attrValueNode) === NodeType.Ident) {
classNameStart = A.start(attrValueNode);
classNameEnd = A.end(attrValueNode);
} else {
return;
}
const className = unescapeRange(classNameStart, classNameEnd);
const { line: sl, column: sc } = locConverter.get(classNameStart);
const { line: el, column: ec } = locConverter.get(classNameEnd);
addCssExport(
sl,
sc,
el,
ec,
className,
getReexport(className),
[classNameStart, classNameEnd],
true,
CssIcssExportDependency.EXPORT_MODE.NONE
);
};
/**
* Scope a `::view-transition-*()` pseudo-element's `(<pt-name> .<pt-class>…)` argument: each non-dashed custom-ident (the part name and its classes) is localized like a `view-transition-name` value; `*` and dashed idents are left untouched. Keeps the pseudo consistent with the scoped `view-transition-name`/`-class` declarations.
* @param {AstNode[]} cvs the pseudo-element's argument component values
* @returns {void}
*/
const walkViewTransitionPart = (cvs) => {
// customIdents off: leave the whole `(…)` untouched (both name and classes), consistent with unscoped `view-transition-name`/`-class` declarations.
if (!this.options.customIdents) return;
for (const cv of cvs) {
if (A.type(cv) !== NodeType.Ident) continue;
const name = A.unescaped(cv);
if (isDashedIdentifier(name)) continue;
const start = A.start(cv);
const end = A.end(cv);
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
addCssExport(
sl,
sc,
el,
ec,
name,
getReexport(name),
[start, end],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE
);
pure.markLocal();
}
};
/**
* Walk component values as a selector list, emitting ID / attribute deps and recursing into `:not()`/`:is()`/`:local()`/`:global()` wrappers; `localMode` is the sub-tree mode and `topLevel` controls whether commas reset it (only outside parentheses).
* @param {AstNode[]} values component values to walk
* @param {"local" | "global"} localMode CSS-Modules mode applicable to this sub-tree
* @param {boolean=} topLevel whether commas in this list reset to `localMode` (defaults to `true`)
* @returns {void}
*/
const walkSelectorList = (values, localMode, topLevel = true) => {
// At a rule's top level, inherit persistent `modeData` (or the one-shot `suppressNextRulePrelude` → "global"); recursive calls use the passed `localMode`.
let segmentMode = localMode;
if (topLevel) {
if (suppressNextRulePrelude) {
segmentMode = "global";
suppressNextRulePrelude = false;
} else if (modeData) {
segmentMode = modeData;
}
}
for (let i = 0; i < values.length; i++) {
const v = values[i];
switch (A.type(v)) {
case NodeType.Whitespace:
break;
case NodeType.Comma:
if (topLevel) {
// Top-level comma resets the segment + persistent mode and, in pure mode, finalizes the segment's purity.
segmentMode = localMode;
modeData = undefined;
pure.finalizeSelector();
}
break;
case NodeType.Colon: {
// Look ahead for `:local` / `:global` markers; other pseudos fall through.
const next = values[i + 1];
if (!next) break;
const nextType = A.type(next);
if (nextType === NodeType.Ident) {
const raw = A.value(next);
const isLocal = equalsLowerCase(raw, "local");
if (isLocal || equalsLowerCase(raw, "global")) {
const id = isLocal ? "local" : "global";
// Bare `:local` / `:global`: switch the segment (and top-level persistent) mode and strip the marker.
const afterIsWhitespace = stripBareMarker(
v,
next,
values[i + 2]
);
// Bare `:local` / `:global` needs whitespace before the next selector (else `:local.b` is ambiguous) — warn when none follows.
if (!afterIsWhitespace) {
this._emitWarning(
state,
`Missing whitespace after ':${id}' in '${source.slice(
A.start(v),
findLeftCurly(source, A.end(next)) + 1
)}'`,
locConverter,
A.start(v),
A.end(next)
);
}
segmentMode = id;
if (topLevel) modeData = id;
// Skip past the colon + ident.
i += 1;
}
} else if (nextType === NodeType.Function) {
const fn = /** @type {FunctionNode} */ (next);
const rawName = A.unescapedName(fn);
const isLocal = equalsLowerCase(rawName, "local");
if (isLocal || equalsLowerCase(rawName, "global")) {
// `:local(…)` / `:global(…)`: scope mode to the args and strip the `:name(` … `)` wrapper.
stripFunctionMarker(v, fn, isLocal);
walkSelectorList(
A.children(fn),
isLocal ? "local" : "global",
false
);
// Skip past the colon + function.
i += 1;
}
}
break;
}
case NodeType.Function: {
// `::view-transition-group(name .class…)` etc.: scope the part name + classes as custom idents (the `v` char guard skips the slice for the common `:is`/`:not`/… functions).
const fnNameStart = A.nameStart(v);
if (
segmentMode === "local" &&
(input.charCodeAt(fnNameStart) === CC_LOWER_V ||
input.charCodeAt(fnNameStart) === CC_UPPER_V) &&
VIEW_TRANSITION_PART_PSEUDO.test(A.unescapedName(v))
) {
walkViewTransitionPart(A.children(v));
break;
}
// Any other function (`:not(…)`, `:is(…)`, …): recurse with the segment mode preserved (only `:local(…)` / `:global(…)`, handled above, switch mode).
walkSelectorList(A.children(v), segmentMode, false);
break;
}
case NodeType.Hash: {
if (A.typeFlag(v) !== "id" || segmentMode !== "local") {
break;
}
// ID selectors emit the ICSS export but aren't a `composes:` anchor.
const idValueStart = A.start(v) + 1;
const idName = A.unescaped(v);
const { line: idSl, column: idSc } = locConverter.get(A.start(v));
const { line: idEl, column: idEc } = locConverter.get(A.end(v));
addCssExport(
idSl,
idSc,
idEl,
idEc,
idName,
getReexport(idName),
[idValueStart, A.end(v)],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE
);
pure.markLocal();
break;
}
case NodeType.SimpleBlock: {
const block = /** @type {SimpleBlock} */ (v);
const bt = A.blockToken(block);
if (bt === "[") {
// Attribute selectors `[class="foo"]` localize only in local mode.
if (segmentMode === "local") handleAttributeSelector(block);
} else if (bt === "(") {
// `@scope (.foo)` and other parenthesised selector wrappers — recurse into the `(…)` block.
walkSelectorList(A.children(block), segmentMode, false);
}
break;
}
case NodeType.Delim: {
const delim = A.value(v);
if (delim === "&") {
// Pure-mode: a nesting `&` inherits a pure ancestor's purity.
if (topLevel && pure.ancestorHadLocal()) pure.markLocal();
break;
}
if (delim !== ".") break;
const next = values[i + 1];
if (!next || A.type(next) !== NodeType.Ident) break;
if (segmentMode === "local") {
// `.<ident>` in local mode is a class selector (dep covers the ident bytes only).
const name = A.unescaped(next);
// Cursor reads instead of `A.loc` — this is the hottest export site.
const { line: sl, column: sc } = locConverter.get(A.start(next));
const { line: el, column: ec } = locConverter.get(A.end(next));
addCssExport(
sl,
sc,
el,
ec,
name,
getReexport(name),
[A.start(next), A.end(next)],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE
);
currentRule.hasLocalAnchor = true;
currentRule.localIdentifiers.push(name);
pure.markLocal();
} else if (icssDefinitions.size !== 0) {
// `.<ident>` in global mode: not localized, but the ident may be `@value`-defined and need ICSS rewrite.
const ident = A.value(next);
if (!isDashedIdentifier(ident) && icssDefinitions.has(ident)) {
emitICSSSymbol(ident, A.start(next), A.end(next));
}
}
// Skip the consumed ident.
i += 1;
break;
}
case NodeType.Ident: {
// ICSS rewrite for a bare `@value`-defined ident used as a type-style selector; only worth the slice when definitions exist.
if (icssDefinitions.size === 0) break;
const ident = A.value(v);
if (!isDashedIdentifier(ident) && icssDefinitions.has(ident)) {
emitICSSSymbol(ident, A.start(v), A.end(v));
}
break;
}
default:
break;
}
}
// Pure-mode: finalize the trailing comma-separated segment.
if (topLevel) pure.finalizeSelector();
};
/**
* Per-qualified-rule scope frames; `{ bailed: true }` for inline-handled `:import` / `:export` pseudo-rules.
* @type {({ bailed: true } | { bailed: false, savedAnchor: boolean, savedLocalIdentifiers: string[], savedPrevComposesFile: string | undefined, savedComposesFiles: Set<string> | null })[]}
*/
const qualifiedRuleStateStack = [];
/**
* Whether url() deps are emitted here (from `currentStructural`): false outside `options.url` and inside an `@import` prelude (the import target, unless url recovery is on).
* @returns {boolean} true when url() deps should be emitted
*/
const urlActive = () => {
if (!this.options.url || !currentStructural) return false;
if (A.type(currentStructural) === NodeType.AtRule) {
// `currentAtRuleName` is cached on at-rule enter — no per-value slice.
return currentAtRuleName !== "@import" || currentUrlRecovery;
}
return true;
};
/**
* At-rules with a dedicated CSS-Modules handler, so the generic `local()`/`global()` value rewrite and dashed-ident scoping are off for them.
* @param {string} name at-rule name including the leading `@`, lower-cased
* @returns {boolean} true for `@import` / `@charset` / `@namespace` / `@value` / `@scope` and the option-gated `@keyframes` / `@counter-style` / `@container`
*/
const isLocalHandledAtRule = (name) =>
name === "@import" ||
name === "@charset" ||
name === "@namespace" ||
name === "@value" ||
name === "@scope" ||
(mayHaveCustomMedia && name === "@custom-media") ||
(mayHaveCustomSelectors && name === "@custom-selector") ||
(isModules &&
((this.options.animation &&
OPTIONALLY_VENDOR_PREFIXED_KEYFRAMES_AT_RULE.test(name)) ||
(this.options.customIdents && name === "@counter-style") ||
(this.options.container && name === "@container")));
/**
* Whether `local()` / `global()` value functions are rewritten to ICSS here (from `currentStructural`).
* @returns {boolean} true when the rewrite is active
*/
const localGlobalActive = () => {
if (!isModules || !currentStructural) return false;
const t = A.type(currentStructural);
if (t === NodeType.AtRule) {
return !isLocalHandledAtRule(currentAtRuleName);
}
if (t === NodeType.Declaration) {
return !currentDeclComposesSkip;
}
return false;
};
/**
* Whether `@value`-defined idents / function names are ICSS-rewritten here (from `currentStructural`).
* @returns {boolean} true when the rewrite is active
*/
const icssActive = () => {
if (!isModules || !currentStructural) return false;
const t = A.type(currentStructural);
if (t === NodeType.AtRule) {
return (
currentAtRuleName !== "@value" &&
currentAtRuleName !== "@import" &&
currentAtRuleName !== "@custom-media" &&
currentAtRuleName !== "@custom-selector"
);
}
if (t === NodeType.Declaration) {
return !currentDeclComposesSkip && !currentDeclIsKnownProperty;
}
return false;
};
/**
* Post-rule top-level reset shared by AtRule and QualifiedRule exit: lock `@import` (block-bearing only locks for at-rules), mark seen-top-level for pure-mode, and clear the persistent `:local`/`:global` override.
* @param {AstNode} node the rule node
* @param {boolean} blockBearing whether to require a body before locking `allowImport` (true for at-rules, ignored for QRs which always have a body)
* @returns {void}
*/
const finishTopLevelRule = (node, blockBearing) => {
if (!blockBearing || A.declarations(node) || A.childRules(node)) {
allowImport = false;
}
pure.markSeenTopLevelRule();
modeData = undefined;
};
/**
* Emit the ICSS export/import deps for one parsed `composes:` group onto `lastLocalIdentifier`: `from "<file>"` imports each name (tracking file load order), `from global` and bare names re-export locally. The caller has already validated and scanned the group.
* @param {{ start: number, end: number, isGlobal: boolean }[]} classNames composed names with source ranges
* @param {{ kind: "string", path: string } | { kind: "global" } | undefined} fromSource the `from …` clause, if any
* @param {string} lastLocalIdentifier the rule's single local-class anchor
* @returns {void}
*/
const emitComposesGroup = (classNames, fromSource, lastLocalIdentifier) => {
if (fromSource && fromSource.kind === "string") {
const request = fromSource.path;
const selfReference = isSelfReferenceRequest(request);
if (!selfReference) {
let files = currentRule.composesFiles;
if (!files) {
files = new Set();
currentRule.composesFiles = files;
}
if (!files.has(request)) {
files.add(request);
if (
currentRule.composesPrevFile !== undefined &&
currentRule.composesPrevFile !== request
) {
let successors = composesGraph.get(currentRule.composesPrevFile);
if (!successors) {
successors = new Set();
composesGraph.set(currentRule.composesPrevFile, successors);
}
successors.add(request);
}
currentRule.composesPrevFile = request;
}
}
for (const { start, end } of classNames) {
const identifier = unescapeRange(start, end);
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
if (selfReference) {
if (identifier === lastLocalIdentifier) continue;
addCssExport(
sl,
sc,
el,
ec,
lastLocalIdentifier,
getReexport(identifier),
[start, end],
true,
CssIcssExportDependency.EXPORT_MODE.SELF_REFERENCE,
CssIcssExportDependency.EXPORT_TYPE.COMPOSES
);
continue;
}
const localName = nextIcssImportName();
const importDep = new CssIcssImportDependency(
request,
[start, end],
/** @type {"local" | "global"} */ (mode),
identifier,
localName
);
importDep.setLoc(sl, sc, el, ec);
module.addDependency(importDep);
if (!composesFirstFileImport.has(request)) {
composesFirstFileImport.set(request, importDep);
}
addCssExport(
sl,
sc,
el,
ec,
lastLocalIdentifier,
getReexport(identifier, localName),
[start, end],
true,
CssIcssExportDependency.EXPORT_MODE.APPEND,
CssIcssExportDependency.EXPORT_TYPE.COMPOSES
);
}
} else if (fromSource && fromSource.kind === "global") {
for (const { start, end } of classNames) {
const identifier = unescapeRange(start, end);
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
addCssExport(
sl,
sc,
el,
ec,
lastLocalIdentifier,
getReexport(identifier),
[start, end],
false,
CssIcssExportDependency.EXPORT_MODE.APPEND,
CssIcssExportDependency.EXPORT_TYPE.COMPOSES
);
}
} else {
for (const { start, end, isGlobal } of classNames) {
const identifier = unescapeRange(start, end);
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
addCssExport(
sl,
sc,
el,
ec,
lastLocalIdentifier,
getReexport(identifier),
[start, end],
!isGlobal,
isGlobal
? CssIcssExportDependency.EXPORT_MODE.APPEND
: CssIcssExportDependency.EXPORT_MODE.SELF_REFERENCE,
CssIcssExportDependency.EXPORT_TYPE.COMPOSES
);
}
}
};
/**
* Emit the ICSS deps + presentational strip for a `composes: …` declaration whose rule has a single local-class anchor (the strip-dep covers the whole declaration).
* @param {Declaration} decl the `composes` declaration
*/
const emitComposesWithAnchor = (decl) => {
if (currentRule.localIdentifiers.length > 1) {
this._emitWarning(
state,
`Composition is only allowed when selector is single local class name not in "${currentRule.localIdentifiers.join(
'", "'
)}"`,
locConverter,
A.start(decl),
A.end(decl)
);
return;
}
const lastLocalIdentifier = currentRule.localIdentifiers[0];
// Split the value at top-level commas — each segment is one `<name>+ [from <source>]` group.
/** @type {AstNode[][]} */
const groups = [];
/** @type {AstNode[]} */
let currentGroup = [];
for (const cv of A.children(decl)) {
if (A.type(cv) === NodeType.Comma) {
groups.push(currentGroup);
currentGroup = [];
} else {
currentGroup.push(cv);
}
}
groups.push(currentGroup);
// Inline scan + dispatch per group — warm path (composes-heavy modules), so no per-group result object is allocated.
for (const group of groups) {
/** @type {{ start: number, end: number, isGlobal: boolean }[]} */
const classNames = [];
/** @type {"names" | "expecting-source" | "done"} */
let phase = "names";
/** @type {{ kind: "string", path: string } | { kind: "global" } | undefined} */
let fromSource;
/** @type {AstNode | undefined} */
let errorToken;
let errorMessage = "";
for (let i = 0; i < group.length; i++) {
const cv = group[i];
if (A.type(cv) === NodeType.Whitespace) continue;
if (phase === "expecting-source") {
if (A.type(cv) === NodeType.String) {
fromSource = {
kind: "string",
path: source.slice(A.start(cv) + 1, A.end(cv) - 1)
};
phase = "done";
continue;
}
if (
A.type(cv) === NodeType.Ident &&
equalsLowerCase(A.value(cv), "global")
) {
fromSource = { kind: "global" };
phase = "done";
continue;
}
errorToken = cv;
errorMessage =
"Incorrect composition, expected global keyword or string value";
break;
}
if (phase === "done") {
continue;
}
if (A.type(cv) === NodeType.Ident) {
const identValue = A.value(cv);
if (
equalsLowerCase(identValue, "from") &&
classNames.length > 0 &&
nextNonWhitespace(group, i + 1) < group.length
) {
phase = "expecting-source";
continue;
}
classNames.push({
start: A.start(cv),
end: A.end(cv),
isGlobal: false
});
continue;
}
if (A.type(cv) === NodeType.Function) {
const fn = /** @type {FunctionNode} */ (cv);
const isGlobal = equalsLowerCase(A.unescapedName(fn), "global");
for (const inner of A.children(fn)) {
if (A.type(inner) === NodeType.Ident) {
classNames.push({
start: A.start(inner),
end: A.end(inner),
isGlobal
});
break;
}
}
continue;
}
errorToken = cv;
errorMessage = "Incorrect composition, expected class named";
break;
}
if (!errorToken && phase === "expecting-source") {
errorMessage =
"Incorrect composition, expected global keyword or string value";
errorToken = /** @type {AstNode | undefined} */ (
group[group.length - 1]
);
}
if (errorToken) {
this._emitWarning(
state,
errorMessage,
locConverter,
A.start(errorToken),
A.end(errorToken)
);
return;
}
if (classNames.length === 0) continue;
emitComposesGroup(classNames, fromSource, lastLocalIdentifier);
}
// Strip the whole `composes: …;` (property name included) plus trailing same-line whitespace. The `;` and that whitespace aren't AST nodes (a block's contents drop them), so scan the source here.
let resumeAt = A.end(decl);
if (source.charCodeAt(A.end(decl)) === CC_SEMICOLON) {
resumeAt = A.end(decl) + 1;
while (isWhitespace(source.charCodeAt(resumeAt))) resumeAt++;
}
module.addPresentationalDependency(
new ConstDependency("", [A.nameStart(decl), resumeAt])
);
};
/**
* Emit url() deps for a `url(...)` / `src(...)` / `image-set(...)` value function.
* @param {FunctionNode} fn the function node
* @param {string} fname the unescaped function name (any case)
*/
const emitUrlFunction = (fn, fname) => {
if (equalsLowerCase(fname, "url") || equalsLowerCase(fname, "src")) {
// Quoted `url("…")` / `src("…")`: first non-whitespace value must be the string token.
const first = A.children(fn)[nextNonWhitespace(A.children(fn), 0)];
if (!first || A.type(first) !== NodeType.String) return;
const string = /** @type {Token} */ (first);
const { ignored, options } = magicCommentsIn(
[lastTokenEndForComments, A.start(fn)],
A.start(string),
A.end(string)
);
if (ignored) return;
const value = normalizeUrl(
input.slice(A.start(string) + 1, A.end(string) - 1),
true
);
// Ignore `url()`, `url('')` and `url("")`, they are valid by spec
if (value.length === 0) return;
const dep = new CssUrlDependency(
value,
[A.start(string), A.end(string)],
"string"
);
setDepLoc(dep, A.start(string), A.end(string));
applyResourceHintDefaults(
dep,
value,
options,
rangeLoc(A.start(string), A.end(string))
);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
} else if (IMAGE_SET_FUNCTION.test(fname)) {
// `image-set(…)`: each comma segment's first string is the URL; advance the magic-comment fence per string.
lastTokenEndForComments = A.nameEnd(fn) + 1;
let prevStringEnd = A.start(fn);
let firstInSegment = true;
for (const cv of A.children(fn)) {
if (A.type(cv) === NodeType.Comma) {
firstInSegment = true;
continue;
}
if (A.type(cv) === NodeType.Whitespace) continue;
const wasFirst = firstInSegment;
firstInSegment = false;
if (!wasFirst || A.type(cv) !== NodeType.String) continue;
const string = /** @type {Token} */ (cv);
const start = prevStringEnd;
prevStringEnd = A.end(string);
const value = normalizeUrl(
input.slice(A.start(string) + 1, A.end(string) - 1),
true
);
if (value.length === 0) continue;
const { ignored, options } = magicCommentsIn(
[start, A.end(string)],
A.start(string),
A.end(string)
);
if (ignored) continue;
const dep = new CssUrlDependency(
value,
[A.start(string), A.end(string)],
"url"
);
setDepLoc(dep, A.start(string), A.end(string));
applyResourceHintDefaults(
dep,
value,
options,
rangeLoc(A.start(string), A.end(string))
);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
}
}
};
/**
* Handle an `@import` at-rule: parse its prelude (url, layer, supports, media), emit the `CssImportDependency`, and warn on malformed forms.
* @param {AtRule} at the `@import` at-rule
* @param {boolean} topLevel whether the rule is at the stylesheet top level
*/
const handleImportAtRule = (at, topLevel) => {
if (!this.options.import) return;
if (!topLevel || !allowImport) {
this._emitWarning(
state,
"Any '@import' rules must precede all other rules",
locConverter,
A.start(at),
A.nameEnd(at)
);
return;
}
const importStart = A.start(at);
const importNameEnd = A.nameEnd(at);
// We only accept `;`-terminated @import; block / EOF / `}` ends are silent bails.
if (source.charCodeAt(A.end(at)) !== CC_SEMICOLON) return;
// Walk the prelude in spec order (URL → layer? → supports? → media query); anything else joins the media query.
const { urlNode, layerNode, supportsNode } = parseImportPrelude(
A.prelude(at)
);
const semi = A.end(at) + 1; // position past `;`
if (!urlNode || (A.type(urlNode) === NodeType.Ident && !isModules)) {
this._emitWarning(
state,
`Expected URL in '${input.slice(importStart, semi)}'`,
locConverter,
importStart,
semi
);
// A malformed `@import` still emits orphan url() deps from its prelude — flag it so the value visitors enable url().
currentUrlRecovery = true;
return;
}
/** @type {string} */
let url;
if (A.type(urlNode) === NodeType.Ident) {
// URL given as identifier — resolve via CSS Modules `@value`.
const identName = A.value(urlNode);
const def = icssDefinitions.get(identName);
if (!def) {
this._emitWarning(
state,
`Unknown '@value' identifier '${identName}' in '${input.slice(
importStart,
semi
)}'`,
locConverter,
importStart,
semi
);
// Drop the whole at-rule so the unresolved identifier isn't substituted into the output.
const dep = new ConstDependency("", [importStart, semi]);
module.addPresentationalDependency(dep);
return;
}
if (def.value === undefined) {
this._emitWarning(
state,
`'@value' identifier '${identName}' was imported from another module and cannot be used as the URL of '@import' — only locally defined values are supported here`,
locConverter,
importStart,
semi
);
const dep = new ConstDependency("", [importStart, semi]);
module.addPresentationalDependency(dep);
return;
}
const raw = def.value.trim();
url =
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
? normalizeUrl(raw.slice(1, -1), true)
: normalizeUrl(raw, false);
} else if (A.type(urlNode) === NodeType.Url) {
const ut = /** @type {UrlToken} */ (urlNode);
url = normalizeUrl(
input.slice(A.contentStart(ut), A.contentEnd(ut)),
false
);
} else if (A.type(urlNode) === NodeType.String) {
url = normalizeUrl(
input.slice(A.start(urlNode) + 1, A.end(urlNode) - 1),
true
);
} else {
// url(...) function — first non-whitespace child is the string.
/** @type {Token | undefined} */
let string;
for (const inner of A.children(urlNode)) {
if (A.type(inner) === NodeType.Whitespace) continue;
if (A.type(inner) === NodeType.String) {
string = /** @type {Token} */ (inner);
}
break;
}
if (!string) {
this._emitWarning(
state,
`Expected URL in '${input.slice(importStart, semi)}'`,
locConverter,
importStart,
semi
);
return;
}
url = normalizeUrl(
input.slice(A.start(string) + 1, A.end(string) - 1),
true
);
}
const newline = skipWhiteLine(input, semi);
if (
webpackIgnored([importNameEnd, A.end(urlNode)], importStart, newline)
) {
return;
}
if (url.length === 0) {
const dep = new ConstDependency("", [importStart, newline]);
module.addPresentationalDependency(dep);
setDepLoc(dep, importStart, newline);
return;
}
/** @type {undefined | string} */
let layer;
if (layerNode) {
if (A.type(layerNode) === NodeType.Function) {
// `layer(<ident>)` — extract content between `(` and `)`.
const fn = /** @type {FunctionNode} */ (layerNode);
layer = input.slice(A.nameEnd(fn) + 1, A.end(fn) - 1).trim();
} else {
// Bare `layer` ident — anonymous layer.
layer = "";
}
}
/** @type {undefined | string} */
let supports;
if (supportsNode) {
supports = input
.slice(A.nameEnd(supportsNode) + 1, A.end(supportsNode) - 1)
.trim();
}
// Media query = whatever sits between the last url/layer/supports part and the closing `;`, trimmed. Start at the next non-whitespace prelude node (skips the gap, comments included).
const lastPrefixPart = supportsNode || layerNode || urlNode;
const afterIdx =
/** @type {AstNode[]} */ (A.prelude(at)).indexOf(lastPrefixPart) + 1;
const nextIdx = nextNonWhitespace(A.prelude(at), afterIdx);
const mediaStart =
nextIdx < A.prelude(at).length
? A.start(A.prelude(at)[nextIdx])
: A.end(at);
/** @type {undefined | string} */
let media;
if (mediaStart !== A.end(at)) {
media = input.slice(mediaStart, A.end(at)).trim();
}
const { line: sl, column: sc } = locConverter.get(importStart);
const { line: el, column: ec } = locConverter.get(newline);
const parent = /** @type {CssModule} */ (module);
// Carry the importing module's layer/supports/media chain onto the dep so a nested `@import` inherits it.
/** @type {Inheritance | undefined} */
let inheritance;
if (parent.cssLayer !== undefined || parent.supports || parent.media) {
inheritance = [[parent.cssLayer, parent.supports, parent.media]];
}
if (parent.inheritance) {
if (!inheritance) inheritance = [];
inheritance.push(...parent.inheritance);
}
const dep = new CssImportDependency(
url,
[importStart, newline],
mode === "local" || mode === "global" ? mode : undefined,
layer,
supports && supports.length > 0 ? supports : undefined,
media && media.length > 0 ? media : undefined,
inheritance,
parent.exportType
);
dep.setLoc(sl, sc, el, ec);
module.addDependency(dep);
// `text` / `css-style-sheet` parents inline the import at build time, so order it via a code-generation dependency.
if (
parent.exportType === "text" ||
parent.exportType === "css-style-sheet"
) {
module.addCodeGenerationDependency(dep);
}
};
/**
* Handle a CSS-Modules `@value` at-rule: register the local / imported value(s) in `icssDefinitions`, emit the import + export deps, and strip the rule.
* @param {AtRule} at the `@value` at-rule
*/
const handleValueAtRule = (at) => {
const start = A.start(at);
const nameEnd = A.nameEnd(at);
const semi = A.end(at);
const atRuleEnd =
source.charCodeAt(semi) === CC_SEMICOLON ? semi + 1 : semi;
const params = input.slice(nameEnd, semi);
const parsed = parseValueAtRuleParams(params);
if (
typeof (/** @type {ValueAtRuleImport} */ (parsed).from) !== "undefined"
) {
if (/** @type {ValueAtRuleImport} */ (parsed).from.length === 0) {
this._emitWarning(
state,
`Broken '@value' at-rule: ${input.slice(start, atRuleEnd)}'`,
locConverter,
start,
atRuleEnd
);
const dep = new ConstDependency("", [start, atRuleEnd]);
module.addPresentationalDependency(dep);
return;
}
let { from, items } = /** @type {ValueAtRuleImport} */ (parsed);
for (const { importName, localName } of items) {
{
const reexport = icssDefinitions.get(from);
if (reexport && reexport.value) {
from = reexport.value.slice(1, -1);
}
const dep = new CssIcssImportDependency(
from,
[0, 0],
/** @type {"local" | "global"} */
(mode),
importName,
localName
);
setDepLoc(dep, start, nameEnd);
module.addDependency(dep);
icssDefinitions.set(localName, {
importName,
request: from
});
}
{
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(nameEnd);
addCssExport(
sl,
sc,
el,
ec,
localName,
getReexport(localName),
undefined,
false,
CssIcssExportDependency.EXPORT_MODE.REPLACE
);
}
}
} else {
if (/** @type {ValueAtRuleValue} */ (parsed).localName.length === 0) {
this._emitWarning(
state,
`Broken '@value' at-rule: ${input.slice(start, atRuleEnd)}'`,
locConverter,
start,
atRuleEnd
);
const dep = new ConstDependency("", [start, atRuleEnd]);
module.addPresentationalDependency(dep);
return;
}
const { localName, value } = /** @type {ValueAtRuleValue} */ (parsed);
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(nameEnd);
if (icssDefinitions.has(value)) {
const def =
/** @type {IcssDefinition} */
(icssDefinitions.get(value));
def.localName = value;
icssDefinitions.set(localName, def);
addCssExport(sl, sc, el, ec, localName, getReexport(value));
} else {
icssDefinitions.set(localName, { value });
addCssExport(sl, sc, el, ec, localName, value);
}
}
const dep = new ConstDependency("", [start, atRuleEnd]);
module.addPresentationalDependency(dep);
};
/**
* The lone dashed-ident name of a `( … )` block, or `undefined` — `(--foo)` yields `--foo`, `(--foo: 1px)` / `(min-width: 0)` yield `undefined`. `hasExtra` reports whether any non-whitespace token follows the ident (a boolean-context violation).
* @param {SimpleBlock} block parenthesised block node
* @param {{ hasExtra: boolean }} out extra-token flag output
* @returns {string | undefined} the dashed-ident name, or undefined
*/
const loneDashedIdentOfBlock = (block, out) => {
out.hasExtra = false;
let ident;
for (const k of A.children(block)) {
if (A.type(k) === NodeType.Whitespace) continue;
if (ident !== undefined) {
out.hasExtra = true;
return ident;
}
if (A.type(k) !== NodeType.Ident) return undefined;
const v = A.value(k);
if (!isDashedIdentifier(v)) return undefined;
ident = v;
}
return ident;
};
/**
* Collect one `@custom-media --name <value>` definition (last-wins) and drop the rule. Classifies the value: `"condition"` (a media-in-parens, or comma list → `(a or b)`) inlines anywhere; `"type"` (a media type like `screen` / `not all` / `screen and (…)`) inlines only at a query's start; `true` / `false` / nested custom-media values are `"unsupported"` (warned on use — the ecosystem disagrees on these).
* @param {AtRule} at the `@custom-media` at-rule
*/
const collectCustomMedia = (at) => {
const start = A.start(at);
const end = A.end(at);
const ruleEnd = input.charCodeAt(end) === CC_SEMICOLON ? end + 1 : end;
module.addPresentationalDependency(
new ConstDependency("", [start, ruleEnd])
);
const prelude = A.prelude(at);
let i = 0;
while (i < prelude.length && A.type(prelude[i]) === NodeType.Whitespace) {
i++;
}
if (i >= prelude.length || A.type(prelude[i]) !== NodeType.Ident) return;
const name = A.value(prelude[i]);
if (!isDashedIdentifier(name)) return;
i++;
/**
* @param {string} text resolved text
* @param {"condition" | "type" | "unsupported"} kind value kind
*/
const store = (text, kind) => {
(customMediaDefs || (customMediaDefs = new Map())).set(name, {
text,
kind
});
};
const extra = { hasExtra: false };
// Value span after the name, trimmed.
let vs = i;
let ve = prelude.length;
while (vs < ve && A.type(prelude[vs]) === NodeType.Whitespace) vs++;
while (ve > vs && A.type(prelude[ve - 1]) === NodeType.Whitespace) ve--;
if (vs >= ve) {
store("", "unsupported");
return;
}
let hasComma = false;
for (let k = vs; k < ve; k++) {
if (A.type(prelude[k]) === NodeType.Comma) {
hasComma = true;
break;
}
}
if (!hasComma) {
const first = prelude[vs];
const valueText = input.slice(A.start(first), A.end(prelude[ve - 1]));
if (A.type(first) === NodeType.SimpleBlock) {
// A lone `(--x)` is an unresolved nested custom media; anything else is a media-in-parens.
const nested =
loneDashedIdentOfBlock(
/** @type {SimpleBlock} */ (first),
extra
) !== undefined;
store(nested ? "" : valueText, nested ? "unsupported" : "condition");
return;
}
if (A.type(first) !== NodeType.Ident) {
store("", "unsupported");
return;
}
if (
rangeEqualsLowerCase(input, A.start(first), A.end(first), "true") ||
rangeEqualsLowerCase(input, A.start(first), A.end(first), "false")
) {
store("", "unsupported");
return;
}
if (rangeEqualsLowerCase(input, A.start(first), A.end(first), "not")) {
// `not (…)` is a media condition (wrap so it stays a media-in-parens); `not <type>` is a media type.
let j = vs + 1;
while (j < ve && A.type(prelude[j]) === NodeType.Whitespace) j++;
if (j < ve && A.type(prelude[j]) === NodeType.SimpleBlock) {
store(`(${valueText})`, "condition");
} else {
store(valueText, "type");
}
return;
}
// `screen`, `only screen`, `screen and (…)`, …
store(valueText, "type");
return;
}
// Comma list → `(seg1 or seg2 …)`; supported only when every segment is a media-in-parens.
/** @type {string[]} */
const segments = [];
let ok = true;
/** @type {AstNode[]} */
let seg = [];
const flush = () => {
let a = 0;
let b = seg.length;
while (a < b && A.type(seg[a]) === NodeType.Whitespace) a++;
while (b > a && A.type(seg[b - 1]) === NodeType.Whitespace) b--;
if (a >= b) {
ok = false;
return;
}
const f = seg[a];
const text = input.slice(A.start(f), A.end(seg[b - 1]));
if (
b - a === 1 &&
A.type(f) === NodeType.SimpleBlock &&
loneDashedIdentOfBlock(/** @type {SimpleBlock} */ (f), extra) ===
undefined
) {
segments.push(text);
} else if (
A.type(f) === NodeType.Ident &&
rangeEqualsLowerCase(input, A.start(f), A.end(f), "not")
) {
segments.push(`(${text})`);
} else {
ok = false;
}
};
for (let k = vs; k < ve; k++) {
if (A.type(prelude[k]) === NodeType.Comma) {
flush();
seg = [];
} else {
seg.push(prelude[k]);
}
}
flush();
if (ok && segments.length > 0) {
store(
segments.length === 1 ? segments[0] : `(${segments.join(" or ")})`,
"condition"
);
} else {
store("", "unsupported");
}
};
/**
* Record every `(--name)` custom-media reference in a media prelude (recursing into nested groups/functions) for post-walk resolution; `invalid` flags a non-boolean-context use like `(--name: 1px)`, `leading` marks a reference that is the first term of a top-level query (the only spot a media-type value may be inlined).
* @param {readonly AstNode[]} tokens prelude (or nested block) tokens
* @param {boolean} topLevel whether `tokens` is the media prelude itself (vs. a nested group)
*/
const scanCustomMediaUses = (tokens, topLevel) => {
const extra = { hasExtra: false };
let leading = true;
for (const t of tokens) {
const tt = A.type(t);
if (tt === NodeType.Whitespace) continue;
if (topLevel && tt === NodeType.Comma) {
leading = true;
continue;
}
if (tt === NodeType.SimpleBlock) {
const name = loneDashedIdentOfBlock(
/** @type {SimpleBlock} */ (t),
extra
);
if (name !== undefined) {
(customMediaUses || (customMediaUses = [])).push({
name,
start: A.start(t),
end: A.end(t),
invalid: extra.hasExtra,
leading: Boolean(topLevel && leading)
});
} else {
scanCustomMediaUses(A.children(t), false);
}
} else if (tt === NodeType.Function) {
scanCustomMediaUses(A.children(t), false);
}
if (topLevel) leading = false;
}
};
/**
* Collect one `@custom-selector :--name <selector-list>` definition (last-wins) and drop the rule.
* @param {AtRule} at the `@custom-selector` at-rule
*/
const collectCustomSelector = (at) => {
const start = A.start(at);
const end = A.end(at);
const ruleEnd = input.charCodeAt(end) === CC_SEMICOLON ? end + 1 : end;
module.addPresentationalDependency(
new ConstDependency("", [start, ruleEnd])
);
const prelude = A.prelude(at);
let i = 0;
while (i < prelude.length && A.type(prelude[i]) === NodeType.Whitespace) {
i++;
}
if (i + 1 >= prelude.length || A.type(prelude[i]) !== NodeType.Colon) {
return;
}
const nameNode = prelude[i + 1];
if (
A.type(nameNode) !== NodeType.Ident ||
A.end(prelude[i]) !== A.start(nameNode)
) {
return;
}
const name = A.value(nameNode);
if (!isDashedIdentifier(name)) return;
const list = input.slice(A.end(nameNode), end).trim();
if (list.length === 0) return;
(customSelectorDefs || (customSelectorDefs = new Map())).set(name, list);
};
/**
* Record every `:--name` custom-selector reference in a selector prelude (recursing into `:is(…)` etc.) for post-walk expansion. A `:--name` is a colon immediately followed by a dashed ident.
* @param {readonly AstNode[]} tokens selector prelude (or nested) tokens
*/
const scanCustomSelectorUses = (tokens) => {
for (let k = 0; k < tokens.length; k++) {
const t = tokens[k];
const tt = A.type(t);
if (tt === NodeType.Colon) {
const next = tokens[k + 1];
if (
next &&
A.type(next) === NodeType.Ident &&
A.end(t) === A.start(next) &&
isDashedIdentifier(A.value(next))
) {
(customSelectorUses || (customSelectorUses = [])).push({
name: A.value(next),
start: A.start(t),
end: A.end(next)
});
}
} else if (tt === NodeType.Function || tt === NodeType.SimpleBlock) {
scanCustomSelectorUses(A.children(t));
}
}
};
/**
* Rewrite the collected `@custom-media` / `@custom-selector` uses now that all (possibly later-defined) definitions are known; warns on invalid, unknown, or unsupported custom-media uses.
*/
const resolveCustomMediaAndSelectors = () => {
if (customMediaUses) {
for (const use of customMediaUses) {
if (use.invalid) {
this._emitWarning(
state,
`Custom media query '${use.name}' must be used in a boolean context`,
locConverter,
use.start,
use.end
);
continue;
}
const def = customMediaDefs && customMediaDefs.get(use.name);
if (!def) {
this._emitWarning(
state,
`Unknown custom media query '${use.name}'`,
locConverter,
use.start,
use.end
);
continue;
}
if (def.kind === "unsupported") {
this._emitWarning(
state,
`Custom media query '${use.name}' uses an unsupported value ('true'/'false' or a nested custom media) and was left unresolved`,
locConverter,
use.start,
use.end
);
continue;
}
if (def.kind === "type" && !use.leading) {
this._emitWarning(
state,
`Custom media query '${use.name}' resolves to a media type and can only be used at the start of a media query`,
locConverter,
use.start,
use.end
);
continue;
}
module.addPresentationalDependency(
new ConstDependency(def.text, [use.start, use.end])
);
}
}
if (customSelectorUses) {
for (const use of customSelectorUses) {
const list = customSelectorDefs && customSelectorDefs.get(use.name);
if (list === undefined) continue;
module.addPresentationalDependency(
new ConstDependency(`:is(${list})`, [use.start, use.end])
);
}
}
};
/**
* Export the localizable idents / strings in a known property's value (`animation-name: foo`, grid line-names / template-areas, …). Top-level only, except `grid-template` recurses into `repeat(…)` and `[line-name]` blocks.
* @param {Declaration} decl the declaration
* @param {string} declPropertyName the vendor-stripped, lower-cased property name
* @returns {void}
*/
const emitKnownPropertyExports = (decl, declPropertyName) => {
/** @type {Record<string, number>} */
let parsedKeywords = Object.create(null);
const isGridProperty = Boolean(declPropertyName.startsWith("grid"));
// Only `view-transition-*` legitimately takes a dashed-ident value; other known properties keep the historical plain-ident handling.
const isViewTransitionProperty = Boolean(
declPropertyName.startsWith("view-transition")
);
const isGridTemplate = isGridProperty
? Boolean(
declPropertyName === "grid" ||
declPropertyName === "grid-template" ||
declPropertyName === "grid-template-columns" ||
declPropertyName === "grid-template-rows"
)
: false;
const keywords =
/** @type {Record<string, number>} */
(knownProperties.get(declPropertyName));
let afterExclamation = false;
/**
* Emit the ICSS export for one collected name span (a quoted string drops its delimiters). Called inline during the walk so no intermediate `values` array / per-name tuples are allocated.
* @param {number} start name start offset
* @param {number} end name end offset
* @param {boolean=} isString whether the span is a quoted string
* @returns {void}
*/
const emit = (start, end, isString) => {
const { line: sl, column: sc } = locConverter.get(start);
const { line: el, column: ec } = locConverter.get(end);
const name = unescapeRange(
isString ? start + 1 : start,
isString ? end - 1 : end
);
addCssExport(
sl,
sc,
el,
ec,
name,
getReexport(name),
[start, end],
true,
CssIcssExportDependency.EXPORT_MODE.ONCE,
isGridProperty
? CssIcssExportDependency.EXPORT_TYPE.GRID_CUSTOM_IDENTIFIER
: CssIcssExportDependency.EXPORT_TYPE.NORMAL
);
};
// Collect idents/strings to export — top-level only, except grid-template recurses (`[line-name]` blocks live in `repeat(…)`).
/** @type {(cvs: AstNode[]) => void} */
const walkExports = (cvs) => {
for (const cv of cvs) {
switch (A.type(cv)) {
case NodeType.Comma:
parsedKeywords = Object.create(null);
break;
case NodeType.Delim:
afterExclamation = A.value(cv) === "!";
break;
case NodeType.Ident: {
if (isGridTemplate) break;
if (afterExclamation) {
afterExclamation = false;
break;
}
const identifier = A.value(cv);
// `view-transition-name: --foo` — a dashed name scopes as a custom property under `dashedIdents`, not as a plain ident export.
if (isViewTransitionProperty && isDashedIdentifier(identifier)) {
if (this.options.dashedIdents) {
emitDashedIdentExport(A.start(cv), A.end(cv));
}
break;
}
// Values are almost always lowercase already — avoid the copy.
const keyword = toLowerCaseIfNeeded(identifier);
parsedKeywords[keyword] =
typeof parsedKeywords[keyword] !== "undefined"
? parsedKeywords[keyword] + 1
: 0;
if (
keywords[keyword] &&
parsedKeywords[keyword] < keywords[keyword]
) {
break;
}
emit(A.start(cv), A.end(cv));
break;
}
case NodeType.String: {
if (
declPropertyName === "animation" ||
declPropertyName === "animation-name"
) {
emit(A.start(cv), A.end(cv), true);
}
if (
declPropertyName === "grid" ||
declPropertyName === "grid-template" ||
declPropertyName === "grid-template-areas"
) {
const areas = A.unescaped(cv);
const matches = matchAll(/\b\w+\b/g, areas);
for (const match of matches) {
const areaStart = A.start(cv) + 1 + match.index;
emit(areaStart, areaStart + match[0].length, false);
}
}
break;
}
case NodeType.SimpleBlock: {
const block = /** @type {SimpleBlock} */ (cv);
if (A.blockToken(block) === "[") {
// Collect identifiers until the first non-ident token (`<line-names> = '[' <custom-ident>* ']'`).
for (const inner of A.children(block)) {
if (A.type(inner) === NodeType.Whitespace) continue;
if (A.type(inner) !== NodeType.Ident) break;
emit(A.start(inner), A.end(inner));
}
} else if (isGridTemplate) {
walkExports(A.children(block));
}
break;
}
case NodeType.Function:
if (isGridTemplate) {
walkExports(A.children(cv));
}
break;
// Other types carry no ICSS-export information.
}
}
};
walkExports(A.children(decl));
};
/**
* Pure-mode: mark the at-rule local when its prelude names a local `@keyframes` / `@counter-style` / `@container` identifier (or a `:local(…)` function), so the rule isn't flagged impure. `@container` ignores the `none`/`and`/`or`/`not` keywords.
* @param {AtRule} at the at-rule
* @param {boolean} isKeyframes whether it's `@keyframes`
* @param {boolean} isCounterStyle whether it's `@counter-style`
* @param {boolean} isContainer whether it's `@container`
* @returns {void}
*/
const markPureFromAtRulePrelude = (
at,
isKeyframes,
isCounterStyle,
isContainer
) => {
const acceptIdent = isKeyframes || isCounterStyle || isContainer;
const acceptString = isKeyframes;
for (const cv of A.prelude(at)) {
const cvType = A.type(cv);
if (cvType === NodeType.Whitespace) continue;
if (cvType === NodeType.String) {
if (acceptString) pure.markLocal();
break;
}
if (cvType === NodeType.Ident) {
if (!acceptIdent) break;
if (
isContainer &&
isContainerKeyword(source, A.start(cv), A.end(cv))
) {
continue;
}
pure.markLocal();
break;
}
if (cvType === NodeType.Function) {
if (equalsLowerCase(A.unescapedName(cv), "local")) {
pure.markLocal();
}
break;
}
}
};
// Drive the walk through SourceProcessor: structural enter / exit map to the `walkAst…Enter` / `…Exit` halves; value visitors handle url / ICSS / local-global.
/** @type {VisitorMap} */
const visitors = {
[NodeType.Comment]: commentVisitor,
[NodeType.AtRule]: {
// At-rule enter: scope save, name dispatch, prelude value context, pure-block push.
enter: (path) => {
const node = path.node;
const at = /** @type {AtRule} */ (node);
const topLevel = path.parent === null;
currentUrlRecovery = false;
advanceCommentCursor(A.start(at));
const savedAnchor = currentRule.hasLocalAnchor;
const savedLocalIdentifiers = currentRule.localIdentifiers;
// Only modules-mode walks mutate `localIdentifiers`; otherwise share
// the (empty) parent list instead of copying it per rule.
currentRule.localIdentifiers = isModules
? [...savedLocalIdentifiers]
: savedLocalIdentifiers;
const name = `@${toLowerCaseIfNeeded(A.name(at))}`;
switch (name) {
case "@namespace": {
this._emitWarning(
state,
"'@namespace' is not supported in bundled CSS",
locConverter,
A.start(at),
A.nameEnd(at)
);
break;
}
case "@charset": {
if (/** @type {CssModule} */ (module).exportType !== "style") {
const atEnd = A.end(at);
const atRuleEnd =
source.charCodeAt(atEnd) === CC_SEMICOLON ? atEnd + 1 : atEnd;
const dep = new ConstDependency("", [A.start(at), atRuleEnd]);
module.addPresentationalDependency(dep);
const string = A.prelude(at).find(
(v) => A.type(v) !== NodeType.Whitespace
);
if (string && A.type(string) === NodeType.String) {
/** @type {CssModuleBuildInfo} */
(module.buildInfo).charset = source
.slice(A.start(string) + 1, A.end(string) - 1)
.toUpperCase();
}
}
break;
}
case "@import": {
handleImportAtRule(at, topLevel);
break;
}
case "@custom-media": {
if (mayHaveCustomMedia) collectCustomMedia(at);
break;
}
case "@custom-selector": {
if (mayHaveCustomSelectors) collectCustomSelector(at);
break;
}
case "@media": {
if (mayHaveCustomMedia) scanCustomMediaUses(A.prelude(at), true);
break;
}
default: {
if (!isModules) break;
if (name === "@value") {
handleValueAtRule(at);
break;
} else if (
this.options.animation &&
OPTIONALLY_VENDOR_PREFIXED_KEYFRAMES_AT_RULE.test(name)
) {
processLocalAtRule(at, {
string: mode === "local",
identifier: mode === "local"
});
} else if (
this.options.customIdents &&
name === "@counter-style"
) {
processLocalAtRule(at, {
identifier: mode === "local"
});
} else if (this.options.container && name === "@container") {
processLocalAtRule(at, {
identifier: mode === "local" ? /^(none|and|or|not)$/ : false
});
}
}
}
// `@scope (.x) to (.y)` — walk the prelude as a selector list.
if (
isModules &&
equalsLowerCase(A.name(at), "scope") &&
A.prelude(at).length > 0
) {
walkSelectorList(
A.prelude(at),
/** @type {"local" | "global"} */ (
mode === "local" ? "local" : "global"
)
);
}
// Prelude value-visitor context; AST-handled at-rules emit their own deps so they're excluded from the local() / global() / ICSS walks.
const effectiveLocalMode = isEffectivelyLocal();
const isProcessedByLocalAtRule = isLocalHandledAtRule(name);
currentStructural = at;
currentAtRuleName = name;
dashed.active = false;
// `@import` url() is the import target — only walk its prelude for url deps on malformed-import recovery.
if (this.options.url && (name !== "@import" || currentUrlRecovery)) {
lastTokenEndForComments = A.nameEnd(at);
}
// Dashed-ident scoping over the prelude (the Ident / Function visitors emit).
dashed.active = Boolean(
this.options.dashedIdents &&
isModules &&
!isProcessedByLocalAtRule &&
effectiveLocalMode &&
!(mayHaveCustomMedia && name === "@media")
);
dashed.emit = dashed.active;
// Pure-mode: `@keyframes` / `@counter-style` / `@container` bodies are marked skip / treat-as-leaf.
let atSkipChildren = false;
let atTreatAsLeaf = false;
const isKeyframes =
OPTIONALLY_VENDOR_PREFIXED_KEYFRAMES_AT_RULE.test(name);
const isCounterStyle = name === "@counter-style";
const isContainer = name === "@container";
if (
pure.enabled &&
isModules &&
isLocalMode() &&
(isKeyframes || isCounterStyle || isContainer)
) {
if (isKeyframes || isCounterStyle) {
atSkipChildren = true;
atTreatAsLeaf = true;
}
markPureFromAtRulePrelude(
at,
isKeyframes,
isCounterStyle,
isContainer
);
}
// pure.stack push for block-bearing at-rules.
const atDecls = A.declarations(at);
const atChildRules = A.childRules(at);
const atHasBlock = Boolean(atDecls || atChildRules);
const atBlockStart = A.blockStart(at);
if (atHasBlock && atBlockStart !== -1) {
const isAtRulePrelude = isPureBodyAtRule(name);
if (isAtRulePrelude) pure.finalizeSelector();
pure.enterBlock({
isRulePrelude: isAtRulePrelude,
treatAsLeaf: atTreatAsLeaf,
ownSkip: atSkipChildren,
declarations: atDecls,
childRules: atChildRules,
preludeStart: A.start(at),
preludeEnd: atBlockStart
});
}
atRuleStateStack.push({
savedAnchor,
savedLocalIdentifiers,
name,
hasBlock: atHasBlock,
endsWithSemicolon: source.charCodeAt(A.end(at)) === CC_SEMICOLON,
fontPreloaded: false
});
},
// At-rule exit: pure-frame finalization, `suppressNextRulePrelude`, scope restore, top-level reset.
exit: (path) => {
const node = path.node;
const state = atRuleStateStack.pop();
if (!state) return;
if (state.hasBlock) {
pure.exitBlock();
} else if (
isModules &&
state.endsWithSemicolon &&
!isLocalHandledAtRule(state.name)
) {
// An unrecognized `;`-terminated at-rule: treat the next sibling's selectors as global.
suppressNextRulePrelude = true;
}
currentRule.hasLocalAnchor = state.savedAnchor;
currentRule.localIdentifiers = state.savedLocalIdentifiers;
if (path.parent === null) finishTopLevelRule(node, true);
}
},
[NodeType.QualifiedRule]: {
// Qualified-rule enter: scope setup, selector + prelude context, pure-block push; `:import` / `:export` bail via `path.skipChildren()`.
enter: (path) => {
const node = path.node;
const rule = /** @type {QualifiedRule} */ (node);
const topLevel = path.parent === null;
advanceCommentCursor(A.start(rule));
// `:--name` custom-selector references (dashed idents never collide with the `:import`/`:export` ICSS preludes below).
if (mayHaveCustomSelectors) scanCustomSelectorUses(A.prelude(rule));
// `:import(…) { … }` / `:export { … }` ICSS pseudo-rules are processed inline at top level; nested ones bail out.
if (isModules) {
const rulePrelude = A.prelude(rule);
const firstIdx = nextNonWhitespace(rulePrelude, 0);
if (
firstIdx + 1 < rulePrelude.length &&
A.type(rulePrelude[firstIdx]) === NodeType.Colon
) {
const second = rulePrelude[firstIdx + 1];
const secondType = A.type(second);
const rawName =
secondType === NodeType.Ident
? A.value(second)
: secondType === NodeType.Function
? A.name(second)
: "";
const isImport = equalsLowerCase(rawName, "import");
if (isImport || equalsLowerCase(rawName, "export")) {
if (topLevel) {
const startColon = A.start(rulePrelude[firstIdx]);
const endAfterBody = processImportOrExport(
isImport ? 0 : 1,
second,
rule
);
module.addPresentationalDependency(
new ConstDependency("", [startColon, endAfterBody])
);
if (A.blockStart(rule) !== -1) {
A.setBlockEnd(rule, endAfterBody);
}
A.setEnd(rule, endAfterBody);
} else if (A.blockStart(rule) !== -1) {
// Nested `:import` / `:export` — leave the body alone.
A.setEnd(rule, A.blockEnd(rule));
}
// Don't recurse into the body — handled inline above.
path.skipChildren();
qualifiedRuleStateStack.push({ bailed: true });
return;
}
}
}
// Reset the anchor flag for this rule's body, inheriting (copying) the parent's identifier list so nested `composes:` sees both parent and child class names.
const savedAnchor = currentRule.hasLocalAnchor;
const savedLocalIdentifiers = currentRule.localIdentifiers;
currentRule.hasLocalAnchor = false;
// Only modules-mode walks mutate `localIdentifiers`; otherwise share the (empty) parent list instead of copying it per rule.
currentRule.localIdentifiers = isModules
? [...savedLocalIdentifiers]
: savedLocalIdentifiers;
// Composes-state reset between rules (saved / restored around this rule); composesFiles is swapped out and re-created lazily only if this rule composes.
const savedPrevComposesFile = currentRule.composesPrevFile;
const savedComposesFiles = currentRule.composesFiles;
currentRule.composesPrevFile = undefined;
currentRule.composesFiles = null;
qualifiedRuleStateStack.push({
bailed: false,
savedAnchor,
savedLocalIdentifiers,
savedPrevComposesFile,
savedComposesFiles
});
// Selectors are only CSS-Modules-relevant when `isModules` holds.
const rulePrelude = A.prelude(rule);
if (isModules) {
walkSelectorList(
rulePrelude,
/** @type {"local" | "global"} */ (
mode === "local" ? "local" : "global"
)
);
}
// A malformed declaration can leave orphan `url(...)` in the prelude — let the url visitor pick those up.
currentStructural = rule;
dashed.active = false;
dashed.emit = false;
if (this.options.url && rulePrelude.length > 0) {
lastTokenEndForComments = A.start(rulePrelude[0]);
}
// Dashed-ident scoping for the deprecated `--foo: { … }` custom-property-set syntax (prelude starts with a dashed-ident).
if (
this.options.dashedIdents &&
isModules &&
rulePrelude.length > 0
) {
const first = rulePrelude[nextNonWhitespace(rulePrelude, 0)];
if (
first &&
A.type(first) === NodeType.Ident &&
isDashedIdentifier(A.value(first))
) {
const effectiveLocalMode = isEffectivelyLocal();
if (effectiveLocalMode) {
dashed.active = true;
dashed.emit = true;
}
}
}
// Pure-mode: report an impure prelude (if leaf-ish) and push the inherited-context frame before walking the body.
const ruleBlockStart = A.blockStart(rule);
pure.enterBlock({
isRulePrelude: true,
treatAsLeaf: false,
ownSkip: false,
declarations: A.declarations(rule),
childRules: A.childRules(rule),
preludeStart: A.start(rule),
preludeEnd: ruleBlockStart !== -1 ? ruleBlockStart : A.end(rule)
});
},
// Qualified-rule exit: pure-frame finalization, scope restore, top-level reset; no-op for bailed ICSS.
exit: (path) => {
const node = path.node;
const state = qualifiedRuleStateStack.pop();
if (!state || state.bailed) return;
pure.exitBlock();
currentRule.hasLocalAnchor = state.savedAnchor;
currentRule.localIdentifiers = state.savedLocalIdentifiers;
currentRule.composesPrevFile = state.savedPrevComposesFile;
currentRule.composesFiles = state.savedComposesFiles;
if (path.parent === null) finishTopLevelRule(node, false);
}
},
// Top-level declarations are parse errors (dropped by `parseAStylesheet`), so a declaration's parent is always a block.
[NodeType.Declaration]: (path) => {
const node = path.node;
const decl = /** @type {Declaration} */ (node);
// Reset value-visitor context, read by the value visitors below.
currentStructural = decl;
dashed.active = false;
dashed.emit = false;
// Position `lastTokenEndForComments` just past the `:` so a magic comment before a url() is found (every mode).
let colonPos = A.nameEnd(decl);
while (
colonPos < source.length &&
source.charCodeAt(colonPos) !== CC_COLON
) {
colonPos++;
}
lastTokenEndForComments = colonPos + 1;
currentDeclIsKnownProperty = false;
currentDeclComposesSkip = false;
// Property-name analysis and value exports are CSS-Modules-only; a plain
// stylesheet's declarations need no per-decl name slice / known-property lookup.
if (!isModules) return;
const nameStart = A.nameStart(decl);
const nameEnd = A.nameEnd(decl);
// Range-based name analysis — the common declaration never
// slices its name out of the source.
/** @type {string | undefined} */
let declName;
/** @type {string | undefined} */
let declPropertyName;
if (source.charCodeAt(nameStart) === CC_HYPHEN_MINUS) {
// Vendor-prefixed or custom property — rare, take the string path.
declName = A.name(decl);
declPropertyName = toLowerCaseIfNeeded(
declName.replace(VENDOR_PREFIX, "")
);
currentDeclIsKnownProperty = knownProperties.has(declPropertyName);
} else {
declPropertyName = knownPropertyForRange(
knownPropertyIndex,
source,
nameStart,
nameEnd
);
// Cache the known-property flag so the per-token value visitors don't recompute it.
currentDeclIsKnownProperty = declPropertyName !== undefined;
}
const effectiveLocalMode = isEffectivelyLocal();
// `composes:` with a local anchor: its strip-dep covers the whole declaration, so suppress the value's local/global/dashed/ICSS rewrites.
currentDeclComposesSkip =
currentRule.hasLocalAnchor &&
(declPropertyName !== undefined
? COMPOSES_PROPERTY.test(declPropertyName)
: rangeEqualsLowerCase(source, nameStart, nameEnd, "composes") ||
rangeEqualsLowerCase(source, nameStart, nameEnd, "compose-with"));
if (currentDeclComposesSkip) emitComposesWithAnchor(decl);
const skipForComposes = currentDeclComposesSkip;
// Known-property value localization (`animation-name: foo` exports `foo`).
if (effectiveLocalMode && currentDeclIsKnownProperty) {
emitKnownPropertyExports(
decl,
/** @type {string} */ (declPropertyName)
);
}
// Dashed-ident (custom-property) export of the property name; the value's dashed idents are scoped by the Ident / Function visitors (top-level only for unknown properties).
if (
this.options.dashedIdents &&
effectiveLocalMode &&
!skipForComposes
) {
// Only the `-`-prefixed path can carry a dashed ident.
if (declName !== undefined && isDashedIdentifier(declName)) {
emitDashedIdentExport(nameStart, nameEnd);
}
dashed.active = true;
dashed.emit = !currentDeclIsKnownProperty;
}
// ICSS-symbol rewrite (`color: foo` when `foo` is `@value`-defined), skipping known properties, the composes anchor, and dashed idents (handled above). Nothing to rewrite without definitions — don't slice the name.
if (
!skipForComposes &&
!currentDeclIsKnownProperty &&
icssDefinitions.size !== 0
) {
if (declName === undefined) {
declName = source.slice(nameStart, nameEnd);
}
if (
!(dashed.active && isDashedIdentifier(declName)) &&
icssDefinitions.has(declName)
) {
emitICSSSymbol(declName, nameStart, nameEnd);
}
}
},
// Value-level visitors decide handling from the enclosing node via `urlActive()` / `localGlobalActive()` / `icssActive()`.
[NodeType.Url]: (path) => {
const node = path.node;
const url = /** @type {UrlToken} */ (node);
if (!urlActive()) return;
// Skip bare url-tokens for a known property in CSS-Modules local mode.
if (
currentStructural &&
A.type(currentStructural) === NodeType.Declaration &&
isModules &&
currentDeclIsKnownProperty &&
isEffectivelyLocal()
) {
return;
}
const { ignored, options: urlComments } = magicCommentsIn(
[lastTokenEndForComments, A.end(node)],
lastTokenEndForComments,
A.end(node)
);
if (ignored) return;
let value = normalizeUrl(
input.slice(A.contentStart(url), A.contentEnd(url)),
false
);
// Ignore `url()`, `url('')` and `url("")`, they are valid by spec
if (value.length === 0) return;
if (isModules) {
const def = icssDefinitions.get(value);
if (def) {
if (def.value !== undefined) {
const raw = def.value.trim();
value =
(raw.startsWith('"') && raw.endsWith('"')) ||
(raw.startsWith("'") && raw.endsWith("'"))
? normalizeUrl(raw.slice(1, -1), true)
: normalizeUrl(raw, false);
if (value.length === 0) return;
} else {
this._emitWarning(
state,
`'@value' identifier '${value}' was imported from another module and cannot be used inside 'url()' — only locally defined values are supported here`,
locConverter,
A.start(node),
A.end(node)
);
return;
}
}
}
const dep = new CssUrlDependency(
value,
[A.start(node), A.end(node)],
"url"
);
setDepLoc(dep, A.start(node), A.end(node));
applyResourceHintDefaults(
dep,
value,
urlComments,
rangeLoc(A.start(node), A.end(node))
);
module.addDependency(dep);
module.addCodeGenerationDependency(dep);
},
[NodeType.Comma](path) {
const node = path.node;
if (urlActive()) lastTokenEndForComments = A.start(node);
},
[NodeType.Function]: {
enter: (path) => {
const node = path.node;
const fn = /** @type {FunctionNode} */ (node);
const fnameRaw = A.unescapedName(fn);
// `equalsLowerCase` keeps this visitor free of a per-function
// lowercased copy — functions are the densest value nodes.
const isLocalFn = equalsLowerCase(fnameRaw, "local");
const isGlobalFn = !isLocalFn && equalsLowerCase(fnameRaw, "global");
if (urlActive()) emitUrlFunction(fn, fnameRaw);
if (localGlobalActive() && (isLocalFn || isGlobalFn)) {
processLocalOrGlobalFunction(fn, isLocalFn ? 1 : 2);
}
if (
icssActive() &&
!isLocalFn &&
!isGlobalFn &&
!(dashed.active && isDashedIdentifier(fnameRaw)) &&
icssDefinitions.has(fnameRaw)
) {
emitICSSSymbol(fnameRaw, A.nameStart(fn), A.nameEnd(fn));
}
// Dashed-ident scoping: handle this function, then set the child nesting level's state for the walk.
dashed.push();
if (dashed.active) {
if (isLocalFn || isGlobalFn) {
// `local()` / `global()` dashed args go through the ICSS path above, not here.
dashed.active = false;
} else if (
equalsLowerCase(fnameRaw, "var") ||
equalsLowerCase(fnameRaw, "style")
) {
// `var(--foo, …)` / `style(--foo, …)`: emit the first ident; the fallback doesn't self-emit.
processDashedIdentInVarFunction(fn);
dashed.emit = false;
} else if (dashed.emit && isDashedIdentifier(A.name(fn))) {
// Custom-function call `--my-func(args)` — the name is the exported dashed-ident.
emitDashedIdentExport(A.nameStart(fn), A.nameEnd(fn));
}
}
},
exit: () => {
dashed.pop();
}
},
[NodeType.Ident](path) {
const node = path.node;
// Fast exit before slicing the ident value: outside dashed-ident scoping
// and ICSS context (any non-CSS-Modules stylesheet) a bare ident carries
// no work, and idents are the most common node, so skipping the per-ident
// `value` slice matters. With no `@value`/`:import` definitions the ICSS
// probe can never hit, so it doesn't warrant the slice either.
const dashedActive = dashed.active;
const icss = icssActive() && icssDefinitions.size !== 0;
if (!dashedActive && !icss) return;
const identValue = A.value(node);
if (dashedActive && isDashedIdentifier(identValue)) {
// Dashed idents are scoped here, never `@value` ICSS-rewritten.
if (!dashed.emit) return;
// Resolve the `--foo from "./x.css"` / `--foo from global` import suffix via sibling lookahead.
const siblings = childrenOf(path.parent);
const i = siblings ? siblings.indexOf(node) : -1;
if (siblings && i !== -1) {
const j = nextNonWhitespace(siblings, i + 1);
if (
j < siblings.length &&
A.type(siblings[j]) === NodeType.Ident &&
rangeEqualsLowerCase(
input,
A.start(siblings[j]),
A.end(siblings[j]),
"from"
)
) {
const fromIdent = siblings[j];
const sourceNode = siblings[nextNonWhitespace(siblings, j + 1)];
if (
sourceNode &&
A.type(sourceNode) === NodeType.Ident &&
rangeEquals(
input,
A.start(sourceNode),
A.end(sourceNode),
"global"
)
) {
emitDashedIdentFromGlobal(A.end(node), A.end(sourceNode));
return;
}
if (sourceNode && A.type(sourceNode) === NodeType.String) {
emitDashedIdentImport(
A.start(node),
A.end(node),
A.start(fromIdent),
A.end(sourceNode),
input.slice(A.start(sourceNode) + 1, A.end(sourceNode) - 1)
);
return;
}
}
}
emitDashedIdentExport(A.start(node), A.end(node));
return;
}
if (!icss) return;
if (icssDefinitions.has(identValue)) {
emitICSSSymbol(identValue, A.start(node), A.end(node));
}
}
};
// `as` selects the top-level production (§5.3): a `style` attribute is a
// block's contents, everything else a full stylesheet (the default). `as`
// and `skip` are per-parse config on the processor; only `locConverter`
// (per-source) is passed to `process`. One skip set: selector prelude +
// unread value leaves (non-modules only; CSS Modules needs its selectors
// and ICSS-captured values).
new SourceProcessor({
as: /** @type {"stylesheet" | "block-contents"} */ (this.options.as),
// Non-modules parses skip selector preludes, but `@custom-selector` expansion needs them, so keep them when the file may use one.
skip: isModules
? undefined
: mayHaveCustomSelectors
? SKIP_NON_MODULES_KEEP_SELECTORS
: SKIP_NON_MODULES
})
.use(/** @type {VisitorMap} */ (visitors))
.process(source, { locConverter });
if (customMediaUses || customSelectorUses) resolveCustomMediaAndSelectors();
/** @type {BuildInfo} */
(module.buildInfo).strict = true;
// Topologically sort the `composes … from` files and tag each file's first import dep with `sourceOrder` for cascade-correct load order (cycles keep their natural position).
if (composesFirstFileImport.size > 1) {
topologicalSort(
composesGraph,
[...composesFirstFileImport.keys()],
(file, i) => {
/** @type {CssIcssImportDependency} */
(composesFirstFileImport.get(file)).sourceOrder = i;
}
);
}
const buildMeta = /** @type {BuildMeta} */ (state.module.buildMeta);
buildMeta.exportsType = this.options.namedExports ? "namespace" : "default";
buildMeta.defaultObject = this.options.namedExports
? false
: "redirect-warn";
if (cssExportEntries.length > 0) {
module.addDependency(new CssIcssExportDependency(cssExportEntries));
}
if (
/** @type {CssModule} */ (module).exportType === "text" ||
/** @type {CssModule} */ (module).exportType === "css-style-sheet"
) {
module.addDependency(new StaticExportsDependency(["default"], true));
} else {
module.addDependency(new StaticExportsDependency([], true));
}
return state;
}
}
module.exports = CssParser;