Files
webstudio/packages/css-data/src/parse-css.ts

1147 lines
35 KiB
TypeScript

import { camelCase } from "change-case";
import * as csstree from "css-tree";
import type {
StyleValue,
CssProperty,
StyleProperty,
} from "@webstudio-is/css-engine";
import {
parseCssValue as parseCssValueLonghand,
parseCssVar,
} from "./parse-css-value";
import { expandShorthands } from "./shorthands";
import { shorthandProperties } from "./__generated__/shorthand-properties";
const shorthandSet = new Set<string>(shorthandProperties);
// css-tree exposes `skip` as a Symbol on the walk function itself.
// Returning it from an enter() callback prevents descent into child nodes.
const walkSkip = (csstree.walk as unknown as { skip: symbol }).skip;
export type ParsedStyleDecl = {
breakpoint?: string;
selector: string;
state?: string;
property: CssProperty;
value: StyleValue;
};
// @todo we don't parse correctly most of them if not all
const prefixedProperties = [
"-webkit-box-orient",
"-webkit-line-clamp",
"-webkit-font-smoothing",
"-moz-osx-font-smoothing",
"-webkit-tap-highlight-color",
"-webkit-overflow-scrolling",
"-webkit-text-stroke",
"-webkit-text-stroke-color",
"-webkit-text-stroke-width",
];
const prefixes = ["webkit", "moz", "ms", "o"];
const prefixRegex = new RegExp(`^-(${prefixes.join("|")})-`);
const normalizeProperty = (property: string): CssProperty => {
// convert unprefixed used by webflow version into prefixed one
if (property === "tap-highlight-color") {
return "-webkit-tap-highlight-color";
}
if (property === "font-smoothing") {
return "-webkit-font-smoothing";
}
if (prefixedProperties.includes(property)) {
return property as CssProperty;
}
// remove old or unexpected prefixes
return property.replace(prefixRegex, "") as CssProperty;
};
/**
* Store prefixed properties without change
* and convert to camel case only unprefixed properties
* @todo stop converting to camel case and use hyphenated format
*/
export const camelCaseProperty = (
property: CssProperty | StyleProperty
): StyleProperty => {
property = normalizeProperty(property) as CssProperty | StyleProperty;
// these are manually added with pascal case
if (
property === "-webkit-font-smoothing" ||
property === "WebkitFontSmoothing"
) {
return "WebkitFontSmoothing";
}
if (
property === "-moz-osx-font-smoothing" ||
property === "MozOsxFontSmoothing"
) {
return "MozOsxFontSmoothing";
}
if (property.startsWith("-")) {
return property as StyleProperty;
}
return camelCase(property) as StyleProperty;
};
const parseCssValue = (property: CssProperty, value: string) => {
const expanded = new Map(expandShorthands([[property, value]]));
const final = new Map<CssProperty, StyleValue>();
for (const [property, value] of expanded) {
final.set(property, parseCssValueLonghand(property, value));
}
return final;
};
/**
* Recursively resolve all var() references in a CSS value string.
* Handles nested var() in fallbacks (e.g. var(--a, var(--b))).
* Returns `walkSkip` from enter() to prevent double-processing nested nodes.
*
* Resolution order per var(): direct custom property → cssVars → inline fallback
* (fallback is itself recursively resolved if it contains var()).
*
* Returns undefined when the value contains no var() (fast path) or when the
* recursion depth limit is exceeded.
*/
const resolveVars = (
value: string,
customProperties: Map<string, string>,
cssVars: Map<string, string> | undefined,
depth = 0
):
| { resolved: string; dropped: string[]; usedCssVarsSubstitution: boolean }
| undefined => {
if (!value.includes("var(") || depth > 8) {
return;
}
let ast: csstree.CssNode;
try {
ast = csstree.parse(value, { context: "value", positions: true });
} catch {
return;
}
const subs: Array<{ start: number; end: number; text: string }> = [];
const dropped: string[] = [];
let usedCssVarsSubstitution = false;
csstree.walk(ast, {
visit: "Function",
enter(node) {
const fnNode = node as csstree.FunctionNode;
if (fnNode.name !== "var" || !fnNode.loc) {
return;
}
const varRef = parseCssVar(fnNode);
if (!varRef) {
return walkSkip;
}
const direct =
customProperties.get(`--${varRef.value}`) ??
cssVars?.get(`--${varRef.value}`);
if (direct !== undefined) {
if (!customProperties.has(`--${varRef.value}`)) {
usedCssVarsSubstitution = true;
}
subs.push({
start: fnNode.loc.start.offset,
end: fnNode.loc.end.offset,
text: direct,
});
return walkSkip;
}
// Try inline fallback — may itself contain var() references.
if (varRef.fallback?.type === "unparsed") {
const fbValue = varRef.fallback.value;
// If the fallback itself has no var(), use it directly.
// Otherwise recursively resolve any nested vars inside it.
const fallbackResult = fbValue.includes("var(")
? resolveVars(fbValue, customProperties, cssVars, depth + 1)
: { resolved: fbValue, dropped: [] };
if (
fallbackResult !== undefined &&
fallbackResult.dropped.length === 0
) {
subs.push({
start: fnNode.loc.start.offset,
end: fnNode.loc.end.offset,
text: fallbackResult.resolved,
});
return walkSkip;
}
}
dropped.push(`--${varRef.value}`);
return walkSkip;
},
});
let resolved = value;
for (const s of [...subs].reverse()) {
resolved = resolved.slice(0, s.start) + s.text + resolved.slice(s.end);
}
// Substituted text may itself contain var() references (transitive vars).
// Re-resolve until no more substitutions are possible.
if (resolved !== value && resolved.includes("var(")) {
const deeper = resolveVars(resolved, customProperties, cssVars, depth + 1);
if (deeper) {
return {
resolved: deeper.resolved,
dropped: [...new Set([...dropped, ...deeper.dropped])],
usedCssVarsSubstitution:
usedCssVarsSubstitution || deeper.usedCssVarsSubstitution,
};
}
}
return { resolved, dropped, usedCssVarsSubstitution };
};
const preserveOriginalVarLonghands = (
property: string,
value: string,
parsedCss: Map<CssProperty, StyleValue>
) => {
const originalExpanded = new Map(expandShorthands([[property, value]]));
const wholeValueVarText = getWholeValueFallbacklessVarText(value);
for (const [prop, expandedValue] of originalExpanded) {
if (!expandedValue.includes("var(")) {
continue;
}
parsedCss.set(
prop as CssProperty,
parseCssValueLonghand(prop as CssProperty, expandedValue)
);
}
// Recover unresolved var()->longhand mapping when shorthand expansion drops
// the var() token (e.g. outline/border cases that default to currentcolor).
for (const varText of getFallbacklessVarTexts(value)) {
const varName = getVarNameFromVarText(varText);
if (varName === undefined) {
continue;
}
const placeholder = inferVarPlaceholder(varName);
if (placeholder === undefined) {
continue;
}
const substituted = value.split(varText).join(placeholder);
const expandedWithPlaceholder = new Map(
expandShorthands([[property, substituted]])
);
const normalizedPlaceholder = normalizeCssValueText(placeholder);
for (const [prop, expandedValue] of expandedWithPlaceholder) {
if (normalizeCssValueText(expandedValue) !== normalizedPlaceholder) {
continue;
}
parsedCss.set(
prop as CssProperty,
parseCssValueLonghand(prop as CssProperty, varText)
);
}
}
if (wholeValueVarText !== undefined) {
const preservedVarLonghands = new Map<CssProperty, StyleValue>();
for (const prop of parsedCss.keys()) {
preservedVarLonghands.set(
prop,
parseCssValueLonghand(prop, wholeValueVarText)
);
}
if (preservedVarLonghands.size > 0) {
return preservedVarLonghands;
}
}
const hasAnyPreservedVar = [...parsedCss.values()].some((styleValue) => {
const serialized = JSON.stringify(styleValue);
return serialized.includes('"type":"var"') || serialized.includes("var(");
});
const firstFallbacklessVarText = getFallbacklessVarTexts(value)[0];
if (firstFallbacklessVarText !== undefined) {
for (const [prop, styleValue] of parsedCss) {
if (styleValue.type !== "invalid") {
continue;
}
parsedCss.set(
prop,
parseCssValueLonghand(prop, firstFallbacklessVarText)
);
}
}
// Last-resort authored-var preservation for unresolved shorthand vars.
// Some complex shorthands (animation/background/mask/transition/...) still
// don't provide enough signal for placeholder-based longhand mapping.
// When no var() survived at all, keep the first unresolved var() on all
// parsed longhands so authored references are not lost.
if (!hasAnyPreservedVar) {
if (firstFallbacklessVarText !== undefined) {
for (const prop of parsedCss.keys()) {
parsedCss.set(
prop,
parseCssValueLonghand(prop, firstFallbacklessVarText)
);
}
}
}
// css-tree shorthand expansion doesn't currently recover background-image
// from authored gradient shorthands that still contain var() references.
if (
property === "background" &&
value.includes("gradient(") &&
value.includes("var(")
) {
parsedCss.set(
"background-image",
parseCssValueLonghand("background-image", value)
);
}
return parsedCss;
};
const preserveOriginalBackgroundImageVars = (
property: string,
value: string,
parsedCss: Map<CssProperty, StyleValue>
) => {
if (
property === "background" &&
value.includes("gradient(") &&
value.includes("var(")
) {
parsedCss.set(
"background-image",
parseCssValueLonghand("background-image", value)
);
}
return parsedCss;
};
const getSingleFallbacklessVarText = (value: string): string | undefined => {
try {
const ast = csstree.parse(value, { context: "value" });
const vars: string[] = [];
let hasFallback = false;
csstree.walk(ast, {
visit: "Function",
enter(node) {
const fnNode = node as csstree.FunctionNode;
if (fnNode.name !== "var") {
return;
}
const parsedVar = parseCssVar(fnNode);
if (!parsedVar || parsedVar.fallback !== undefined) {
hasFallback = true;
return walkSkip;
}
vars.push(csstree.generate(fnNode));
},
});
if (hasFallback || vars.length !== 1) {
return;
}
return vars[0];
} catch {
return;
}
};
const getFallbacklessVarTexts = (value: string): string[] => {
try {
const ast = csstree.parse(value, { context: "value" });
const vars: string[] = [];
csstree.walk(ast, {
visit: "Function",
enter(node) {
const fnNode = node as csstree.FunctionNode;
if (fnNode.name !== "var") {
return;
}
const parsedVar = parseCssVar(fnNode);
if (!parsedVar || parsedVar.fallback !== undefined) {
return;
}
vars.push(csstree.generate(fnNode));
},
});
return [...new Set(vars)];
} catch {
return [];
}
};
const getVarNameFromVarText = (varText: string): string | undefined => {
const match = varText.trim().match(/^var\(\s*(--[\w-]+)\s*\)$/);
return match?.[1];
};
const normalizeCssValueText = (value: string): string => {
try {
return csstree.generate(csstree.parse(value, { context: "value" }));
} catch {
return value;
}
};
const inferVarPlaceholder = (varName: string): string | undefined => {
if (/(^|-)color(s)?($|-)|(^|-)fill($|-)|(^|-)stroke($|-)/.test(varName)) {
return "rgb(1,2,3)";
}
if (/(^|-)style($|-)|(^|-)line($|-)/.test(varName)) {
return "solid";
}
if (
/(^|-)(width|height|size|radius|thickness|offset|gap|inset|spacing|margin|padding)($|-)/.test(
varName
)
) {
return "1px";
}
if (/(^|-)opacity($|-)/.test(varName)) {
return "0.5";
}
return;
};
const getWholeValueFallbacklessVarText = (
value: string
): string | undefined => {
const singleVarText = getSingleFallbacklessVarText(value);
if (singleVarText === undefined) {
return;
}
try {
const normalizedValue = csstree.generate(
csstree.parse(value, { context: "value" })
);
if (normalizedValue !== singleVarText) {
return;
}
return singleVarText;
} catch {
return;
}
};
const hasOnlyFallbacklessVars = (value: string): boolean => {
let hasVar = false;
try {
const ast = csstree.parse(value, { context: "value" });
let allFallbackless = true;
csstree.walk(ast, {
visit: "Function",
enter(node) {
const fnNode = node as csstree.FunctionNode;
if (fnNode.name !== "var") {
return;
}
const parsedVar = parseCssVar(fnNode);
if (!parsedVar) {
allFallbackless = false;
return walkSkip;
}
hasVar = true;
if (parsedVar.fallback !== undefined) {
allFallbackless = false;
return walkSkip;
}
},
});
return hasVar && allFallbackless;
} catch {
return false;
}
};
// css-tree keeps escaped selector identifiers as-is.
// Example: `.border-\[color\:var\(--border-color\)\]` is parsed with
// node.name = "border-\\[color\\:var\\(--border-color\\)\\]".
// We unescape it so class names match authored HTML class attributes.
const unescapeCssIdentifier = (value: string): string =>
value.replace(/\\([0-9a-fA-F]{1,6}\s?|.)/g, (_match, escaped: string) => {
const hexMatch = escaped.match(/^([0-9a-fA-F]{1,6})\s?$/);
if (hexMatch) {
const codePoint = Number.parseInt(hexMatch[1], 16);
return String.fromCodePoint(codePoint);
}
return escaped;
});
/**
* Substitute var() references in a shorthand value then expand the result.
* Works for any shorthand (background, border, transition, font…).
*
* Returns undefined when the value contains no var() (fast path).
* Returns { result: empty Map, droppedVars } when all vars are unresolvable.
* Returns { result: parsed Map, droppedVars } when some vars resolved and
* some did not — droppedVars is non-empty and an error should be reported,
* but the partially-resolved value is still attempted (may produce invalid).
*
* Special case — cross-rule single var():
* When the entire shorthand value is exactly one var(--x) and --x is defined
* only in cssVars (not in the same-rule customProperties), the var() reference
* is preserved in the expanded longhands instead of being inlined. The
* resolved value is used solely to guide shorthand expansion (i.e. to decide
* which longhand sub-property the value belongs to). Longhands whose
* expanded string differs from the resolved var value are stored as concrete
* (reset/initial) values, while the one longhand that received the var's
* value is stored as var(--x).
*/
const substituteVarsInShorthand = (
property: string,
value: string,
customProperties: Map<string, string>,
cssVars?: Map<string, string>
):
| { result: Map<CssProperty, StyleValue>; droppedVars: string[] }
| undefined => {
// Cross-rule single-var preservation: when the whole shorthand value is a
// single var(--x) whose definition lives in another rule (cssVars only),
// expand with the resolved concrete value to find the right sub-property,
// but store the original var() text instead of the concrete value.
//
// Two sub-cases:
// Single-token var (--clr: #f00 → background: var(--clr))
// expandShorthands maps one longhand to the resolved token; that
// longhand gets var(), reset longhands get their concrete defaults.
//
// Multi-token var (--b: 2px solid red → border: var(--b))
// No individual longhand string equals the full resolved text, so
// var() is assigned to ALL longhands (the var controls all of them).
//
// resolvedText is normalised through csstree so that values like
// `rgb(255, 0, 0)` (with spaces) compare equal to the `rgb(255,0,0)`
// that expandShorthands produces.
const crossRuleVarMatch = value.trim().match(/^var\(\s*(--[\w-]+)[^)]*\)$/);
if (crossRuleVarMatch !== null && cssVars !== undefined) {
const varName = crossRuleVarMatch[1];
if (!customProperties.has(varName) && cssVars.has(varName)) {
const rawResolved = cssVars.get(varName)!.trim();
let normalizedResolved = rawResolved;
try {
normalizedResolved = csstree.generate(
csstree.parse(rawResolved, { context: "value" })
);
} catch {
// keep rawResolved if parsing fails
}
const expandedStrings = new Map(
expandShorthands([[property, normalizedResolved]])
);
const anyMatch = [...expandedStrings.values()].some(
(s) => s === normalizedResolved
);
const originalVarText = value.trim();
const result = new Map<CssProperty, StyleValue>();
for (const [prop, expandedStr] of expandedStrings) {
if (!anyMatch || expandedStr === normalizedResolved) {
// Multi-token var (no match anywhere) → preserve var() on every
// longhand. Single-token var → preserve var() only on the longhand(s)
// that actually received the var's value.
result.set(
prop as CssProperty,
parseCssValueLonghand(prop as CssProperty, originalVarText)
);
} else {
// Longhand received a reset/default value — store it as concrete.
result.set(
prop as CssProperty,
parseCssValueLonghand(prop as CssProperty, expandedStr)
);
}
}
return { result, droppedVars: [] };
}
}
const resolution = resolveVars(value, customProperties, cssVars);
if (resolution === undefined) {
return;
}
const { resolved, dropped } = resolution;
const { usedCssVarsSubstitution } = resolution;
if (resolved === value) {
// No substitution happened.
if (dropped.length === 0) {
// No recognizable var() nodes — let caller fall through to default.
return;
}
if (hasOnlyFallbacklessVars(value)) {
const parsed = preserveOriginalVarLonghands(
property,
value,
parseCssValue(property as CssProperty, value)
);
if (parsed.size > 0) {
return {
result: parsed,
droppedVars: dropped,
};
}
}
// All vars were unresolvable — signal that the property will be dropped.
return { result: new Map(), droppedVars: dropped };
}
let parsed = parseCssValue(property as CssProperty, resolved);
if (value.includes("var(") && usedCssVarsSubstitution) {
parsed = preserveOriginalBackgroundImageVars(property, value, parsed);
}
return {
result: parsed,
droppedVars: dropped,
};
};
const cssTreeTryParse = (input: string) => {
try {
const ast = csstree.parse(input);
return ast;
} catch {
return;
}
};
type Selector = {
name: string;
state?: string;
};
export type ParseCssResult = {
styles: ParsedStyleDecl[];
errors: string[];
};
/**
* Extract all CSS custom property (--*) declarations from a CSS string,
* regardless of which selector rule they appear in.
* Useful when CSS from multiple rules (e.g. Tailwind utility classes) needs
* to be resolved together as if they apply to the same element.
*/
export const extractCssCustomProperties = (
css: string
): Map<string, string> => {
const map = new Map<string, string>();
const ast = cssTreeTryParse(css);
if (ast === undefined) {
return map;
}
csstree.walk(ast, {
visit: "Declaration",
enter(node) {
const decl = node as csstree.Declaration;
const prop = normalizeProperty(decl.property);
if (prop.startsWith("--")) {
map.set(prop, csstree.generate(decl.value));
}
},
});
return map;
};
export const parseCss = (
css: string,
cssVars: Map<string, string>
): ParseCssResult => {
const ast = cssTreeTryParse(css);
const stylesMap = new Map<string, ParsedStyleDecl>();
const errors: string[] = [];
if (ast === undefined) {
return { styles: [], errors };
}
// Track context as we traverse — use a stack to support nested @media
const atruleStack: csstree.Atrule[] = [];
let currentRule: csstree.Rule | undefined;
// Custom properties declared in the current rule, used to resolve var() in shorthand expansion.
// Pre-populated for each rule (two-pass) so that forward references like
// border-color: rgb(x y z / var(--op)); --op: 0.6;
// resolve correctly regardless of declaration order.
const customProperties = new Map<string, string>();
const collectRuleCustomProperties = (rule: csstree.Rule) => {
customProperties.clear();
csstree.walk(rule, {
visit: "Declaration",
enter(node) {
const decl = node as csstree.Declaration;
const prop = normalizeProperty(decl.property);
if (prop.startsWith("--")) {
customProperties.set(prop, csstree.generate(decl.value));
}
},
});
};
csstree.walk(ast, {
enter(node) {
if (node.type === "Atrule") {
atruleStack.push(node);
} else if (node.type === "Rule") {
currentRule = node;
collectRuleCustomProperties(node);
}
},
leave(node) {
if (node.type === "Atrule") {
atruleStack.pop();
} else if (node.type === "Rule") {
currentRule = undefined;
}
// Process declarations
if (
node.type !== "Declaration" ||
!currentRule ||
currentRule.prelude.type === undefined
) {
return;
}
// All enclosing at-rules must be @media — reject @supports, @keyframes, etc.
if (atruleStack.some((atrule) => atrule.name !== "media")) {
return;
}
let breakpoint: undefined | string;
let invalidBreakpoint = false;
// Collect and flatten all enclosing @media preludes
const flattenedParts: string[] = [];
for (const atrule of atruleStack) {
if (atrule.prelude?.type === "AtrulePrelude") {
let hasNonPxWidthUnit = false;
let hasPrintMedia = false;
let currentFeatureName: string | undefined;
csstree.walk(atrule.prelude, {
enter: (node) => {
if (node.type === "MediaQuery") {
// Mutates AST in-place to normalize media type for breakpoint string generation;
// the AST is discarded after parseCss returns so this is safe.
if (node.mediaType === "screen" || node.mediaType === "all") {
node.mediaType = null;
}
if (node.mediaType === "print") {
hasPrintMedia = true;
}
}
if (node.type === "Feature") {
currentFeatureName = node.name;
}
// Only reject non-px units on width features (min-width, max-width)
if (
node.type === "Dimension" &&
node.unit !== "px" &&
(currentFeatureName === "min-width" ||
currentFeatureName === "max-width")
) {
hasNonPxWidthUnit = true;
}
},
leave: (node) => {
if (node.type === "Feature") {
currentFeatureName = undefined;
}
},
});
if (hasPrintMedia || hasNonPxWidthUnit) {
invalidBreakpoint = true;
break;
}
const generated = csstree.generate(atrule.prelude);
if (generated) {
flattenedParts.push(generated);
}
}
}
if (!invalidBreakpoint && flattenedParts.length > 0) {
breakpoint = flattenedParts.join(" and ");
}
if (invalidBreakpoint || currentRule.prelude.type !== "SelectorList") {
return;
}
const selectors: Selector[] = [];
for (const node of currentRule.prelude.children) {
if (node.type !== "Selector") {
continue;
}
let selector: Selector | undefined = undefined;
const children = node.children.toArray();
const startsWithNesting = children[0]?.type === "NestingSelector";
for (let index = 0; index < children.length; index += 1) {
const childNode = children[index];
let name: string = "";
let state: string | undefined;
switch (childNode.type) {
case "TypeSelector":
name = childNode.name;
break;
case "ClassSelector":
name = `.${childNode.name}`;
break;
case "IdSelector":
name = `#${childNode.name}`;
break;
case "AttributeSelector":
// for example &[data-state=active]
if (startsWithNesting && index === 1 && children.length === 2) {
state = csstree.generate(childNode);
} else {
name = csstree.generate(childNode);
}
break;
case "PseudoClassSelector": {
// First pseudo selector is not a state but an element selector, e.g. :root
if (selector) {
state = `:${childNode.name}`;
} else {
name = `:${childNode.name}`;
}
break;
}
case "PseudoElementSelector":
state = `::${childNode.name}`;
break;
case "Combinator":
// " " vs " > "
name =
childNode.name === " " ? childNode.name : ` ${childNode.name} `;
break;
}
if (selector) {
selector.name += name;
if (state) {
selector.state = state;
}
} else {
selector = { name, state };
}
}
if (selector) {
selectors.push(selector);
selector = undefined;
}
}
const stringValue = csstree.generate(node.value);
const property = normalizeProperty(node.property);
let parsedCss: Map<CssProperty, StyleValue>;
if (shorthandSet.has(property)) {
const substituted = substituteVarsInShorthand(
property,
stringValue,
customProperties,
cssVars
);
if (substituted !== undefined) {
parsedCss = substituted.result;
for (const varName of substituted.droppedVars) {
errors.push(
`"${property}" was not applied because ${varName} could not be resolved`
);
}
} else {
parsedCss = parseCssValue(property, stringValue);
}
} else {
parsedCss = parseCssValue(property, stringValue);
}
for (const { name: selector, state } of selectors) {
for (const [prop, value] of parsedCss) {
const normalizedProperty = normalizeProperty(prop);
const styleDecl: ParsedStyleDecl = {
selector,
property: normalizedProperty,
value,
};
if (breakpoint) {
styleDecl.breakpoint = breakpoint;
}
if (state) {
styleDecl.state = state;
}
// deduplicate styles within selector and state by using map
stylesMap.set(
`${breakpoint}:${selector}:${state}:${normalizedProperty}`,
styleDecl
);
}
}
},
});
return { styles: Array.from(stylesMap.values()), errors };
};
export type ParsedClassSelector = {
/** Token name: "card" for .card, "card__title" for .card .title */
tokenName: string;
/** Target class names (the last segment): ["card"] or ["title"] */
classNames: string[];
/**
* Non-class selector suffixes on the target: attribute selectors, pseudo-classes, pseudo-elements.
* e.g. ["[disabled]"] for .btn[disabled], [":hover"] for .card:hover
*/
states?: string[];
/**
* Ancestor constraints for nested selectors (e.g., .card .title).
* Ordered from outermost to innermost ancestor.
* Each entry's combinator describes the relationship to the next segment.
*/
ancestors?: Array<{
classNames: string[];
combinator: "descendant" | "child";
}>;
};
/**
* Parse a selector string and determine if it's a class-based selector
* suitable for token extraction. Uses css-tree to handle the full CSS
* selector spec.
*
* Supports simple selectors, compound selectors, and nested selectors
* using descendant (" ") and child (">") combinators where ALL segments
* are class-based.
*
* Supported patterns:
* .card → { tokenName: "card", classNames: ["card"] }
* .card.active → { tokenName: "card.active", classNames: ["card", "active"] }
* .btn[disabled] → { tokenName: "btn", ..., states: ["[disabled]"] }
* .card:hover → { tokenName: "card", ..., states: [":hover"] }
* .card .title → { tokenName: "card__title", ..., ancestors: [{classNames:["card"], combinator:"descendant"}] }
* .card > .title → { tokenName: "card__title", ..., ancestors: [{classNames:["card"], combinator:"child"}] }
* .a .b .c → { tokenName: "a__b__c", ..., ancestors: [{..,"descendant"},{..,"descendant"}] }
* .card > .title:hover → { tokenName: "card__title", ..., states: [":hover"], ancestors: [...] }
*
* Returns undefined for:
* - Selectors with sibling combinators (+, ~)
* - Selectors with non-class nodes in ancestor segments (e.g., .card h1, h1 .card)
* - Pure element/id selectors
*/
export const parseClassBasedSelector = (
selector: string
): ParsedClassSelector | undefined => {
let ast: csstree.CssNode;
try {
ast = csstree.parse(selector, { context: "selector" });
} catch {
return undefined;
}
if (ast.type !== "Selector") {
return undefined;
}
const children = ast.children.toArray();
if (children.length === 0) {
return undefined;
}
// First node must be a ClassSelector
if (children[0].type !== "ClassSelector") {
return undefined;
}
// Split children into segments at Combinator nodes
type Segment = {
nodes: csstree.CssNode[];
combinator?: "descendant" | "child";
};
const segments: Segment[] = [];
let currentNodes: csstree.CssNode[] = [];
for (const child of children) {
if (child.type === "Combinator") {
if (child.name === " ") {
segments.push({ nodes: currentNodes, combinator: "descendant" });
} else if (child.name === ">") {
segments.push({ nodes: currentNodes, combinator: "child" });
} else {
// Sibling combinators (+, ~) — reject
return undefined;
}
currentNodes = [];
} else {
currentNodes.push(child);
}
}
// Push the last (target) segment
segments.push({ nodes: currentNodes });
// Validate all segments and extract class names
const ancestors: Array<{
classNames: string[];
combinator: "descendant" | "child";
}> = [];
// Process ancestor segments (all except the last)
for (let i = 0; i < segments.length - 1; i++) {
const segment = segments[i];
const segClassNames: string[] = [];
for (const node of segment.nodes) {
if (node.type === "ClassSelector") {
segClassNames.push(unescapeCssIdentifier(node.name));
} else {
// Non-class node in an ancestor segment — reject
return undefined;
}
}
if (segClassNames.length === 0) {
return undefined;
}
ancestors.push({
classNames: segClassNames,
combinator: segment.combinator!,
});
}
// Process the last (target) segment
const lastSegment = segments[segments.length - 1];
const classNames: string[] = [];
const states: string[] = [];
for (const node of lastSegment.nodes) {
switch (node.type) {
case "ClassSelector":
classNames.push(unescapeCssIdentifier(node.name));
break;
case "AttributeSelector":
case "PseudoClassSelector":
case "PseudoElementSelector":
states.push(csstree.generate(node));
break;
default:
return undefined;
}
}
if (classNames.length === 0) {
return undefined;
}
// Build token name: join segment class names with "__"
const segmentNames = [
...ancestors.map((a) =>
a.classNames.length === 1 ? a.classNames[0] : a.classNames.join(".")
),
classNames.length === 1 ? classNames[0] : classNames.join("."),
];
const tokenName = segmentNames.join("__");
return {
tokenName,
classNames,
...(states.length > 0 ? { states } : {}),
...(ancestors.length > 0 ? { ancestors } : {}),
};
};
type ParsedBreakpoint = {
minWidth?: number;
maxWidth?: number;
condition?: string;
};
export const parseMediaQuery = (
mediaQuery: string
): undefined | ParsedBreakpoint => {
const ast = csstree.parse(mediaQuery, { context: "mediaQuery" });
let minWidth: undefined | number;
let maxWidth: undefined | number;
let currentWidthProperty: undefined | "minWidth" | "maxWidth";
const otherFeatures: string[] = [];
csstree.walk(ast, (node) => {
if (node.type === "Feature") {
if (node.name === "min-width") {
currentWidthProperty = "minWidth";
} else if (node.name === "max-width") {
currentWidthProperty = "maxWidth";
} else {
currentWidthProperty = undefined;
// Capture any other media feature as custom condition
const generated = csstree.generate(node);
// Remove outer parentheses if present
let cleaned =
generated.startsWith("(") && generated.endsWith(")")
? generated.slice(1, -1)
: generated;
// Normalize whitespace: remove spaces around colons for consistency
cleaned = cleaned.replace(/\s*:\s*/g, ":");
otherFeatures.push(cleaned);
}
}
if (node.type === "Dimension" && node.unit === "px") {
const value = Number(node.value);
if (currentWidthProperty === "minWidth") {
minWidth = value;
} else if (currentWidthProperty === "maxWidth") {
maxWidth = value;
}
currentWidthProperty = undefined;
}
});
const condition =
otherFeatures.length > 0 ? otherFeatures.join(" and ") : undefined;
const hasWidth = minWidth !== undefined || maxWidth !== undefined;
// If there's a custom condition and no width, return only condition
if (condition !== undefined && !hasWidth) {
return { condition };
}
if (!hasWidth && condition === undefined) {
return;
}
return {
...(minWidth !== undefined ? { minWidth } : {}),
...(maxWidth !== undefined ? { maxWidth } : {}),
...(condition !== undefined ? { condition } : {}),
};
};