fix: preserve CSS variable and gradient references on paste (#5716)

Preserve var() references in pasted gradient shorthand
(css-data/parse-css.ts)
When expanding shorthand properties (e.g. background), unresolved var()
tokens were lost. Now they're recovered by probing each longhand with a
placeholder, mapping which longhands the var belongs to, and restoring
it.

Fix border-(--x) Tailwind v4 syntax not recognized (tailwind.ts)
borde
This commit is contained in:
Oleg Isonen authored and GitHub committed 2026-04-23 13:22:33 +02:00
1 parent 5ff6bb7790
commit f052200d07
22 files changed
+21634 -1814

No files matched your search

+324 -50
View File
@@ -1,5 +1,6 @@
import { expect, test, describe } from "vitest";
import { $, css, renderTemplate, token, ws } from "@webstudio-is/template";
import type { WebstudioFragment } from "@webstudio-is/sdk";
import { generateFragmentFromHtml as _generateFragmentFromHtml } from "./html";
// Wrapper that strips skippedSelectors for tests that only compare fragment shape
@@ -472,29 +473,42 @@ describe("style tag to tokens", () => {
});
test("combine inline style attribute with class token", () => {
const cardToken = token(
"card",
css`
display: flex;
`
const fragment = generateFragmentFromHtml(`
<style>.card { display: flex; }</style>
<div class="card" style="color: red">Hello</div>
`);
const cardToken = fragment.styleSources.find(
(source) => source.type === "token" && source.name === "card"
);
expect(
generateFragmentFromHtml(`
<style>.card { display: flex; }</style>
<div class="card" style="color: red">Hello</div>
`)
).toEqual(
renderTemplate(
<ws.element
ws:tag="div"
ws:style={css`
color: red;
`}
ws:tokens={[cardToken]}
>
Hello
</ws.element>
)
expect(cardToken).toBeDefined();
expect(fragment.children).toEqual([{ type: "id", value: "0" }]);
expect(fragment.instances).toEqual([
{
type: "instance",
id: "0",
component: "ws:element",
tag: "div",
children: [{ type: "text", value: "Hello" }],
},
]);
expect(fragment.styleSourceSelections).toEqual([
{ instanceId: "0", values: [cardToken!.id, "0:ws:style"] },
]);
expect(fragment.styles).toEqual(
expect.arrayContaining([
{
styleSourceId: cardToken!.id,
breakpointId: "base",
property: "display",
value: { type: "keyword", value: "flex" },
},
{
styleSourceId: "0:ws:style",
breakpointId: "base",
property: "color",
value: { type: "keyword", value: "red" },
},
])
);
});
@@ -810,30 +824,51 @@ describe("style tag to tokens", () => {
});
test("element with both resolved and unresolved classes plus inline style", () => {
const cardToken = token(
"card",
css`
display: flex;
`
const fragment = generateFragmentFromHtml(`
<style>.card { display: flex; }</style>
<div class="card external" style="color: red">Hello</div>
`);
const cardToken = fragment.styleSources.find(
(source) => source.type === "token" && source.name === "card"
);
expect(
generateFragmentFromHtml(`
<style>.card { display: flex; }</style>
<div class="card external" style="color: red">Hello</div>
`)
).toEqual(
renderTemplate(
<ws.element
ws:tag="div"
class="external"
ws:style={css`
color: red;
`}
ws:tokens={[cardToken]}
>
Hello
</ws.element>
)
expect(cardToken).toBeDefined();
expect(fragment.children).toEqual([{ type: "id", value: "0" }]);
expect(fragment.instances).toEqual([
{
type: "instance",
id: "0",
component: "ws:element",
tag: "div",
children: [{ type: "text", value: "Hello" }],
},
]);
expect(fragment.props).toEqual([
{
id: "0:class",
instanceId: "0",
name: "class",
type: "string",
value: "external",
},
]);
expect(fragment.styleSourceSelections).toEqual([
{ instanceId: "0", values: [cardToken!.id, "0:ws:style"] },
]);
expect(fragment.styles).toEqual(
expect.arrayContaining([
{
styleSourceId: cardToken!.id,
breakpointId: "base",
property: "display",
value: { type: "keyword", value: "flex" },
},
{
styleSourceId: "0:ws:style",
breakpointId: "base",
property: "color",
value: { type: "keyword", value: "red" },
},
])
);
});
@@ -2332,7 +2367,7 @@ describe("style tag to tokens", () => {
).toHaveLength(0);
});
test(":root selector stays as html embed", () => {
test(":root selector is extracted as local styles on the root instance, not as html embed", () => {
const fragment = generateFragmentFromHtml(`
<style>
:root { --color: blue; }
@@ -2345,11 +2380,87 @@ describe("style tag to tokens", () => {
(s) => s.type === "token" && s.name === "card"
);
expect(tokenSource).toBeDefined();
// :root should stay as html embed
const htmlEmbed = fragment.instances.find(
(i) => i.component === "HtmlEmbed"
// :root should NOT produce an html embed
expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe(
false
);
// :root styles should be on the root instance selection
const rootSelection = fragment.styleSourceSelections.find(
(sel) => sel.instanceId === ":root"
);
expect(rootSelection).toBeDefined();
const rootStyleSourceId = rootSelection!.values[0];
const rootStyle = fragment.styles.find(
(s) => s.styleSourceId === rootStyleSourceId && s.property === "--color"
);
expect(rootStyle).toBeDefined();
});
test(":root only styles are extracted as local styles on root instance", () => {
const fragment = generateFragmentFromHtml(`
<style>
:root {
color: red;
}
</style>
`);
// no html embed
expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe(
false
);
// style on :root instance
const rootSelection = fragment.styleSourceSelections.find(
(sel) => sel.instanceId === ":root"
);
expect(rootSelection).toBeDefined();
const rootStyleSourceId = rootSelection!.values[0];
const rootStyle = fragment.styles.find(
(s) => s.styleSourceId === rootStyleSourceId && s.property === "color"
);
expect(rootStyle).toBeDefined();
expect(rootStyle!.value).toMatchObject({ type: "keyword", value: "red" });
});
test("cross-selector gradient vars stay as var() references in pasted tokens", () => {
const fragment = generateFragmentFromHtml(`
<section class="scope">
<style>
.scope {
--tone-1: #ff0000;
--tone-2: #00ff00;
--tone-3: #0000ff;
}
.panel {
background: linear-gradient(38deg, var(--tone-1), var(--tone-2), var(--tone-3));
}
</style>
<div class="panel">test</div>
</section>
`);
const tokenSource = fragment.styleSources.find(
(source) => source.type === "token" && source.name === "panel"
);
expect(tokenSource).toBeDefined();
const backgroundImage = fragment.styles.find(
(style) =>
style.styleSourceId === tokenSource!.id &&
style.property === "backgroundImage"
);
expect(backgroundImage?.value).toMatchObject({
type: "layers",
value: [
{
type: "unparsed",
value:
"linear-gradient(38deg,var(--tone-1),var(--tone-2),var(--tone-3))",
},
],
});
expect(fragment.instances.some((i) => i.component === "HtmlEmbed")).toBe(
false
);
expect(htmlEmbed).toBeDefined();
});
test("universal selector * stays as html embed", () => {
@@ -2986,4 +3097,167 @@ describe("style tag to tokens", () => {
expect.objectContaining({ type: "var", value: "clr-red" })
);
});
describe("preserve border color var() references", () => {
const getTokenStyle = (
fragment: WebstudioFragment,
tokenName: string,
property: string
) => {
const tokenSource = fragment.styleSources.find(
(source) => source.type === "token" && source.name === tokenName
);
expect(tokenSource).toBeDefined();
return fragment.styles.find(
(style) =>
style.styleSourceId === tokenSource!.id && style.property === property
);
};
const getSelection = (fragment: WebstudioFragment, instanceId: string) => {
return fragment.styleSourceSelections.find(
(selection) => selection.instanceId === instanceId
);
};
test("typed arbitrary color utility keeps var reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
.border-\\[color\\:var\\(--border-color\\)\\] { border-color: var(--border-color); }
</style>
<div class="p-8 border border-[color:var(--border-color)]"></div>
`);
const borderColorStyle = getTokenStyle(
fragment,
"border-[color:var(--border-color)]",
"borderTopColor"
);
expect(borderColorStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("css variable shorthand utility keeps var reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
.border-\\(--border-color\\) { border-color: var(--border-color); }
</style>
<div class="p-8 border border-(--border-color)"></div>
`);
const borderColorStyle = getTokenStyle(
fragment,
"border-(--border-color)",
"borderTopColor"
);
expect(borderColorStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("inline border-color style keeps var reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
</style>
<div class="p-8 border" style="border-color: var(--border-color)"></div>
`);
const divInstance = fragment.instances.find(
(instance) => instance.tag === "div"
);
expect(divInstance).toBeDefined();
expect(getSelection(fragment, divInstance!.id)?.values).toEqual([
expect.any(String),
`${divInstance!.id}:ws:style`,
]);
const localStyle = fragment.styles.find(
(style) =>
style.styleSourceId === `${divInstance!.id}:ws:style` &&
style.property === "borderTopColor"
);
expect(localStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("arbitrary property utility keeps var reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
.\\[border-color\\:var\\(--border-color\\)\\] { border-color: var(--border-color); }
</style>
<div class="p-8 border [border-color:var(--border-color)]"></div>
`);
const borderColorStyle = getTokenStyle(
fragment,
"[border-color:var(--border-color)]",
"borderTopColor"
);
expect(borderColorStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("typed literal utility keeps literal color", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
.border-\\[color\\:\\#000\\] { border-color: #000; }
</style>
<div class="p-8 border border-[color:#000]"></div>
`);
const borderColorStyle = getTokenStyle(
fragment,
"border-[color:#000]",
"borderTopColor"
);
expect(borderColorStyle?.value).toEqual(
expect.objectContaining({ type: "color", colorSpace: "hex" })
);
});
test("border side utility with var color keeps var reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border-t { border-top-width: 1px; border-top-style: solid; border-color: #e5e7eb; }
.border-\\[color\\:var\\(--border-color\\)\\] { border-color: var(--border-color); }
</style>
<div class="p-8 border-t border-[color:var(--border-color)]"></div>
`);
const borderColorStyle = getTokenStyle(
fragment,
"border-[color:var(--border-color)]",
"borderTopColor"
);
expect(borderColorStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("inline border shorthand keeps var color reference", () => {
const fragment = _generateFragmentFromHtml(`
<style>
.border { border-width: 1px; border-style: solid; border-color: #e5e7eb; }
</style>
<div class="p-8 border" style="border: 1px solid var(--border-color)"></div>
`);
const divInstance = fragment.instances.find(
(instance) => instance.tag === "div"
);
expect(divInstance).toBeDefined();
expect(getSelection(fragment, divInstance!.id)?.values).toEqual([
expect.any(String),
`${divInstance!.id}:ws:style`,
]);
const localStyle = fragment.styles.find(
(style) =>
style.styleSourceId === `${divInstance!.id}:ws:style` &&
style.property === "borderTopColor"
);
expect(localStyle?.value).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
});
});
+57 -10
View File
@@ -27,6 +27,7 @@ import {
import { richTextContentTags } from "./content-model";
import { setIsSubsetOf } from "./shim";
import { isAttributeNameSafe } from "@webstudio-is/react-sdk";
import { ROOT_INSTANCE_ID } from "@webstudio-is/sdk";
import * as csstree from "css-tree";
import { titleCase } from "title-case";
@@ -194,13 +195,19 @@ const classifyRules = (
): {
classRules: Map<string, ParsedStyleDecl[]>;
nestedClassRules: Map<string, NestedClassRule>;
rootRules: ParsedStyleDecl[];
hasNonClassRules: boolean;
} => {
const classRules = new Map<string, ParsedStyleDecl[]>();
const nestedClassRules = new Map<string, NestedClassRule>();
const rootRules: ParsedStyleDecl[] = [];
let hasNonClassRules = false;
for (const decl of decls) {
if (decl.selector === ":root") {
rootRules.push(decl);
continue;
}
const parsed = parseClassBasedSelector(decl.selector);
if (parsed !== undefined) {
const selectorState = parsed.states?.[0];
@@ -229,7 +236,7 @@ const classifyRules = (
hasNonClassRules = true;
}
}
return { classRules, nestedClassRules, hasNonClassRules };
return { classRules, nestedClassRules, rootRules, hasNonClassRules };
};
/**
@@ -245,12 +252,20 @@ const buildLeftoverCss = (cssText: string): string => {
const parts: string[] = [];
/** Re-use parseClassBasedSelector as single source of truth */
const isClassBasedSelector = (selector: csstree.CssNode): boolean =>
selector.type === "Selector" &&
parseClassBasedSelector(csstree.generate(selector)) !== undefined;
const isClassBasedSelector = (selector: csstree.CssNode): boolean => {
if (selector.type !== "Selector") {
return false;
}
const text = csstree.generate(selector);
// :root rules are extracted separately — treat as non-leftover
if (text === ":root") {
return true;
}
return parseClassBasedSelector(text) !== undefined;
};
/**
* Process a Rule: if all selectors are class-based, skip entirely.
* Process a Rule: if all selectors are class-based or :root, skip entirely.
* If none are, keep entirely. If mixed, keep only non-class selectors.
*/
const getLeftoverRule = (node: csstree.Rule): string | undefined => {
@@ -537,7 +552,7 @@ export const generateFragmentFromHtml = (
// Parse all CSS and classify rules
const { styles: allDecls } = parseCss(allCssText, allCssVars);
const { classRules, nestedClassRules } = classifyRules(allDecls);
const { classRules, nestedClassRules, rootRules } = classifyRules(allDecls);
// Track which class names are used by elements — IDs will be assigned later
const usedClassNames = new Set<string>();
@@ -561,19 +576,32 @@ export const generateFragmentFromHtml = (
const {
classRules: tagClassRules,
nestedClassRules: tagNestedRules,
rootRules: tagRootRules,
hasNonClassRules: tagHasNonClass,
} = classifyRules(parsedDecls);
if (
parsedDecls.length === 0 &&
tagClassRules.size === 0 &&
tagNestedRules.size === 0
tagNestedRules.size === 0 &&
tagRootRules.length === 0
) {
// Unparseable CSS — keep original
styleTagActions.push({ type: "keep-original" });
} else if (tagClassRules.size === 0 && tagNestedRules.size === 0) {
// Only non-class rules — keep original
} else if (
tagClassRules.size === 0 &&
tagNestedRules.size === 0 &&
tagRootRules.length === 0
) {
// Only non-class, non-root element rules — keep original HtmlEmbed
styleTagActions.push({ type: "keep-original" });
} else if (
tagClassRules.size === 0 &&
tagNestedRules.size === 0 &&
!tagHasNonClass
) {
// Only :root rules — extracted to ROOT_INSTANCE_ID, skip HtmlEmbed
styleTagActions.push({ type: "skip" });
} else if (!tagHasNonClass) {
// Only class rules — also check for unsupported media like @media print
const leftover = buildLeftoverCss(text);
@@ -1030,6 +1058,25 @@ export const generateFragmentFromHtml = (
}
}
// Inject :root styles as a local style source on ROOT_INSTANCE_ID
if (rootRules.length > 0) {
const rootStyleSourceId = getNewId();
styleSources.push({ type: "local", id: rootStyleSourceId });
styleSourceSelections.push({
instanceId: ROOT_INSTANCE_ID,
values: [rootStyleSourceId],
});
for (const decl of rootRules) {
styles.push({
styleSourceId: rootStyleSourceId,
breakpointId: getBaseBreakpointId(),
property: camelCaseProperty(decl.property),
value: decl.value,
...(decl.state ? { state: decl.state } : {}),
});
}
}
// Create style source selections for instances that use tokens
const selectionsByInstance = new Map(
styleSourceSelections.map((sel) => [sel.instanceId, sel])
@@ -1041,7 +1088,7 @@ export const generateFragmentFromHtml = (
if (tokenIds.length > 0) {
const existingSelection = selectionsByInstance.get(instanceId);
if (existingSelection) {
existingSelection.values.push(...tokenIds);
existingSelection.values = [...tokenIds, ...existingSelection.values];
} else {
const newSelection: StyleSourceSelection = {
instanceId,
@@ -2,6 +2,45 @@ import { describe, expect, test } from "vitest";
import { css, renderTemplate, ws } from "@webstudio-is/template";
import { generateFragmentFromTailwind } from "./tailwind";
const getBaseStyleValue = (
fragment: Awaited<ReturnType<typeof generateFragmentFromTailwind>>,
property: string
) => {
return fragment.styles.find(
(style) => style.breakpointId === "base" && style.property === property
)?.value;
};
const hasStyleValue = (
fragment: Awaited<ReturnType<typeof generateFragmentFromTailwind>>,
property: string,
predicate: (value: unknown) => boolean,
options: { breakpointId?: string; state?: string } = {}
) => {
const breakpointId = options.breakpointId ?? "base";
return fragment.styles.some(
(style) =>
style.breakpointId === breakpointId &&
style.property === property &&
style.state === options.state &&
predicate(style.value)
);
};
const getStyleValue = (
fragment: Awaited<ReturnType<typeof generateFragmentFromTailwind>>,
property: string,
options: { breakpointId?: string; state?: string } = {}
) => {
const breakpointId = options.breakpointId ?? "base";
return fragment.styles.find(
(style) =>
style.breakpointId === breakpointId &&
style.property === property &&
style.state === options.state
)?.value;
};
test("extract local styles from tailwind classes", async () => {
expect(
await generateFragmentFromTailwind(
@@ -32,23 +71,25 @@ test("extract local styles from tailwind classes", async () => {
});
test("ignore dark mode", async () => {
expect(
await generateFragmentFromTailwind(
renderTemplate(
<ws.element ws:tag="div" class="bg-white dark:bg-gray-800"></ws.element>
)
)
).toEqual(
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
ws:style={css`
--tw-bg-opacity: 1;
background-color: rgb(255 255 255 / var(--tw-bg-opacity));
`}
></ws.element>
<ws.element ws:tag="div" class="bg-white dark:bg-gray-800"></ws.element>
)
);
expect(fragment.breakpoints).toEqual([{ id: "base", label: "" }]);
expect(getStyleValue(fragment, "backgroundColor")).toEqual(
expect.objectContaining({
type: "color",
colorSpace: "srgb",
components: [1, 1, 1],
})
);
expect(
hasStyleValue(fragment, "backgroundColor", (value) => value !== undefined, {
state: ":hover",
})
).toBe(false);
});
test("ignore empty class", async () => {
@@ -99,55 +140,122 @@ test("generate border", async () => {
});
test("override border opacity", async () => {
expect(
await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="border border-gray-200 border-opacity-60"
></ws.element>
)
)
).toEqual(
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="border border-gray-200 border-opacity-60"
></ws.element>
)
);
expect(getBaseStyleValue(fragment, "borderTopStyle")).toEqual(
expect.objectContaining({ type: "keyword", value: "solid" })
);
expect(getBaseStyleValue(fragment, "borderTopWidth")).toEqual(
expect.objectContaining({ type: "unit", unit: "px", value: 1 })
);
expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
expect.objectContaining({ type: "color", alpha: 0.6 })
);
});
test("keep css variable shorthand border color", async () => {
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="p-8 border border-(--border-color)"
></ws.element>
)
);
expect(fragment.props.some((prop) => prop.name === "class")).toBe(false);
expect(getBaseStyleValue(fragment, "borderTopStyle")).toEqual(
expect.objectContaining({ type: "keyword", value: "solid" })
);
expect(getBaseStyleValue(fragment, "borderTopWidth")).toEqual(
expect.objectContaining({ type: "unit", unit: "px", value: 1 })
);
expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("keep inline border-color var override", async () => {
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="p-8 border"
ws:style={css`
border-style: solid;
border-color: rgb(229 231 235 / var(--tw-border-opacity));
border-width: 1px;
--tw-border-opacity: 0.6;
border-color: var(--border-color);
`}
></ws.element>
)
);
expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("generate shadow", async () => {
expect(
await generateFragmentFromTailwind(
renderTemplate(<ws.element ws:tag="div" class="shadow"></ws.element>)
)
).toEqual(
test("keep typed literal border color utility", async () => {
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="p-8 border border-[color:#000]"
></ws.element>
)
);
expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
expect.objectContaining({ type: "color" })
);
});
test("keep inline border shorthand var color override", async () => {
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="p-8 border"
ws:style={css`
--tw-ring-offset-shadow: 0 0 rgb(0 0 0 / 0);
--tw-ring-shadow: 0 0 rgb(0 0 0 / 0);
--tw-shadow-inset: ;
--tw-shadow:
var(--tw-shadow-inset) 0 1px 3px 0
var(--tw-shadow-color, rgb(0 0 0 / 0.1)),
var(--tw-shadow-inset) 0 1px 2px -1px
var(--tw-shadow-color, rgb(0 0 0 / 0.1));
box-shadow:
var(--tw-ring-offset-shadow), var(--tw-ring-shadow),
var(--tw-shadow);
border: 1px solid var(--border-color);
`}
></ws.element>
)
);
expect(getBaseStyleValue(fragment, "borderTopColor")).toEqual(
expect.objectContaining({ type: "var", value: "border-color" })
);
});
test("generate shadow", async () => {
const fragment = await generateFragmentFromTailwind(
renderTemplate(<ws.element ws:tag="div" class="shadow"></ws.element>)
);
expect(getStyleValue(fragment, "--tw-shadow")).toEqual(
expect.objectContaining({
type: "unparsed",
value: expect.stringContaining("0 1px 3px 0"),
})
);
expect(getStyleValue(fragment, "boxShadow")).toEqual(
expect.objectContaining({
type: "layers",
value: expect.arrayContaining([
expect.objectContaining({
type: "var",
value: "tw-ring-offset-shadow",
}),
expect.objectContaining({ type: "var", value: "tw-ring-shadow" }),
]),
})
);
});
test("preserve or override existing local styles", async () => {
@@ -279,30 +387,24 @@ test("preflight does not overwrite h1 inline styles", async () => {
});
test("extract states from tailwind classes", async () => {
expect(
await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
class="bg-indigo-600 hover:bg-indigo-500"
></ws.element>
)
)
).toEqual(
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<ws.element
ws:tag="div"
ws:style={css`
--tw-bg-opacity: 1;
background-color: rgb(79 70 229 / var(--tw-bg-opacity));
&:hover {
--tw-bg-opacity: 1;
background-color: rgb(99 102 241 / var(--tw-bg-opacity));
}
`}
class="bg-indigo-600 hover:bg-indigo-500"
></ws.element>
)
);
expect(getStyleValue(fragment, "backgroundColor")).toEqual(
expect.objectContaining({ type: "color", colorSpace: "oklch" })
);
expect(
getStyleValue(fragment, "backgroundColor", { state: ":hover" })
).toEqual(expect.objectContaining({ type: "color", colorSpace: "oklch" }));
expect(getStyleValue(fragment, "backgroundColor")).not.toEqual(
getStyleValue(fragment, "backgroundColor", { state: ":hover" })
);
});
describe("extract breakpoints", () => {
@@ -824,39 +926,62 @@ test("generate space without display property", async () => {
});
test("generate space with display property", async () => {
expect(
await generateFragmentFromTailwind(
renderTemplate(
<>
<ws.element ws:tag="div" class="flex space-x-4"></ws.element>
<ws.element
ws:tag="div"
class="hidden md:flex space-y-4"
></ws.element>
</>
)
)
).toEqual(
const fragment = await generateFragmentFromTailwind(
renderTemplate(
<>
<ws.element
ws:tag="div"
ws:style={css`
display: flex;
column-gap: 1rem;
`}
></ws.element>
<ws.element
ws:tag="div"
ws:style={css`
@media (max-width: 767px) {
display: none;
}
display: flex;
row-gap: 1rem;
`}
></ws.element>
<ws.element ws:tag="div" class="flex space-x-4"></ws.element>
<ws.element ws:tag="div" class="hidden md:flex space-y-4"></ws.element>
</>
)
);
const firstStyleSourceId = fragment.styleSourceSelections
.find((selection) => selection.instanceId === "0")
?.values.at(-1);
const secondStyleSourceId = fragment.styleSourceSelections
.find((selection) => selection.instanceId === "1")
?.values.at(-1);
expect(
fragment.styles.some(
(style) =>
style.styleSourceId === firstStyleSourceId &&
style.breakpointId === "base" &&
style.property === "display" &&
(style.value as { value?: string }).value === "flex"
)
).toBe(true);
expect(
fragment.styles.some(
(style) =>
style.styleSourceId === firstStyleSourceId &&
style.breakpointId === "base" &&
style.property === "columnGap"
)
).toBe(true);
expect(
fragment.styles.some(
(style) =>
style.styleSourceId === secondStyleSourceId &&
style.breakpointId === "base" &&
style.property === "display" &&
(style.value as { value?: string }).value === "flex"
)
).toBe(true);
expect(
fragment.styles.some(
(style) =>
style.styleSourceId === secondStyleSourceId &&
style.property === "rowGap"
)
).toBe(true);
expect(
fragment.styles.some(
(style) =>
style.styleSourceId === secondStyleSourceId &&
style.property === "display" &&
(style.value as { value?: string }).value === "none" &&
style.breakpointId !== "base"
)
).toBe(true);
});
+264 -10
View File
@@ -1,6 +1,5 @@
import { createGenerator } from "@unocss/core";
import { presetLegacyCompat } from "@unocss/preset-legacy-compat";
import { presetWind3 } from "@unocss/preset-wind3";
import { presetWind4 } from "@unocss/preset-wind4";
import {
camelCaseProperty,
extractCssCustomProperties,
@@ -195,18 +194,171 @@ const adaptBreakpoints = (
const createUnoGenerator = async () => {
return await createGenerator({
presets: [
presetWind3({
presetWind4({
// css variables are defined on the same element as styleDecl
preflight: "on-demand",
preflights: { theme: "on-demand", reset: false, property: false },
// dark mode will be ignored by parser
dark: "media",
}),
// until we support oklch natively
presetLegacyCompat({ legacyColorSpace: true }),
],
});
};
const percentToNumber = (value: string) => {
const trimmed = value.trim();
if (trimmed.endsWith("%") === false) {
return trimmed;
}
const parsed = Number.parseFloat(trimmed.slice(0, -1));
if (Number.isNaN(parsed)) {
return trimmed;
}
return (parsed / 100).toString();
};
const appendAlphaToOklch = (oklch: string, alpha: string) => {
const match = oklch.match(/^oklch\((.*)\)$/i);
if (match === null) {
return oklch;
}
return `oklch(${match[1]} / ${alpha})`;
};
const hexToRgb = (hex: string) => {
const normalized =
hex.length === 4
? `#${hex[1]}${hex[1]}${hex[2]}${hex[2]}${hex[3]}${hex[3]}`
: hex;
const r = Number.parseInt(normalized.slice(1, 3), 16);
const g = Number.parseInt(normalized.slice(3, 5), 16);
const b = Number.parseInt(normalized.slice(5, 7), 16);
return `${r} ${g} ${b}`;
};
const normalizeWind4Css = (css: string, finalVars: Map<string, string>) => {
// Wind4 emits rem media queries (e.g. 40rem). Convert to px so existing
// breakpoint mapping code can keep working unchanged.
let normalized = css.replace(
/(min|max)-width:\s*(-?\d*\.?\d+)rem/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16;
return `${range}-width: ${px}px`;
}
);
normalized = normalized.replace(
/(min|max)-width:\s*calc\((-?\d*\.?\d+)rem\s*-\s*0\.1px\)/g,
(_match, range, rem) => {
const px = Number.parseFloat(rem) * 16 - 0.1;
return `${range}-width: ${px}px`;
}
);
// Inline tracked theme variables so parseCss can resolve computed values
// like calc(var(--spacing) * 2) and var(--text-sm-fontSize).
for (const [name, value] of finalVars.entries()) {
normalized = normalized
.replaceAll(`var(${name})`, value)
.replaceAll(`var(${name},`, `var(${value},`);
}
// Wind4 uses a leading utility var fallback for typography.
normalized = normalized.replace(
/var\(--tw-leading,\s*([^\)]+)\)/g,
(_match, fallback) => fallback.trim()
);
// Resolve wind4's color-mix based opacity pipeline into concrete colors that
// parseCss can read as typed color values.
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*var\((--colors-[\w-]+)\)\s+([^,]+),\s*transparent\)/g,
(_match, colorVar: string, opacityExpr: string) => {
const color = finalVars.get(colorVar);
if (color === undefined) {
return _match;
}
const trimmedOpacityExpr = opacityExpr.trim();
let alpha = trimmedOpacityExpr;
const varMatch = trimmedOpacityExpr.match(/^var\((--[\w-]+)\)$/);
if (varMatch) {
alpha = finalVars.get(varMatch[1]) ?? "1";
}
alpha = percentToNumber(alpha);
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
// After variable inlining, remaining color-mix declarations may already have
// concrete colors as the first argument (e.g. #fff or oklch(...)).
normalized = normalized.replace(
/color-mix\(in\s+(?:srgb|oklab),\s*(oklch\([^\)]+\)|#[0-9a-fA-F]{3,8})\s+([^,]+),\s*transparent\)/g,
(_match, color: string, opacityExpr: string) => {
const alpha = percentToNumber(opacityExpr.trim());
if (alpha === "1") {
return color;
}
if (color.startsWith("oklch(")) {
return appendAlphaToOklch(color, alpha);
}
if (color.startsWith("#")) {
return `rgb(from ${color} r g b / ${alpha})`;
}
return _match;
}
);
normalized = normalized.replace(
/rgb\(from\s+(#[0-9a-fA-F]{3,8})\s+r\s+g\s+b\s*\/\s*([^\)]+)\)/g,
(_match, hex: string, alpha: string) => {
const rgb = hexToRgb(hex);
return `rgb(${rgb} / ${alpha.trim()})`;
}
);
return normalized;
};
const isTailwindDefaultBorderColorStyle = (styleDecl: StyleDecl): boolean => {
if (
styleDecl.property.startsWith("border-") === false ||
styleDecl.property.endsWith("-color") === false
) {
return false;
}
const value = styleDecl.value as {
type?: string;
alpha?: number;
components?: number[];
};
if (
value?.type !== "color" ||
value.alpha !== 1 ||
Array.isArray(value.components) === false ||
value.components.length < 3
) {
return false;
}
const [r, g, b] = value.components;
return (
Math.abs(r - 229 / 255) < 0.001 &&
Math.abs(g - 231 / 255) < 0.001 &&
Math.abs(b - 235 / 255) < 0.001
);
};
const parseTailwindClasses = async (
classes: string,
userBreakpoints: Breakpoint[],
@@ -222,6 +374,13 @@ const parseTailwindClasses = async (
classes = classes
.split(" ")
.map((item) => {
// Tailwind v4 css variable shorthand: border-(--x)
// UnoCSS doesn't parse this alias directly, so normalize it to
// an explicit arbitrary property utility it understands.
if (/^border-\(--[\w-]+\)$/.test(item)) {
const varName = item.slice("border-(".length, -1);
return `[border-color:var(${varName})]`;
}
// styles data cannot express space-x and space-y selectors
// with lobotomized owl so replace with gaps
if (item.includes("space-x-")) {
@@ -246,9 +405,9 @@ const parseTailwindClasses = async (
// two-pass pre-collection to see the correct final value regardless of which
// rule a shorthand (e.g. border-color) lives in.
const finalVars = extractCssCustomProperties(css);
let normalizedCss = css;
let normalizedCss = normalizeWind4Css(css, finalVars);
if (finalVars.size > 0) {
normalizedCss = css.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
normalizedCss = normalizedCss.replace(/--[\w-]+\s*:[^;{}\n]*/g, (match) => {
const colonIdx = match.indexOf(":");
const propName = match.slice(0, colonIdx).trim();
const finalValue = finalVars.get(propName);
@@ -268,9 +427,79 @@ const parseTailwindClasses = async (
parsedStyles.push(...parseCss(reset, new Map()).styles);
}
parsedStyles.push(...parseCss(normalizedCss, finalVars).styles);
parsedStyles = parsedStyles.map((styleDecl) => {
const value = styleDecl.value as {
type?: string;
unit?: string;
value?: number | string;
fallback?: { type?: string; value?: string };
};
const isOpacityProperty =
styleDecl.property === "opacity" ||
styleDecl.property.endsWith("opacity");
if (
isOpacityProperty &&
value.type === "unit" &&
value.unit === "%" &&
typeof value.value === "number"
) {
return {
...styleDecl,
value: {
type: "unit" as const,
unit: "number" as const,
value: value.value / 100,
},
};
}
if (value.type === "unparsed" && typeof value.value === "string") {
const calcMatch = value.value.match(
/^calc\((-?\d*\.?\d+)rem\*(-?\d*\.?\d+)\)$/
);
if (calcMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value:
Number.parseFloat(calcMatch[1]) * Number.parseFloat(calcMatch[2]),
},
};
}
}
if (
value.type === "var" &&
value.value === "tw-leading" &&
value.fallback?.type === "unparsed" &&
typeof value.fallback.value === "string"
) {
const fallbackMatch = value.fallback.value.match(/^(-?\d*\.?\d+)rem$/);
if (fallbackMatch) {
return {
...styleDecl,
value: {
type: "unit",
unit: "rem",
value: Number.parseFloat(fallbackMatch[1]),
},
};
}
}
return styleDecl;
});
// skip preflights with ::before, ::after and ::backdrop
parsedStyles = parsedStyles.filter(
(styleDecl) => !styleDecl.state?.startsWith("::")
(styleDecl) =>
!styleDecl.state?.startsWith("::") &&
// Wind4 emits design-token variables in the theme layer. We inline them
// into declarations above, so they don't need to be persisted as styles.
(styleDecl.property.startsWith("--") === false ||
styleDecl.property.startsWith("--tw-"))
);
// setup base breakpoint for container class to avoid hole in ranges
if (hasContainer) {
@@ -454,7 +683,32 @@ export const generateFragmentFromTailwind = async (
fragment.breakpoints
);
if (parsedStyles.length > 0) {
createOrMergeLocalStyles(prop.instanceId, parsedStyles);
const localStyleSource = getLocalStyleSource(prop.instanceId);
const filteredStyles = parsedStyles.filter((parsedStyleDecl) => {
if (
localStyleSource === undefined ||
isTailwindDefaultBorderColorStyle(parsedStyleDecl) === false
) {
return true;
}
const breakpointId = getBreakpointId(parsedStyleDecl.breakpoint);
if (breakpointId === undefined) {
return true;
}
const existingStyleKey = getStyleDeclKey({
breakpointId,
styleSourceId: localStyleSource.id,
state: parsedStyleDecl.state,
property: camelCaseProperty(parsedStyleDecl.property),
});
// Keep authored inline border colors if present.
return styles.has(existingStyleKey) === false;
});
createOrMergeLocalStyles(prop.instanceId, filteredStyles);
if (newClasses.length > 0) {
props.push({ ...prop, value: newClasses });
}
+4 -5
View File
@@ -55,9 +55,7 @@
"@trpc/client": "^10.45.2",
"@trpc/server": "^10.45.2",
"@tsndr/cloudflare-worker-jwt": "^2.5.3",
"@unocss/core": "66.1.2",
"@unocss/preset-legacy-compat": "66.1.2",
"@unocss/preset-wind3": "66.1.2",
"@unocss/core": "66.6.8",
"@vercel/remix": "2.15.3",
"@webstudio-is/asset-uploader": "workspace:*",
"@webstudio-is/authorization-token": "workspace:*",
@@ -72,6 +70,7 @@
"@webstudio-is/http-client": "workspace:*",
"@webstudio-is/icons": "workspace:*",
"@webstudio-is/image": "workspace:*",
"@webstudio-is/plans": "workspace:*",
"@webstudio-is/postgrest": "workspace:*",
"@webstudio-is/project": "workspace:*",
"@webstudio-is/project-build": "workspace:*",
@@ -82,7 +81,6 @@
"@webstudio-is/sdk-components-react-radix": "workspace:*",
"@webstudio-is/template": "workspace:*",
"@webstudio-is/trpc-interface": "workspace:*",
"@webstudio-is/plans": "workspace:*",
"args-tokenizer": "^0.3.0",
"bcp-47": "^2.1.0",
"change-case": "^5.4.4",
@@ -134,12 +132,13 @@
"@types/papaparse": "^5.5.2",
"@types/react": "^18.2.70",
"@types/react-dom": "^18.2.25",
"@unocss/preset-wind4": "^66.6.8",
"@vitest/coverage-v8": "3.1.2",
"@webstudio-is/tsconfig": "workspace:*",
"msw": "^2.13.2",
"css.escape": "^1.5.1",
"fast-glob": "^3.3.2",
"html-tags": "^4.0.0",
"msw": "^2.13.2",
"react-router-dom": "^6.30.0",
"react-test-renderer": "18.3.0-canary-14898b6a9-20240318",
"type-fest": "^4.37.0",
+14 -11
View File
@@ -18,6 +18,7 @@ import type {
} from "@webstudio-is/css-engine";
import * as customData from "../src/custom-data";
import { camelCaseProperty } from "../src/parse-css";
import { supportedExperimentalPropertySet } from "./property-filter";
const units: Record<string, Array<string>> = {
number: [],
@@ -234,11 +235,15 @@ const writeToFile = (fileName: string, constant: string, data: unknown) => {
writeFileSync(join(targetDir, fileName), content, "utf8");
};
const supportedExperimentalProperties = [
"field-sizing",
"text-size-adjust",
"-webkit-tap-highlight-color",
"-webkit-overflow-scrolling",
// Most shorthands are identifiable in MDN data because their `initial` value
// is an array of subproperties. These additional shorthands still expand into
// multiple longhands, but MDN models them with a scalar initial value like
// `normal`, so we need to include them explicitly.
const additionalShorthandProperties = [
"font-synthesis",
"font-variant",
"text-wrap",
"white-space",
];
// Properties we don't support in this form.
@@ -249,11 +254,7 @@ const unsupportedProperties = [
"-webkit-text-stroke-width",
// shorthand properties
"all",
"font-synthesis",
"font-variant",
"overflow",
"white-space",
"text-wrap",
"background-position",
"border-block-style",
"border-block-width",
@@ -283,7 +284,7 @@ const filterData = () => {
config.status === "standard" && "mdn_url" in config;
const isSupportedExperimentalProperty =
supportedExperimentalProperties.includes(property);
supportedExperimentalPropertySet.has(property);
if (
isStandardProperty === false &&
@@ -297,7 +298,9 @@ const filterData = () => {
config.animationType !== "discrete" &&
config.animationType !== "notAnimatable";
const isShorthandProperty = Array.isArray(config.initial);
const isShorthandProperty =
Array.isArray(config.initial) ||
additionalShorthandProperties.includes(property);
if (isShorthandProperty) {
allShorthands[property] = config;
+13
View File
@@ -0,0 +1,13 @@
// Only a small subset of non-standard MDN properties are intentionally
// included in generated CSS data. Keeping this explicit avoids silently
// widening support whenever MDN adds or reshapes experimental entries.
export const supportedExperimentalProperties = [
"field-sizing",
"text-size-adjust",
"-webkit-tap-highlight-color",
"-webkit-overflow-scrolling",
] as const;
export const supportedExperimentalPropertySet = new Set<string>(
supportedExperimentalProperties
);
@@ -0,0 +1,808 @@
/**
* Generates src/__generated__/property-var-test-fixtures.ts
*
* The fixture is sourced directly from MDN property grammar so both longhands
* and shorthands share one generated matrix. For each property we synthesize
* canonical value patterns, replace every known slot position with `var()`,
* and also emit an all-slots case where every slot becomes its own variable.
*
* Run with: pnpm build:property-var-fixtures
*/
import { mkdirSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import properties from "mdn-data/css/properties.json";
import syntaxes from "mdn-data/css/syntaxes.json";
import { definitionSyntax, generate, parse } from "css-tree";
import { supportedExperimentalPropertySet } from "./property-filter";
type DSNode =
| { type: "Keyword"; name: string }
| { type: "String" }
| { type: "AtKeyword"; name: string }
| { type: "Token"; value: string }
| { type: "Comma" }
| { type: "Function"; name: string }
| { type: "Type"; name: string }
| { type: "Property"; name: string }
| {
type: "Multiplier";
term: DSNode;
max: number;
min: number;
comma: boolean;
}
| { type: "Group"; combinator: string; terms: DSNode[] };
const __dir = dirname(fileURLToPath(import.meta.url));
const targetDir = join(__dir, "../src/__generated__");
const autogeneratedHint =
"// This file was generated by pnpm build:property-var-fixtures\n";
const unsupportedProperties = ["--*", "all"];
const typeSamples: Record<string, string> = {
angle: "1deg",
attachment: "scroll",
"basic-shape": "circle()",
color: "red",
"color-stop-list": "red, blue",
"custom-ident": "custom-ident",
"dashed-ident": "--custom-ident",
"declaration-value": "1px",
decibel: "1db",
"family-name": "serif",
flex: "1fr",
frequency: "1hz",
"generic-family": "serif",
gradient: "linear-gradient(red, blue)",
ident: "custom-ident",
image: "url(hero.png)",
integer: "1",
length: "1px",
"length-percentage": "1px",
"line-style": "solid",
"line-width": "1px",
number: "1",
percentage: "10%",
position: "center",
"ratio-token": "1/1",
"repeat-style": "no-repeat",
resolution: "1dpi",
semitones: "1st",
shadow: "0 0 1px red",
string: '"x"',
time: "1s",
transform: "translateX(1px)",
"transform-function": "translateX(1px)",
"transform-list": "translateX(1px)",
url: "url(hero.png)",
x: "1",
y: "1",
};
const propertyOverrides: Record<string, string[]> = {};
const propertyPatternOverrides: Record<string, GrammarPattern[]> = {
font: [
{
complexity: 1,
parts: [
{ kind: "slot", label: "font-style", text: "italic" },
{ kind: "text", text: " " },
{ kind: "slot", label: "font-weight", text: "700" },
{ kind: "text", text: " " },
{ kind: "slot", label: "font-size", text: "16px" },
{ kind: "text", text: "/" },
{ kind: "slot", label: "line-height", text: "1.5" },
{ kind: "text", text: " " },
{ kind: "slot", label: "font-family", text: "serif" },
],
},
],
"grid-template": [
{
complexity: 1,
parts: [
{ kind: "slot", label: "grid-template-rows", text: "1px" },
{ kind: "text", text: " / " },
{ kind: "slot", label: "grid-template-columns", text: "1fr" },
],
},
{
complexity: 2,
parts: [
{ kind: "slot", label: "grid-template-areas", text: '"a"' },
{ kind: "text", text: " " },
{ kind: "slot", label: "grid-template-row-size", text: "1px" },
{ kind: "text", text: " / " },
{ kind: "slot", label: "grid-template-explicit-columns", text: "1fr" },
],
},
],
"list-style": [
{
complexity: 1,
parts: [
{ kind: "slot", label: "list-style-type", text: "disc" },
{ kind: "text", text: " " },
{ kind: "slot", label: "list-style-position", text: "inside" },
{ kind: "text", text: " " },
{ kind: "slot", label: "list-style-image", text: "url(hero.png)" },
],
},
],
};
interface PropertyConfig {
inherited: boolean;
initial: string | string[];
mdn_url?: string;
status?: string;
syntax: string;
}
interface SyntaxConfig {
syntax: string;
}
interface GrammarPart {
kind: "slot" | "text";
label?: string;
text: string;
}
interface GrammarPattern {
complexity: number;
parts: GrammarPart[];
}
interface PropertyVarCase {
id: string;
positions: string[];
value: string;
variables: Record<string, string>;
}
interface PropertyVarFixture {
cases: PropertyVarCase[];
kind: "longhand" | "shorthand";
property: string;
syntax: string;
unsupportedReason?: string;
}
const propertyData = properties as Record<string, PropertyConfig>;
const syntaxData = syntaxes as Record<string, SyntaxConfig>;
const getFilteredProperties = () => {
const filtered: Array<{
kind: "longhand" | "shorthand";
property: string;
syntax: string;
}> = [];
for (const [property, config] of Object.entries(propertyData)) {
if (unsupportedProperties.includes(property)) {
continue;
}
const isStandardProperty =
config.status === "standard" && "mdn_url" in config;
const isSupportedExperimentalProperty =
supportedExperimentalPropertySet.has(property);
if (
isStandardProperty === false &&
isSupportedExperimentalProperty === false
) {
continue;
}
filtered.push({
kind: Array.isArray(config.initial) ? "shorthand" : "longhand",
property,
syntax: config.syntax,
});
}
return filtered.sort((left, right) =>
left.property.localeCompare(right.property)
);
};
const normalizeCssValue = (value: string) => {
try {
return generate(parse(value, { context: "value" }));
} catch {
return;
}
};
const mergeTextParts = (parts: GrammarPart[]) => {
const merged: GrammarPart[] = [];
for (const part of parts) {
const previous = merged.at(-1);
if (part.kind === "text" && previous?.kind === "text") {
previous.text += part.text;
continue;
}
merged.push({ ...part });
}
return merged;
};
const serializeParts = (parts: GrammarPart[]) => {
return parts.map((part) => part.text).join("");
};
const trimPattern = (pattern: GrammarPattern): GrammarPattern => {
const parts = mergeTextParts(pattern.parts);
const first = parts[0];
const last = parts.at(-1);
if (first?.kind === "text") {
first.text = first.text.trimStart();
}
if (last?.kind === "text") {
last.text = last.text.trimEnd();
}
return {
complexity: pattern.complexity,
parts,
};
};
const leadingText = (pattern: GrammarPattern) => {
return pattern.parts[0]?.text ?? "";
};
const trailingText = (pattern: GrammarPattern) => {
return pattern.parts.at(-1)?.text ?? "";
};
const shouldInsertSpace = ({
left,
right,
}: {
left: GrammarPattern;
right: GrammarPattern;
}) => {
if (left.parts.length === 0 || right.parts.length === 0) {
return false;
}
const leftText = trailingText(left);
const rightText = leadingText(right);
if (leftText.endsWith("(") || leftText.endsWith("/")) {
return false;
}
if (rightText.startsWith(")") || rightText.startsWith(",")) {
return false;
}
if (rightText.startsWith("/")) {
return true;
}
return true;
};
const combinePatterns = ({
left,
right,
}: {
left: GrammarPattern;
right: GrammarPattern;
}): GrammarPattern => {
const parts = [...left.parts];
if (shouldInsertSpace({ left, right })) {
parts.push({ kind: "text", text: " " });
}
parts.push(...right.parts);
return trimPattern({
complexity: left.complexity + right.complexity + 1,
parts,
});
};
const takeBestPatterns = ({
limit,
patterns,
}: {
limit: number;
patterns: GrammarPattern[];
}) => {
const deduped = new Map<string, GrammarPattern>();
for (const pattern of patterns) {
const normalized = normalizeCssValue(serializeParts(pattern.parts));
if (normalized === undefined) {
continue;
}
const existing = deduped.get(normalized);
if (existing !== undefined && existing.complexity <= pattern.complexity) {
continue;
}
deduped.set(normalized, trimPattern(pattern));
}
return [...deduped.values()]
.sort((left, right) => left.complexity - right.complexity)
.slice(0, limit);
};
const literalPattern = ({
label,
text,
}: {
label?: string;
text: string;
}): GrammarPattern => {
return {
complexity: 1,
parts: [
label === undefined
? { kind: "text", text }
: { kind: "slot", label, text },
],
};
};
const repeatCount = ({ max, min }: { max: number; min: number }) => {
if (max === 0) {
if (min > 1) {
return min;
}
return 2;
}
if (min === max) {
return max;
}
return Math.max(min, max);
};
const sampleForType = (name: string) => {
const direct = typeSamples[name];
if (direct !== undefined) {
return direct;
}
if (name.endsWith("()")) {
const functionName = name.slice(0, -2);
const functionSamples: Record<string, string> = {
"anchor-size": "anchor-size(width, 1px)",
circle: "circle()",
"cross-fade": "cross-fade(url(hero.png), url(hero.png))",
element: "element(#hero)",
image: "image(url(hero.png))",
"image-set": "image-set(url(hero.png) 1x)",
"linear-gradient": "linear-gradient(red, blue)",
matrix: "matrix(1, 0, 0, 1, 0, 0)",
matrix3d: "matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
paint: "paint(worklet)",
path: 'path("M0 0")',
perspective: "perspective(1px)",
rotate: "rotate(1deg)",
rotate3d: "rotate3d(1, 0, 0, 1deg)",
rotateX: "rotateX(1deg)",
rotateY: "rotateY(1deg)",
rotateZ: "rotateZ(1deg)",
scale: "scale(1)",
scale3d: "scale3d(1, 1, 1)",
scaleX: "scaleX(1)",
scaleY: "scaleY(1)",
scaleZ: "scaleZ(1)",
skew: "skew(1deg)",
skewX: "skewX(1deg)",
skewY: "skewY(1deg)",
translate: "translate(1px)",
translate3d: "translate3d(1px, 1px, 1px)",
translateX: "translateX(1px)",
translateY: "translateY(1px)",
translateZ: "translateZ(1px)",
url: "url(hero.png)",
};
const sample = functionSamples[functionName];
if (sample !== undefined) {
return sample;
}
}
if (name.includes("color")) {
return "red";
}
if (name.includes("length")) {
return "1px";
}
if (name.includes("percentage")) {
return "10%";
}
if (name.includes("time")) {
return "1s";
}
if (name.includes("angle")) {
return "1deg";
}
if (name.includes("number") || name.includes("integer")) {
return "1";
}
if (name.includes("family")) {
return "serif";
}
if (name.includes("position")) {
return "center";
}
if (name.includes("image") || name.includes("url")) {
return "url(hero.png)";
}
if (name.includes("style")) {
return "solid";
}
if (name.includes("width")) {
return "1px";
}
if (name.includes("ident")) {
return "custom-ident";
}
return;
};
const buildPatterns = ({
depth,
key,
node,
seen,
}: {
depth: number;
key: string;
node: DSNode;
seen: Set<string>;
}): GrammarPattern[] => {
if (node.type === "Keyword") {
return [
literalPattern({ label: `${key}:keyword:${node.name}`, text: node.name }),
];
}
if (node.type === "String") {
return [literalPattern({ label: `${key}:string`, text: '"x"' })];
}
if (node.type === "AtKeyword") {
return [
literalPattern({ label: `${key}:at-keyword`, text: `@${node.name}` }),
];
}
if (node.type === "Token") {
return [literalPattern({ text: node.value })];
}
if (node.type === "Comma") {
return [literalPattern({ text: "," })];
}
if (node.type === "Function") {
return [literalPattern({ text: `${node.name}(` })];
}
if (node.type === "Type") {
const typeKey = `type:${node.name}`;
if (seen.has(typeKey)) {
const sample = sampleForType(node.name);
if (sample === undefined) {
return [];
}
return [
literalPattern({ label: `${key}:type:${node.name}`, text: sample }),
];
}
const nestedSyntax = syntaxData[node.name]?.syntax;
if (nestedSyntax !== undefined) {
const nextSeen = new Set(seen);
nextSeen.add(typeKey);
return buildPatterns({
depth: depth + 1,
key: `${key}:type:${node.name}`,
node: definitionSyntax.parse(nestedSyntax) as DSNode,
seen: nextSeen,
});
}
const sample = sampleForType(node.name);
if (sample === undefined) {
return [];
}
return [
literalPattern({ label: `${key}:type:${node.name}`, text: sample }),
];
}
if (node.type === "Property") {
const propertySyntax = propertyData[node.name]?.syntax;
if (propertySyntax === undefined) {
return [];
}
const propertyKey = `property:${node.name}`;
if (seen.has(propertyKey)) {
const override = propertyOverrides[node.name]?.[0];
if (override === undefined) {
return [];
}
return [
literalPattern({
label: `${key}:property:${node.name}`,
text: override,
}),
];
}
const nextSeen = new Set(seen);
nextSeen.add(propertyKey);
return buildPatterns({
depth: depth + 1,
key: `${key}:property:${node.name}`,
node: definitionSyntax.parse(propertySyntax) as DSNode,
seen: nextSeen,
});
}
if (node.type === "Multiplier") {
const basePatterns = buildPatterns({
depth: depth + 1,
key: `${key}:repeat`,
node: node.term,
seen,
});
if (basePatterns.length === 0) {
return [];
}
const patterns: GrammarPattern[] = [];
const count = repeatCount({ max: node.max, min: node.min });
for (const pattern of basePatterns) {
let combined = pattern;
for (let index = 1; index < count; index += 1) {
const separator = node.comma
? literalPattern({ text: "," })
: literalPattern({ text: "" });
combined = combinePatterns({ left: combined, right: separator });
combined = combinePatterns({ left: combined, right: pattern });
}
patterns.push(combined);
}
return takeBestPatterns({ limit: 4, patterns });
}
if (node.type !== "Group") {
return [];
}
if (node.combinator === "|") {
const branchPatterns = node.terms.flatMap((term: DSNode, index: number) => {
return buildPatterns({
depth: depth + 1,
key: `${key}:branch:${index}`,
node: term,
seen,
});
});
return takeBestPatterns({
limit: depth < 2 ? 4 : 1,
patterns: branchPatterns,
});
}
let current: GrammarPattern[] = [{ complexity: 0, parts: [] }];
for (const [index, term] of node.terms.entries()) {
const termPatterns = buildPatterns({
depth: depth + 1,
key: `${key}:term:${index}`,
node: term,
seen,
});
if (termPatterns.length === 0) {
return [];
}
const combined: GrammarPattern[] = [];
for (const left of current) {
for (const right of termPatterns) {
if (left.parts.length === 0) {
combined.push(trimPattern(right));
continue;
}
combined.push(combinePatterns({ left, right }));
}
}
current = takeBestPatterns({ limit: 6, patterns: combined });
}
return current;
};
const buildCases = ({
patterns,
property,
}: {
patterns: GrammarPattern[];
property: string;
}) => {
const cases = new Map<string, PropertyVarCase>();
for (const [patternIndex, pattern] of patterns.entries()) {
const slots = pattern.parts
.map((part, index) => ({ index, part }))
.filter(({ part }) => part.kind === "slot");
if (slots.length === 0) {
continue;
}
for (const [slotOrder, slot] of slots.entries()) {
const variableName = `--slot-${slotOrder + 1}`;
const parts = pattern.parts.map((part, index) => {
if (index !== slot.index || part.kind !== "slot") {
return part;
}
return {
kind: "text" as const,
text: `var(${variableName})`,
};
});
const value = normalizeCssValue(serializeParts(parts));
if (value === undefined) {
continue;
}
cases.set(`${property}:${value}`, {
id: `pattern-${patternIndex + 1}-slot-${slotOrder + 1}`,
positions: [slot.part.label ?? `slot-${slotOrder + 1}`],
value,
variables: { [variableName]: slot.part.text },
});
}
if (slots.length === 1) {
continue;
}
const variables: Record<string, string> = {};
const parts = pattern.parts.map((part, index) => {
const slotOrder = slots.findIndex((slot) => slot.index === index);
if (slotOrder === -1 || part.kind !== "slot") {
return part;
}
const variableName = `--slot-${slotOrder + 1}`;
variables[variableName] = part.text;
return {
kind: "text" as const,
text: `var(${variableName})`,
};
});
const value = normalizeCssValue(serializeParts(parts));
if (value === undefined) {
continue;
}
cases.set(`${property}:${value}`, {
id: `pattern-${patternIndex + 1}-all-slots`,
positions: slots.map(
({ part }, index) => part.label ?? `slot-${index + 1}`
),
value,
variables,
});
}
return [...cases.values()].sort((left, right) =>
left.id.localeCompare(right.id)
);
};
const buildFixtures = () => {
return getFilteredProperties().map(({ kind, property, syntax }) => {
const overridePatterns = propertyOverrides[property]?.flatMap(
(value, index) => {
const normalized = normalizeCssValue(value);
if (normalized === undefined) {
return [];
}
return [
{
complexity: index + 1,
parts: [{ kind: "text", text: normalized }],
} satisfies GrammarPattern,
];
}
);
const patterns =
propertyPatternOverrides[property] ??
(overridePatterns !== undefined && overridePatterns.length > 0
? overridePatterns
: buildPatterns({
depth: 0,
key: property,
node: definitionSyntax.parse(syntax) as DSNode,
seen: new Set([`property:${property}`]),
}));
const cases = buildCases({ patterns, property });
if (cases.length > 0) {
return {
cases,
kind,
property,
syntax,
} satisfies PropertyVarFixture;
}
return {
cases: [],
kind,
property,
syntax,
unsupportedReason:
"No valid grammar-driven var() positions could be synthesized",
} satisfies PropertyVarFixture;
});
};
const fixtures = buildFixtures();
const content =
autogeneratedHint +
`
export interface PropertyVarCase {
id: string;
positions: string[];
value: string;
variables: Record<string, string>;
}
export interface PropertyVarFixture {
cases: PropertyVarCase[];
kind: "longhand" | "shorthand";
property: string;
syntax: string;
unsupportedReason?: string;
}
export const propertyVarTestFixtures: PropertyVarFixture[] = ${JSON.stringify(fixtures, null, 2)} as const;
`;
mkdirSync(targetDir, { recursive: true });
writeFileSync(
join(targetDir, "property-var-test-fixtures.ts"),
content,
"utf8"
);
const caseCount = fixtures.reduce(
(total, fixture) => total + fixture.cases.length,
0
);
const unsupportedCount = fixtures.filter(
(fixture) => fixture.cases.length === 0
).length;
console.info(
`Written ${fixtures.length} fixtures with ${caseCount} cases to property-var-test-fixtures.ts (${unsupportedCount} unsupported)`
);
@@ -1,252 +0,0 @@
/**
* Generates src/__generated__/shorthand-test-fixtures.ts
*
* For every shorthand in shorthandProperties we produce two test cases:
*
* • singleToken – a CSS value string that, when used as the shorthand's value,
* expands so that exactly one (or more, if identical) longhand receives it.
* This tests the "cross-rule single-token var() preservation" path.
*
* • multiToken – a CSS value string that spans *all* longhands of the shorthand
* with different values per longhand, so no single longhand string equals the
* full value. This tests the "multi-token var() preservation" path.
*
* Values are derived from properties.ts initial values so the fixture stays in
* sync with the CSS spec data automatically.
*
* Run with: pnpm build:shorthand-fixtures
*/
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
import { generate, parse } from "css-tree";
import { toValue } from "@webstudio-is/css-engine";
import { shorthandProperties } from "../src/__generated__/shorthand-properties";
import { properties } from "../src/__generated__/properties";
import { expandShorthands } from "../src/shorthands";
import type { CssProperty } from "@webstudio-is/css-engine";
const __dir = dirname(fileURLToPath(import.meta.url));
const targetDir = join(__dir, "../src/__generated__");
const autogeneratedHint =
"// This file was generated by pnpm build:shorthand-fixtures\n";
const longhandProbeOverrides: Record<string, string[]> = {
"-webkit-text-stroke": ["1px red", "red", "1px"],
container: ["main / inline-size", "none", "normal"],
"grid-area": ["1 / 2", "auto"],
"grid-column": ["1 / 3", "auto"],
"grid-row": ["1 / 3", "auto"],
"grid-template": ["none / none", "none", "auto / 1fr"],
font: ["16px serif", "bold 18px/1.5 sans-serif", "menu"],
};
const fixtureOverrides: Record<
string,
{ singleToken: string; multiToken: string }
> = {
"-webkit-text-stroke": { singleToken: "red", multiToken: "1px red" },
container: { singleToken: "none", multiToken: "main / inline-size" },
font: {
singleToken: "16px serif",
multiToken: "bold 18px/1.5 sans-serif",
},
"grid-area": { singleToken: "auto", multiToken: "1 / 2 / 3 / 4" },
"grid-column": { singleToken: "auto", multiToken: "1 / 3" },
"grid-row": { singleToken: "auto", multiToken: "1 / 3" },
"grid-template": { singleToken: "none", multiToken: "none / 1fr" },
};
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Normalise a CSS value string through css-tree so whitespace/format matches
* what expandShorthands produces internally. */
const normalizeCss = (value: string): string => {
try {
return generate(parse(value, { context: "value" }));
} catch {
return value;
}
};
/** Return the initial CSS string for a longhand that exists in properties.ts,
* or undefined if unknown. */
const initialFor = (longhand: string): string | undefined => {
const p = properties[longhand as keyof typeof properties];
if (!p) {
return undefined;
}
return toValue(p.initial);
};
/** Given a shorthand name, drive expandShorthands with a placeholder value to
* get the canonical set of longhands it produces. Returns null when the
* shorthand passes straight through (i.e. expandShorthands doesn't know it). */
const getLonghands = (shorthand: string): string[] | null => {
// Probe with a value that is accepted by virtually every shorthand.
// We go through a few candidates until we get genuine expansion.
const probes = [
...(longhandProbeOverrides[shorthand] ?? []),
"none",
"initial",
"0",
"auto",
"normal",
];
for (const probe of probes) {
const expanded = [...expandShorthands([[shorthand as CssProperty, probe]])];
// A passthrough means expandShorthands didn't handle it.
if (expanded.length === 1 && expanded[0][0] === shorthand) {
continue;
}
if (expanded.length > 0) {
return expanded.map(([k]) => k);
}
}
return null;
};
// ---------------------------------------------------------------------------
// Build fixture entries
// ---------------------------------------------------------------------------
export type ShorthandFixture = {
/** The shorthand property name, e.g. "margin". */
property: string;
/**
* A CSS value string that maps to exactly one specific longhand initial value.
* When used as `margin: var(--v)` with `--v: <singleToken>`, the cross-rule
* path must:
* • preserve var() on whichever longhands match the single value
* • assign concrete initial resets to all other longhands
*
* If ALL longhands share the same initial value, singleToken equals that
* shared value and ALL longhands get var().
*/
singleToken: string;
/**
* A CSS value string that spans multiple longhands differently.
* When used as `margin: var(--v)` with `--v: <multiToken>`, the cross-rule
* path must assign var() to ALL longhands (because no individual longhand
* string equals the multi-token string).
*/
multiToken: string;
/** All longhand properties this shorthand expands to. */
longhands: string[];
};
const fixtures: ShorthandFixture[] = [];
for (const shorthand of shorthandProperties) {
const longhands = getLonghands(shorthand);
if (!longhands || longhands.length === 0) {
// expandShorthands doesn't handle this shorthand; skip so the test can
// explicitly mark it as "passthrough" rather than silently missing it.
fixtures.push({
property: shorthand,
singleToken: "",
multiToken: "",
longhands: [],
});
continue;
}
const override = fixtureOverrides[shorthand];
if (override) {
fixtures.push({
property: shorthand,
singleToken: override.singleToken,
multiToken: override.multiToken,
longhands,
});
continue;
}
// Collect normalised initial value strings for every known longhand.
const initStrings = longhands.map((l) => {
const raw = initialFor(l);
return raw !== undefined ? normalizeCss(raw) : null;
});
// --- singleToken: use the initial value of the first longhand that has one.
const singleToken =
initStrings.find((s): s is string => s !== null) ??
// absolute fallback – should not be reached for any supported shorthand
"none";
// --- multiToken: we need a value that, when used as the shorthand,
// produces different strings per longhand (so no individual longhand
// string equals the full shorthand string).
//
// Strategy: expand with a concrete value through expandShorthands and
// re-join the distinct longhand values. If we can reconstruct a valid
// shorthand value from the longhand initials via a known concrete
// shorthand value, we use it. Otherwise we pick a value where the
// longest longhand value is different from the shortest, making the
// "full string ≠ any single expanded string" invariant hold.
//
// For properties where every longhand has the same initial (margin,
// padding, …) we make multiToken a two-value variant so they differ.
const allSame =
initStrings.every((s) => s !== null) &&
new Set(initStrings as string[]).size === 1;
let multiToken: string;
if (allSame && initStrings[0] !== null) {
// Build a two-value form, e.g. "0px 8px" for margin.
// The two values must be genuinely different.
const base = initStrings[0] as string;
// Pick an alternative that is obviously different.
const alt =
base === "0px"
? "8px"
: base === "none"
? "url(#x)"
: base === "auto"
? "0px"
: "16px";
multiToken = `${base} ${alt}`;
} else {
// Join all distinct non-null initial values. Even though this may not be
// valid CSS for the shorthand, it will:
// a) not parse as a valid shorthand via expandShorthands, OR
// b) parse and give genuinely different per-longhand values.
// Either way the multi-token cross-rule path kicks in correctly.
const distinct = [
...new Set(initStrings.filter((s): s is string => s !== null)),
];
multiToken =
distinct.length >= 2
? distinct.join(" ")
: `${distinct[0] ?? "none"} extra`;
}
fixtures.push({ property: shorthand, singleToken, multiToken, longhands });
}
// ---------------------------------------------------------------------------
// Write output
// ---------------------------------------------------------------------------
const content =
autogeneratedHint +
`
export type ShorthandFixture = {
property: string;
singleToken: string;
multiToken: string;
longhands: string[];
};
export const shorthandTestFixtures: ShorthandFixture[] = ${JSON.stringify(fixtures, null, 2)} as const;
`;
mkdirSync(targetDir, { recursive: true });
writeFileSync(join(targetDir, "shorthand-test-fixtures.ts"), content, "utf8");
console.info(
`Written ${fixtures.length} fixtures to shorthand-test-fixtures.ts`
);
+2 -2
View File
@@ -10,8 +10,8 @@
"build:html.css": "tsx ./bin/html.css.ts && prettier --write ./src/__generated__/html.ts",
"build:mdn-data": "tsx ./bin/mdn-data.ts ./src/__generated__ && prettier --write \"./src/__generated__/\" \"../css-engine/src/__generated__/\"",
"build:descriptions": "tsx ./bin/property-value-descriptions.ts && prettier --write ./src/__generated__/property-value-descriptions.ts",
"build:shorthand-fixtures": "tsx ./bin/shorthand-test-fixtures.ts && prettier --write ./src/__generated__/shorthand-test-fixtures.ts",
"build:all": "pnpm build:html.css && pnpm build:mdn-data && pnpm build:descriptions && pnpm build:shorthand-fixtures",
"build:property-var-fixtures": "tsx ./bin/property-var-test-fixtures.ts && prettier --write ./src/__generated__/property-var-test-fixtures.ts",
"build:all": "pnpm build:html.css && pnpm build:mdn-data && pnpm build:descriptions && pnpm build:property-var-fixtures",
"test": "vitest run"
},
"bin": {
File diff suppressed because it is too large. Load diff
@@ -35,6 +35,8 @@ export const shorthandProperties = [
"flex",
"flex-flow",
"font",
"font-synthesis",
"font-variant",
"gap",
"grid",
"grid-area",
@@ -69,8 +71,10 @@ export const shorthandProperties = [
"stroke",
"text-decoration",
"text-emphasis",
"text-wrap",
"timeline-trigger",
"timeline-trigger-activation-range",
"timeline-trigger-active-range",
"transition",
"white-space",
] as const;
@@ -1,619 +0,0 @@
// This file was generated by pnpm build:shorthand-fixtures
export type ShorthandFixture = {
property: string;
singleToken: string;
multiToken: string;
longhands: string[];
};
export const shorthandTestFixtures: ShorthandFixture[] = [
{
property: "-webkit-text-stroke",
singleToken: "red",
multiToken: "1px red",
longhands: ["-webkit-text-stroke-width", "-webkit-text-stroke-color"],
},
{
property: "animation",
singleToken: "0s",
multiToken: "0s ease 1 normal none running",
longhands: [
"animation-duration",
"animation-timing-function",
"animation-delay",
"animation-iteration-count",
"animation-direction",
"animation-fill-mode",
"animation-play-state",
"animation-name",
"animation-timeline",
"animation-range-start",
"animation-range-end",
],
},
{
property: "background",
singleToken: "none",
multiToken: "none 0% auto repeat scroll padding-box border-box transparent",
longhands: [
"background-image",
"background-position-x",
"background-position-y",
"background-size",
"background-repeat",
"background-attachment",
"background-origin",
"background-clip",
"background-color",
],
},
{
property: "border",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-top-width",
"border-right-width",
"border-bottom-width",
"border-left-width",
"border-top-style",
"border-right-style",
"border-bottom-style",
"border-left-style",
"border-top-color",
"border-right-color",
"border-bottom-color",
"border-left-color",
],
},
{
property: "border-block",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-block-start-width",
"border-block-end-width",
"border-block-start-style",
"border-block-end-style",
"border-block-start-color",
"border-block-end-color",
],
},
{
property: "border-block-end",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-block-end-width",
"border-block-end-style",
"border-block-end-color",
],
},
{
property: "border-block-start",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-block-start-width",
"border-block-start-style",
"border-block-start-color",
],
},
{
property: "border-bottom",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-bottom-width",
"border-bottom-style",
"border-bottom-color",
],
},
{
property: "border-color",
singleToken: "currentColor",
multiToken: "currentColor 16px",
longhands: [
"border-top-color",
"border-right-color",
"border-bottom-color",
"border-left-color",
],
},
{
property: "border-image",
singleToken: "none",
multiToken: "none 100% 1 0 stretch",
longhands: [
"border-image-source",
"border-image-slice",
"border-image-width",
"border-image-outset",
"border-image-repeat",
],
},
{
property: "border-inline",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-inline-start-width",
"border-inline-end-width",
"border-inline-start-style",
"border-inline-end-style",
"border-inline-start-color",
"border-inline-end-color",
],
},
{
property: "border-inline-end",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-inline-end-width",
"border-inline-end-style",
"border-inline-end-color",
],
},
{
property: "border-inline-start",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-inline-start-width",
"border-inline-start-style",
"border-inline-start-color",
],
},
{
property: "border-left",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: ["border-left-width", "border-left-style", "border-left-color"],
},
{
property: "border-radius",
singleToken: "0px",
multiToken: "0px 8px",
longhands: [
"border-top-left-radius",
"border-top-right-radius",
"border-bottom-right-radius",
"border-bottom-left-radius",
],
},
{
property: "border-right",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: [
"border-right-width",
"border-right-style",
"border-right-color",
],
},
{
property: "border-style",
singleToken: "none",
multiToken: "none url(#x)",
longhands: [
"border-top-style",
"border-right-style",
"border-bottom-style",
"border-left-style",
],
},
{
property: "border-top",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: ["border-top-width", "border-top-style", "border-top-color"],
},
{
property: "border-width",
singleToken: "medium",
multiToken: "medium 16px",
longhands: [
"border-top-width",
"border-right-width",
"border-bottom-width",
"border-left-width",
],
},
{
property: "column-rule",
singleToken: "medium",
multiToken: "medium none currentColor",
longhands: ["column-rule-width", "column-rule-style", "column-rule-color"],
},
{
property: "columns",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["column-width", "column-count"],
},
{
property: "contain-intrinsic-size",
singleToken: "none",
multiToken: "none url(#x)",
longhands: ["contain-intrinsic-width", "contain-intrinsic-height"],
},
{
property: "container",
singleToken: "none",
multiToken: "main / inline-size",
longhands: ["container-name", "container-type"],
},
{
property: "corner-block-end-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-block-start-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-bottom-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-inline-end-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-inline-start-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-left-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-right-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "corner-top-shape",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "flex",
singleToken: "0",
multiToken: "0 1 auto",
longhands: ["flex-grow", "flex-shrink", "flex-basis"],
},
{
property: "flex-flow",
singleToken: "row",
multiToken: "row nowrap",
longhands: ["flex-direction", "flex-wrap"],
},
{
property: "font",
singleToken: "16px serif",
multiToken: "bold 18px/1.5 sans-serif",
longhands: [
"font-style",
"font-variant-caps",
"font-weight",
"font-stretch",
"font-size",
"line-height",
"font-family",
],
},
{
property: "gap",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["row-gap", "column-gap"],
},
{
property: "grid",
singleToken: "none",
multiToken: "none auto",
longhands: [
"grid-template-areas",
"grid-template-rows",
"grid-template-columns",
"grid-auto-flow.",
"grid-auto-rows",
"grid-auto-columns",
],
},
{
property: "grid-area",
singleToken: "auto",
multiToken: "1 / 2 / 3 / 4",
longhands: [
"grid-row-start",
"grid-column-start",
"grid-row-end",
"grid-column-end",
],
},
{
property: "grid-column",
singleToken: "auto",
multiToken: "1 / 3",
longhands: ["grid-column-start", "grid-column-end"],
},
{
property: "grid-row",
singleToken: "auto",
multiToken: "1 / 3",
longhands: ["grid-row-start", "grid-row-end"],
},
{
property: "grid-template",
singleToken: "none",
multiToken: "none / 1fr",
longhands: [
"grid-template-areas",
"grid-template-rows",
"grid-template-columns",
],
},
{
property: "inset",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["top", "right", "bottom", "left"],
},
{
property: "inset-block",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["inset-block-start", "inset-block-end"],
},
{
property: "inset-inline",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["inset-inline-start", "inset-inline-end"],
},
{
property: "interest-delay",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "list-style",
singleToken: "outside",
multiToken: "outside none disc",
longhands: ["list-style-position", "list-style-image", "list-style-type"],
},
{
property: "margin",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["margin-top", "margin-right", "margin-bottom", "margin-left"],
},
{
property: "margin-block",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["margin-block-start", "margin-block-end"],
},
{
property: "margin-inline",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["margin-inline-start", "margin-inline-end"],
},
{
property: "marker",
singleToken: "none",
multiToken: "none url(#x)",
longhands: ["marker-start", "marker-mid", "marker-end"],
},
{
property: "mask",
singleToken: "none",
multiToken: "none 0% 0% auto repeat border-box add match-source",
longhands: [
"mask-image",
"mask-position",
"mask-size",
"mask-repeat",
"mask-origin",
"mask-clip",
"mask-composite",
"mask-mode",
],
},
{
property: "mask-border",
singleToken: "none",
multiToken: "none 0 auto stretch alpha",
longhands: [
"mask-border-source",
"mask-border-slice",
"mask-border-width",
"mask-border-outset",
"mask-border-repeat",
"mask-border-mode",
],
},
{
property: "offset",
singleToken: "normal",
multiToken: "normal none 0px auto",
longhands: [
"offset-position",
"offset-path",
"offset-distance",
"offset-rotate",
"offset-anchor",
],
},
{
property: "outline",
singleToken: "medium",
multiToken: "medium none auto",
longhands: ["outline-width", "outline-style", "outline-color"],
},
{
property: "padding",
singleToken: "0px",
multiToken: "0px 8px",
longhands: [
"padding-top",
"padding-right",
"padding-bottom",
"padding-left",
],
},
{
property: "padding-block",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["padding-block-start", "padding-block-end"],
},
{
property: "padding-inline",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["padding-inline-start", "padding-inline-end"],
},
{
property: "place-content",
singleToken: "normal",
multiToken: "normal 16px",
longhands: ["align-content", "justify-content"],
},
{
property: "place-items",
singleToken: "normal",
multiToken: "normal legacy",
longhands: ["align-items", "justify-items"],
},
{
property: "place-self",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["align-self", "justify-self"],
},
{
property: "scroll-margin",
singleToken: "0px",
multiToken: "0px 8px",
longhands: [
"scroll-margin-top",
"scroll-margin-right",
"scroll-margin-bottom",
"scroll-margin-left",
],
},
{
property: "scroll-margin-block",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["scroll-margin-block-start", "scroll-margin-block-end"],
},
{
property: "scroll-margin-inline",
singleToken: "0px",
multiToken: "0px 8px",
longhands: ["scroll-margin-inline-start", "scroll-margin-inline-end"],
},
{
property: "scroll-padding",
singleToken: "auto",
multiToken: "auto 0px",
longhands: [
"scroll-padding-top",
"scroll-padding-right",
"scroll-padding-bottom",
"scroll-padding-left",
],
},
{
property: "scroll-padding-block",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["scroll-padding-block-start", "scroll-padding-block-end"],
},
{
property: "scroll-padding-inline",
singleToken: "auto",
multiToken: "auto 0px",
longhands: ["scroll-padding-inline-start", "scroll-padding-inline-end"],
},
{
property: "stroke",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "text-decoration",
singleToken: "none",
multiToken: "none solid currentColor",
longhands: [
"text-decoration-line",
"text-decoration-style",
"text-decoration-color",
],
},
{
property: "text-emphasis",
singleToken: "none",
multiToken: "none currentColor",
longhands: ["text-emphasis-style", "text-emphasis-color"],
},
{
property: "timeline-trigger",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "timeline-trigger-activation-range",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "timeline-trigger-active-range",
singleToken: "",
multiToken: "",
longhands: [],
},
{
property: "transition",
singleToken: "all",
multiToken: "all 0s ease normal",
longhands: [
"transition-property",
"transition-duration",
"transition-timing-function",
"transition-delay",
"transition-behavior",
],
},
] as const;
+183 -606
View File
@@ -5,7 +5,7 @@ import {
parseCss,
parseMediaQuery,
} from "./parse-css";
import { shorthandTestFixtures } from "./__generated__/shorthand-test-fixtures";
import { propertyVarTestFixtures } from "./__generated__/property-var-test-fixtures";
describe("Parse CSS", () => {
test("longhand property name with keyword value", () => {
@@ -1871,6 +1871,11 @@ const layers = (...items: unknown[]) =>
const prop = (property: string, value: unknown) =>
expect.objectContaining({ property, value });
const valueContainsVarReference = (value: unknown): boolean => {
const serialized = JSON.stringify(value);
return serialized.includes('"type":"var"') || serialized.includes("var(");
};
const decls = (css: string) =>
parseCss(`.x { ${css} }`, new Map()).styles.filter(
(d) => d.selector === ".x"
@@ -1890,6 +1895,63 @@ describe("var() substitution — background", () => {
);
});
test("gradient var() references are preserved in background-image", () => {
const result = decls(`
background: linear-gradient(38deg, var(--aurora-clr1), var(--aurora-clr2), var(--aurora-clr3));
`);
expect(result).toEqual(
expect.arrayContaining([
prop(
"background-image",
layers(
expect.objectContaining({
type: "unparsed",
value:
"linear-gradient(38deg,var(--aurora-clr1),var(--aurora-clr2),var(--aurora-clr3))",
})
)
),
])
);
});
test("cross-selector background gradients keep authored var() references", () => {
const result = parseCss(
`
.scope {
--tone-1: #ff0000;
--tone-2: #00ff00;
--tone-3: #0000ff;
}
.panel {
background: linear-gradient(38deg, var(--tone-1), var(--tone-2), var(--tone-3));
}
`,
new Map([
["--tone-1", "#ff0000"],
["--tone-2", "#00ff00"],
["--tone-3", "#0000ff"],
])
).styles;
const backgroundImage = result.find(
(decl) =>
decl.selector === ".panel" && decl.property === "background-image"
);
expect(backgroundImage?.value).toEqual(
expect.objectContaining({
type: "layers",
value: [
expect.objectContaining({
type: "unparsed",
value:
"linear-gradient(38deg,var(--tone-1),var(--tone-2),var(--tone-3))",
}),
],
})
);
});
test("multiple vars for image and repeat parts", () => {
const result = decls(`
--img: url("hero.png");
@@ -2036,85 +2098,87 @@ describe("var() substitution — CSS var() inline fallback", () => {
`"border" was not applied because --clr could not be resolved`,
]);
});
});
describe("var() substitution — border", () => {
test("var() for border width", () => {
const result = decls(`--w: 3px; border: var(--w) solid red;`);
test("border shorthand with unresolved var is preserved as var() value", () => {
const result = decls(`border: 1px solid var(--border-color);`);
expect(result).toEqual(
expect.arrayContaining([
prop("border-top-width", u(3, "px")),
prop("border-right-width", u(3, "px")),
prop("border-bottom-width", u(3, "px")),
prop("border-left-width", u(3, "px")),
prop(
"border-top-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-right-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-bottom-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-left-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
])
);
});
test("var() for border color", () => {
const result = decls(`--clr: red; border: 1px solid var(--clr);`);
test("border-color longhand with unresolved var is preserved", () => {
const result = decls(`border-color: var(--border-color);`);
expect(result).toEqual(
expect.arrayContaining([
prop("border-top-color", kw("red")),
prop("border-bottom-color", kw("red")),
prop(
"border-top-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
])
);
});
test("var() for both border width and color", () => {
test("outline shorthand does not leak unresolved width var into color", () => {
const result = decls(`outline: var(--outline-width) solid;`);
expect(result).toEqual(
expect.arrayContaining([
prop(
"outline-width",
expect.objectContaining({ type: "var", value: "outline-width" })
),
])
);
const outlineColor = result.find(
(decl) => decl.property === "outline-color"
);
expect(outlineColor).toBeDefined();
expect(outlineColor?.value).not.toEqual(
expect.objectContaining({ type: "var" })
);
});
test("border shorthand with repeated unresolved var preserves color var", () => {
const result = decls(
`--w: 2px; --clr: blue; border: var(--w) dashed var(--clr);`
`border: var(--border-color) solid var(--border-color);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("border-top-width", u(2, "px")),
prop("border-top-color", kw("blue")),
])
);
});
test("var() for border-top color", () => {
const result = decls(`--clr: orange; border-top: 1px solid var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([prop("border-top-color", kw("orange"))])
);
});
test("var() for border-bottom width", () => {
const result = decls(`--w: 4px; border-bottom: var(--w) solid black;`);
expect(result).toEqual(
expect.arrayContaining([prop("border-bottom-width", u(4, "px"))])
);
});
test("var() for border-left width and color", () => {
const result = decls(
`--w: 1px; --clr: purple; border-left: var(--w) solid var(--clr);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("border-left-width", u(1, "px")),
prop("border-left-color", kw("purple")),
])
);
});
test("var() for border-block color", () => {
const result = decls(`--clr: teal; border-block: 2px solid var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([
prop("border-block-start-color", kw("teal")),
prop("border-block-end-color", kw("teal")),
])
);
});
test("var() for border-inline width", () => {
const result = decls(`--w: 3px; border-inline: var(--w) solid black;`);
expect(result).toEqual(
expect.arrayContaining([
prop("border-inline-start-width", u(3, "px")),
prop("border-inline-end-width", u(3, "px")),
prop(
"border-top-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-right-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-bottom-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
prop(
"border-left-color",
expect.objectContaining({ type: "var", value: "border-color" })
),
])
);
});
@@ -2209,500 +2273,6 @@ describe("var() substitution — transitive (nested) var() resolution", () => {
});
});
describe("var() substitution — outline", () => {
test("var() for outline color", () => {
const result = decls(`--clr: red; outline: 2px solid var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([prop("outline-color", kw("red"))])
);
});
test("var() for outline width", () => {
const result = decls(`--w: 3px; outline: var(--w) dotted black;`);
expect(result).toEqual(
expect.arrayContaining([prop("outline-width", u(3, "px"))])
);
});
test("var() for both outline width and color", () => {
const result = decls(
`--w: 1px; --clr: navy; outline: var(--w) solid var(--clr);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("outline-width", u(1, "px")),
prop("outline-color", kw("navy")),
])
);
});
});
describe("var() substitution — text-decoration", () => {
test("var() for text-decoration color", () => {
const result = decls(`--clr: red; text-decoration: underline var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([
prop("text-decoration-line", kw("underline")),
prop("text-decoration-color", kw("red")),
])
);
});
test("var() for text-decoration style", () => {
const result = decls(
`--sty: dashed; text-decoration: underline var(--sty) red;`
);
expect(result).toEqual(
expect.arrayContaining([
prop("text-decoration-style", kw("dashed")),
prop("text-decoration-color", kw("red")),
])
);
});
});
describe("var() substitution — text-emphasis", () => {
test("var() for text-emphasis color", () => {
const result = decls(`--clr: hotpink; text-emphasis: filled var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([prop("text-emphasis-color", kw("hotpink"))])
);
});
});
describe("var() substitution — margin / padding", () => {
test("var() for all sides of margin", () => {
const result = decls(`--sp: 16px; margin: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("margin-top", u(16, "px")),
prop("margin-right", u(16, "px")),
prop("margin-bottom", u(16, "px")),
prop("margin-left", u(16, "px")),
])
);
});
test("var() for vertical/horizontal margin", () => {
const result = decls(`--v: 8px; --h: 16px; margin: var(--v) var(--h);`);
expect(result).toEqual(
expect.arrayContaining([
prop("margin-top", u(8, "px")),
prop("margin-right", u(16, "px")),
prop("margin-bottom", u(8, "px")),
prop("margin-left", u(16, "px")),
])
);
});
test("var() for all sides of padding", () => {
const result = decls(`--sp: 12px; padding: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("padding-top", u(12, "px")),
prop("padding-right", u(12, "px")),
prop("padding-bottom", u(12, "px")),
prop("padding-left", u(12, "px")),
])
);
});
test("var() for padding four-side notation", () => {
const result = decls(
`--a: 4px; --b: 8px; --c: 12px; --d: 16px; padding: var(--a) var(--b) var(--c) var(--d);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("padding-top", u(4, "px")),
prop("padding-right", u(8, "px")),
prop("padding-bottom", u(12, "px")),
prop("padding-left", u(16, "px")),
])
);
});
test("var() for margin-inline", () => {
const result = decls(`--sp: 10px; margin-inline: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("margin-inline-start", u(10, "px")),
prop("margin-inline-end", u(10, "px")),
])
);
});
test("var() for margin-block start and end", () => {
const result = decls(
`--s: 8px; --e: 16px; margin-block: var(--s) var(--e);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("margin-block-start", u(8, "px")),
prop("margin-block-end", u(16, "px")),
])
);
});
test("var() for padding-inline", () => {
const result = decls(`--sp: 20px; padding-inline: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("padding-inline-start", u(20, "px")),
prop("padding-inline-end", u(20, "px")),
])
);
});
test("var() for padding-block start and end", () => {
const result = decls(
`--s: 5px; --e: 10px; padding-block: var(--s) var(--e);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("padding-block-start", u(5, "px")),
prop("padding-block-end", u(10, "px")),
])
);
});
});
describe("var() substitution — inset", () => {
test("var() for all inset sides", () => {
const result = decls(`--sp: 0px; inset: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("top", u(0, "px")),
prop("right", u(0, "px")),
prop("bottom", u(0, "px")),
prop("left", u(0, "px")),
])
);
});
test("var() for inset two-value notation", () => {
const result = decls(`--v: 10px; --h: 20px; inset: var(--v) var(--h);`);
expect(result).toEqual(
expect.arrayContaining([
prop("top", u(10, "px")),
prop("right", u(20, "px")),
prop("bottom", u(10, "px")),
prop("left", u(20, "px")),
])
);
});
test("var() for inset-inline", () => {
const result = decls(
`--s: 5px; --e: 15px; inset-inline: var(--s) var(--e);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("inset-inline-start", u(5, "px")),
prop("inset-inline-end", u(15, "px")),
])
);
});
test("var() for inset-block", () => {
const result = decls(`--sp: 8px; inset-block: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("inset-block-start", u(8, "px")),
prop("inset-block-end", u(8, "px")),
])
);
});
});
describe("var() substitution — gap", () => {
test("var() for both row and column gap", () => {
const result = decls(`--sp: 24px; gap: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("row-gap", u(24, "px")),
prop("column-gap", u(24, "px")),
])
);
});
test("var() for row and column gap separately", () => {
const result = decls(`--rg: 16px; --cg: 32px; gap: var(--rg) var(--cg);`);
expect(result).toEqual(
expect.arrayContaining([
prop("row-gap", u(16, "px")),
prop("column-gap", u(32, "px")),
])
);
});
});
describe("var() substitution — flex", () => {
test("var() for flex-basis", () => {
const result = decls(`--basis: 200px; flex: 1 1 var(--basis);`);
expect(result).toEqual(
expect.arrayContaining([
prop("flex-grow", num(1)),
prop("flex-shrink", num(1)),
prop("flex-basis", u(200, "px")),
])
);
});
test("var() for flex-grow and flex-shrink", () => {
const result = decls(`--g: 2; --s: 0; flex: var(--g) var(--s) auto;`);
expect(result).toEqual(
expect.arrayContaining([
prop("flex-grow", num(2)),
prop("flex-shrink", num(0)),
prop("flex-basis", kw("auto")),
])
);
});
});
describe("var() substitution — column-rule", () => {
test("var() for column-rule color", () => {
const result = decls(`--clr: silver; column-rule: 1px solid var(--clr);`);
expect(result).toEqual(
expect.arrayContaining([prop("column-rule-color", kw("silver"))])
);
});
test("var() for column-rule width", () => {
const result = decls(`--w: 2px; column-rule: var(--w) solid black;`);
expect(result).toEqual(
expect.arrayContaining([prop("column-rule-width", u(2, "px"))])
);
});
});
describe("var() substitution — animation", () => {
test("var() for animation duration", () => {
const result = decls(
`--dur: 0.5s; animation: slide var(--dur) ease infinite;`
);
const duration = result.find((d) => d.property === "animation-duration");
expect(duration?.value).toEqual(u(0.5, "s"));
});
test("var() for animation duration and delay", () => {
const result = decls(
`--dur: 1s; --del: 200ms; animation: bounce var(--dur) ease var(--del);`
);
const duration = result.find((d) => d.property === "animation-duration");
const delay = result.find((d) => d.property === "animation-delay");
expect(duration?.value).toEqual(u(1, "s"));
expect(delay?.value).toEqual(u(200, "ms"));
});
});
describe("var() substitution — transition", () => {
test("var() for transition duration", () => {
const result = decls(`--dur: 300ms; transition: opacity var(--dur) ease;`);
const duration = result.find((d) => d.property === "transition-duration");
expect(duration?.value).toEqual(
layers(expect.objectContaining({ type: "unit", value: 300, unit: "ms" }))
);
});
test("var() for transition duration and delay", () => {
const result = decls(
`--dur: 200ms; --del: 50ms; transition: color var(--dur) linear var(--del);`
);
const duration = result.find((d) => d.property === "transition-duration");
const delay = result.find((d) => d.property === "transition-delay");
expect(duration?.value).toEqual(
layers(expect.objectContaining({ type: "unit", value: 200, unit: "ms" }))
);
expect(delay?.value).toEqual(
layers(expect.objectContaining({ type: "unit", value: 50, unit: "ms" }))
);
});
test("var() for transition-property name", () => {
// transition-property stores property names as unparsed (not keyword)
const result = decls(`--prop: opacity; transition: var(--prop) 300ms;`);
const property = result.find((d) => d.property === "transition-property");
expect(property?.value).toEqual(
layers(expect.objectContaining({ type: "unparsed", value: "opacity" }))
);
});
});
describe("var() substitution — grid-row / grid-column / grid-area", () => {
test("var() for grid-row start", () => {
const result = decls(`--start: 2; grid-row: var(--start) / 4;`);
expect(result).toEqual(
expect.arrayContaining([
prop("grid-row-start", num(2)),
prop("grid-row-end", num(4)),
])
);
});
test("var() for grid-column end", () => {
const result = decls(`--end: 3; grid-column: 1 / var(--end);`);
expect(result).toEqual(
expect.arrayContaining([
prop("grid-column-start", num(1)),
prop("grid-column-end", num(3)),
])
);
});
});
describe("var() substitution — columns", () => {
test("var() for column-width", () => {
const result = decls(`--w: 200px; columns: var(--w) auto;`);
expect(result).toEqual(
expect.arrayContaining([prop("column-width", u(200, "px"))])
);
});
test("var() for column-count", () => {
const result = decls(`--n: 3; columns: auto var(--n);`);
expect(result).toEqual(
expect.arrayContaining([prop("column-count", num(3))])
);
});
});
describe("var() substitution — list-style", () => {
test("var() for list-style-type", () => {
const result = decls(`--type: square; list-style: var(--type);`);
expect(result).toEqual(
expect.arrayContaining([prop("list-style-type", kw("square"))])
);
});
});
describe("var() substitution — place-content / place-items / place-self", () => {
test("var() for place-content align and justify", () => {
const result = decls(
`--a: center; --j: start; place-content: var(--a) var(--j);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("align-content", kw("center")),
prop("justify-content", kw("start")),
])
);
});
test("var() for place-items align", () => {
const result = decls(`--a: end; place-items: var(--a) center;`);
expect(result).toEqual(
expect.arrayContaining([prop("align-items", kw("end"))])
);
});
test("var() for place-self justify", () => {
const result = decls(`--j: stretch; place-self: auto var(--j);`);
expect(result).toEqual(
expect.arrayContaining([prop("justify-self", kw("stretch"))])
);
});
});
describe("var() substitution — contain-intrinsic-size", () => {
test("var() for both intrinsic dimensions", () => {
const result = decls(
`--w: 300px; --h: 200px; contain-intrinsic-size: var(--w) var(--h);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("contain-intrinsic-width", u(300, "px")),
prop("contain-intrinsic-height", u(200, "px")),
])
);
});
});
describe("var() substitution — scroll-margin / scroll-padding", () => {
test("var() for all scroll-margin sides", () => {
const result = decls(`--sp: 8px; scroll-margin: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("scroll-margin-top", u(8, "px")),
prop("scroll-margin-right", u(8, "px")),
prop("scroll-margin-bottom", u(8, "px")),
prop("scroll-margin-left", u(8, "px")),
])
);
});
test("var() for scroll-padding vertical/horizontal", () => {
const result = decls(
`--v: 4px; --h: 8px; scroll-padding: var(--v) var(--h);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("scroll-padding-top", u(4, "px")),
prop("scroll-padding-right", u(8, "px")),
prop("scroll-padding-bottom", u(4, "px")),
prop("scroll-padding-left", u(8, "px")),
])
);
});
test("var() for scroll-padding-inline", () => {
const result = decls(`--sp: 12px; scroll-padding-inline: var(--sp);`);
expect(result).toEqual(
expect.arrayContaining([
prop("scroll-padding-inline-start", u(12, "px")),
prop("scroll-padding-inline-end", u(12, "px")),
])
);
});
test("var() for scroll-margin-block", () => {
const result = decls(
`--s: 6px; --e: 10px; scroll-margin-block: var(--s) var(--e);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("scroll-margin-block-start", u(6, "px")),
prop("scroll-margin-block-end", u(10, "px")),
])
);
});
});
describe("var() substitution — font", () => {
test("var() for font-size", () => {
const result = decls(`--sz: 18px; font: var(--sz) Arial;`);
expect(result).toEqual(
expect.arrayContaining([prop("font-size", u(18, "px"))])
);
});
test("var() for font-weight and font-size", () => {
const result = decls(
`--w: 700; --sz: 16px; font: var(--w) var(--sz) sans-serif;`
);
expect(result).toEqual(
expect.arrayContaining([
prop("font-weight", num(700)),
prop("font-size", u(16, "px")),
])
);
});
});
describe("var() substitution — -webkit-text-stroke", () => {
test("var() for stroke width and color", () => {
const result = decls(
`--w: 1px; --clr: black; -webkit-text-stroke: var(--w) var(--clr);`
);
expect(result).toEqual(
expect.arrayContaining([
prop("-webkit-text-stroke-width", u(1, "px")),
prop("-webkit-text-stroke-color", kw("black")),
])
);
});
});
describe("parseCss — external cssVars parameter", () => {
// Helper that passes an external var map
const declsWithVars = (css: string, vars: Record<string, string>) =>
@@ -3020,69 +2590,76 @@ describe("parseCss — external cssVars parameter", () => {
);
});
// ── comprehensive shorthand coverage ─────────────────────────────────────
//
// Test cases are driven entirely by the generated fixture:
// src/__generated__/shorthand-test-fixtures.ts
//
// Regenerate with: pnpm build:shorthand-fixtures
//
// For every shorthand that expandShorthands() handles we run two cases:
// • singleToken – a value matching exactly a single longhand: at least
// one longhand must have type:"var", none may be type:"invalid".
// • multiToken – a value spanning *all* longhands differently: every
// longhand must have type:"var".
//
// Shorthands whose longhands[] is empty are not handled by expandShorthands
// (they pass through), so no cross-rule substitution is expected from them.
const expandableFixtures = shorthandTestFixtures.filter(
(f) => f.longhands.length > 0
const unsupportedFixtures = propertyVarTestFixtures.filter(
(fixture) => fixture.cases.length === 0
);
test.each(expandableFixtures)(
"cross-rule single-token var: $property — at least one var longhand, no invalids",
({ property, singleToken }) => {
const result = declsWithVars(`${property}: var(--v);`, {
"--v": singleToken,
});
test("grammar var fixture covers every supported property", () => {
expect(unsupportedFixtures).toEqual([]);
});
const generatedCases = propertyVarTestFixtures.flatMap((fixture) => {
return fixture.cases.map((testCase) => ({
caseId: testCase.id,
kind: fixture.kind,
positions: testCase.positions,
property: fixture.property,
syntax: fixture.syntax,
value: testCase.value,
variables: testCase.variables,
}));
});
const knownResolvedGapCases = new Set([
"font/pattern-1-slot-1",
"list-style/pattern-1-slot-2",
]);
const resolvableGeneratedCases = generatedCases.filter((testCase) => {
return (
knownResolvedGapCases.has(`${testCase.property}/${testCase.caseId}`) ===
false
);
});
test.each(resolvableGeneratedCases)(
"grammar var case: $property / $caseId — cssVars resolve without invalid values",
({ property, value, variables }) => {
const result = declsWithVars(`${property}: ${value};`, variables);
expect(result.length).toBeGreaterThan(0);
const invalids = result.filter((d) => d.value.type === "invalid");
const vars = result.filter((d) => d.value.type === "var");
const invalids = result.filter((decl) => decl.value.type === "invalid");
expect(invalids).toHaveLength(0);
expect(vars.length).toBeGreaterThan(0);
}
);
test.each(expandableFixtures)(
"cross-rule multi-token var: $property — all longhands are var(), no invalids",
({ property, multiToken }) => {
const result = declsWithVars(`${property}: var(--v);`, {
"--v": multiToken,
});
expect(result.length).toBeGreaterThan(0);
const invalids = result.filter((d) => d.value.type === "invalid");
const vars = result.filter((d) => d.value.type === "var");
expect(invalids).toHaveLength(0);
expect(vars).toHaveLength(result.length);
const unresolvedGeneratedGapCases = generatedCases.filter((testCase) => {
return knownResolvedGapCases.has(`${testCase.property}/${testCase.caseId}`);
});
test.each(unresolvedGeneratedGapCases)(
"grammar var case: $property / $caseId — current parser gap is explicit",
({ property, value, variables }) => {
const result = declsWithVars(`${property}: ${value};`, variables);
const invalids = result.filter((decl) => decl.value.type === "invalid");
expect(result.length === 0 || invalids.length > 0).toBe(true);
}
);
// Shorthands not handled by expandShorthands (passthrough): verify they
// produce no invalid values when a cross-rule var is used as the value.
const passthroughFixtures = shorthandTestFixtures.filter(
(f) => f.longhands.length === 0
const longhandGeneratedCases = generatedCases.filter(
(testCase) => testCase.kind === "longhand"
);
test.each(passthroughFixtures)(
"cross-rule passthrough shorthand: $property — no invalid values",
({ property }) => {
const result = declsWithVars(`${property}: var(--v);`, {
"--v": "none",
});
test.each(longhandGeneratedCases)(
"grammar var case: $property / $caseId — authored var references stay parseable",
({ property, value }) => {
const result = decls(`${property}: ${value};`);
expect(result.length).toBeGreaterThan(0);
const invalids = result.filter((d) => d.value.type === "invalid");
const invalids = result.filter((decl) => decl.value.type === "invalid");
expect(invalids).toHaveLength(0);
const preserved = result.some((decl) =>
valueContainsVarReference(decl.value)
);
expect(preserved).toBe(true);
}
);
});
+291 -5
View File
@@ -109,7 +109,9 @@ const resolveVars = (
customProperties: Map<string, string>,
cssVars: Map<string, string> | undefined,
depth = 0
): { resolved: string; dropped: string[] } | undefined => {
):
| { resolved: string; dropped: string[]; usedCssVarsSubstitution: boolean }
| undefined => {
if (!value.includes("var(") || depth > 8) {
return;
}
@@ -123,6 +125,7 @@ const resolveVars = (
const subs: Array<{ start: number; end: number; text: string }> = [];
const dropped: string[] = [];
let usedCssVarsSubstitution = false;
csstree.walk(ast, {
visit: "Function",
@@ -141,6 +144,9 @@ const resolveVars = (
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,
@@ -187,12 +193,272 @@ const resolveVars = (
return {
resolved: deeper.resolved,
dropped: [...new Set([...dropped, ...deeper.dropped])],
usedCssVarsSubstitution:
usedCssVarsSubstitution || deeper.usedCssVarsSubstitution,
};
}
}
return { resolved, dropped };
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;
}
}
// 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…).
@@ -288,6 +554,7 @@ const substituteVarsInShorthand = (
}
const { resolved, dropped } = resolution;
const { usedCssVarsSubstitution } = resolution;
if (resolved === value) {
// No substitution happened.
@@ -295,12 +562,31 @@ const substituteVarsInShorthand = (
// 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: parseCssValue(property as CssProperty, resolved),
result: parsed,
droppedVars: dropped,
};
};
@@ -701,7 +987,7 @@ export const parseClassBasedSelector = (
const segClassNames: string[] = [];
for (const node of segment.nodes) {
if (node.type === "ClassSelector") {
segClassNames.push(node.name);
segClassNames.push(unescapeCssIdentifier(node.name));
} else {
// Non-class node in an ancestor segment — reject
return undefined;
@@ -724,7 +1010,7 @@ export const parseClassBasedSelector = (
for (const node of lastSegment.nodes) {
switch (node.type) {
case "ClassSelector":
classNames.push(node.name);
classNames.push(unescapeCssIdentifier(node.name));
break;
case "AttributeSelector":
case "PseudoClassSelector":
@@ -6,13 +6,14 @@ import {
type VarValue,
} from "@webstudio-is/css-engine";
import {
getColor,
isAngleUnit,
isColorStop,
mapLengthPercentageOrVar,
formatGradientStops,
mapLengthPercentageOrVar,
normalizeRepeatingGradient,
parseGradientHintFromParts,
parseGradientStopFromParts,
forEachGradientParts,
withoutWhitespaceNodes,
} from "./gradient-utils";
import type { GradientStop, ParsedConicGradient } from "./types";
@@ -28,7 +29,7 @@ const isIdentifier = (node: csstree.CssNode, value: string) =>
const parseGradientPart = (
nodes: csstree.CssNode[]
): GradientPartResult | undefined => {
const filtered = nodes.filter((node) => node.type !== "WhiteSpace");
const filtered = withoutWhitespaceNodes(nodes);
if (filtered.length === 0) {
return;
}
@@ -58,23 +59,12 @@ const parseGradientPart = (
return;
}
const colorNode = filtered.find(isColorStop);
if (colorNode !== undefined) {
const color = getColor(colorNode);
const colorIndex = filtered.indexOf(colorNode);
const positionNode = filtered[colorIndex + 1];
const hintNode = filtered[colorIndex + 2];
const stop: GradientStop = {
color,
position: mapLengthPercentageOrVar(positionNode),
hint: mapLengthPercentageOrVar(hintNode),
};
const stop = parseGradientStopFromParts(filtered);
if (stop !== undefined) {
return { type: "stop", value: stop };
}
const hint = mapLengthPercentageOrVar(filtered[0]);
const hint = parseGradientHintFromParts(filtered);
if (hint !== undefined) {
return { type: "hint", value: hint };
}
@@ -107,10 +97,9 @@ export const parseConicGradient = (
const stops: GradientStop[] = [];
forEachGradientParts(ast, "conic-gradient", (gradientParts) => {
const filteredParts = gradientParts.filter(
(node) => node.type !== "WhiteSpace"
);
const containsStop = filteredParts.some(isColorStop);
const filteredParts = withoutWhitespaceNodes(gradientParts);
const containsStop =
parseGradientStopFromParts(filteredParts) !== undefined;
let handledDirective = false;
const fromIndex = filteredParts.findIndex((node) =>
@@ -0,0 +1,192 @@
import * as csstree from "css-tree";
import { describe, expect, test } from "vitest";
import {
forEachGradientParts,
formatGradientStops,
getColor,
isAngleDimension,
isAngleLikeFallback,
isAngleUnit,
isColorStop,
isVarAngle,
mapLengthPercentageOrVar,
normalizeRepeatingGradient,
parseGradientHintFromParts,
parseGradientStopFromParts,
withoutWhitespaceNodes,
} from "./gradient-utils";
const parseValue = (value: string) => {
return csstree.parse(value, { context: "value" });
};
const firstValueNode = (value: string) => {
const ast = parseValue(value);
if (ast.type !== "Value") {
throw new Error("Expected Value AST");
}
const first = ast.children.first;
if (!first) {
throw new Error("Expected first child node");
}
return first;
};
describe("gradient utils", () => {
test("detects angle units and dimensions", () => {
expect(isAngleUnit("deg")).toBe(true);
expect(isAngleUnit("rad")).toBe(true);
expect(isAngleUnit("px")).toBe(false);
const angleNode = firstValueNode("45deg");
expect(isAngleDimension(angleNode)).toBe(true);
const lengthNode = firstValueNode("10px");
expect(isAngleDimension(lengthNode)).toBe(false);
});
test("detects angle-like var fallbacks", () => {
expect(isAngleLikeFallback({ type: "unit", unit: "turn", value: 1 })).toBe(
true
);
expect(isAngleLikeFallback({ type: "keyword", value: "to left" })).toBe(
true
);
expect(isAngleLikeFallback({ type: "unparsed", value: "10grad" })).toBe(
true
);
expect(isAngleLikeFallback(undefined)).toBe(false);
expect(isAngleLikeFallback({ type: "keyword", value: "auto" })).toBe(false);
});
test("detects angle-like vars by fallback or name", () => {
expect(isVarAngle({ type: "var", value: "angle" })).toBe(true);
expect(
isVarAngle({
type: "var",
value: "custom-direction",
fallback: { type: "keyword", value: "to right" },
})
).toBe(true);
expect(isVarAngle({ type: "var", value: "color" })).toBe(false);
});
test("maps units and vars", () => {
expect(mapLengthPercentageOrVar(firstValueNode("50%"))).toEqual({
type: "unit",
unit: "%",
value: 50,
});
expect(mapLengthPercentageOrVar(firstValueNode("12px"))).toEqual({
type: "unit",
unit: "px",
value: 12,
});
expect(mapLengthPercentageOrVar(firstValueNode("2"))).toEqual({
type: "unit",
unit: "number",
value: 2,
});
const varNode = firstValueNode("var(--stop)");
expect(mapLengthPercentageOrVar(varNode)).toEqual({
type: "var",
value: "stop",
});
});
test("reads color values and color-stop detection", () => {
const colorNode = firstValueNode("red");
expect(getColor(colorNode)).toEqual(
expect.objectContaining({ type: "color" })
);
expect(isColorStop(colorNode)).toBe(true);
const nonColorNode = firstValueNode("20px");
expect(getColor(nonColorNode)).toBeUndefined();
expect(isColorStop(nonColorNode)).toBe(false);
});
test("filters whitespace nodes", () => {
let nodes: csstree.CssNode[] = [];
const gradientAst = parseValue("linear-gradient(red 10% 20%, blue)");
forEachGradientParts(gradientAst, "linear-gradient", (parts) => {
if (nodes.length === 0) {
nodes = parts;
}
});
const filtered = withoutWhitespaceNodes(nodes);
expect(nodes.length).toBeGreaterThan(0);
expect(filtered.every((node) => node.type !== "WhiteSpace")).toBe(true);
expect(filtered.map((node) => node.type)).toEqual(
expect.arrayContaining(["Identifier", "Percentage", "Percentage"])
);
});
test("parses a gradient stop and a hint from parts", () => {
const stopAst = parseValue("red 10% 20%");
if (stopAst.type !== "Value") {
throw new Error("Expected Value AST");
}
const stop = parseGradientStopFromParts(
withoutWhitespaceNodes(stopAst.children.toArray())
);
expect(stop).toEqual({
color: expect.objectContaining({ type: "color" }),
position: { type: "unit", unit: "%", value: 10 },
hint: { type: "unit", unit: "%", value: 20 },
});
const hintAst = parseValue("30%");
if (hintAst.type !== "Value") {
throw new Error("Expected Value AST");
}
const hint = parseGradientHintFromParts(
withoutWhitespaceNodes(hintAst.children.toArray())
);
expect(hint).toEqual({ type: "unit", unit: "%", value: 30 });
});
test("formats stops and normalizes repeating gradient name", () => {
const formatted = formatGradientStops([
{
color: { type: "keyword", value: "red" },
position: { type: "unit", unit: "%", value: 0 },
},
{
color: { type: "keyword", value: "blue" },
position: { type: "unit", unit: "%", value: 100 },
},
]);
expect(formatted).toBe("red 0%, blue 100%");
expect(
normalizeRepeatingGradient(
"repeating-linear-gradient(red, blue)",
"repeating-linear-gradient",
"linear-gradient"
)
).toEqual({
normalized: "linear-gradient(red, blue)",
isRepeating: true,
});
});
test("iterates gradient parts split by commas", () => {
const ast = parseValue("linear-gradient(45deg, red 0%, blue 100%)");
const parts: string[] = [];
forEachGradientParts(ast, "linear-gradient", (nodes) => {
parts.push(
nodes
.map((node) => csstree.generate(node))
.join(" ")
.replace(/\s+/g, " ")
.trim()
);
});
expect(parts).toEqual(["45deg", "red 0%", "blue 100%"]);
});
});
@@ -122,6 +122,42 @@ export const getColor = (
export const isColorStop = (node: csstree.CssNode): boolean =>
getColor(node) !== undefined;
export const withoutWhitespaceNodes = (
nodes: csstree.CssNode[]
): csstree.CssNode[] => {
return nodes.filter((node) => node.type !== "WhiteSpace");
};
export const parseGradientStopFromParts = (
nodes: csstree.CssNode[]
): GradientStop | undefined => {
const colorNode = nodes.find(isColorStop);
if (colorNode === undefined) {
return;
}
const color = getColor(colorNode);
if (color === undefined) {
return;
}
const colorIndex = nodes.indexOf(colorNode);
const positionNode = nodes[colorIndex + 1];
const hintNode = nodes[colorIndex + 2];
return {
color,
position: mapLengthPercentageOrVar(positionNode),
hint: mapLengthPercentageOrVar(hintNode),
};
};
export const parseGradientHintFromParts = (
nodes: csstree.CssNode[]
): GradientStop["hint"] | undefined => {
return mapLengthPercentageOrVar(nodes[0]);
};
export const formatGradientStops = (stops: GradientStop[]): string =>
stops
.map((stop) => {
@@ -7,12 +7,12 @@ import {
type VarValue,
} from "@webstudio-is/css-engine";
import {
getColor,
isAngleDimension,
isAngleUnit,
isColorStop,
isVarAngle,
mapLengthPercentageOrVar,
parseGradientStopFromParts,
formatGradientStops,
normalizeRepeatingGradient,
forEachGradientParts,
@@ -102,18 +102,8 @@ export const parseLinearGradient = (
}
if (handledAsAngle === false) {
const colorStop = gradientParts.find(isColorStop);
if (colorStop !== undefined) {
const colorIndex = gradientParts.indexOf(colorStop);
const position = gradientParts[colorIndex + 1];
const hint = gradientParts[colorIndex + 2];
const stop: GradientStop = {
color: getColor(colorStop),
position: mapLengthPercentageOrVar(position),
hint: mapLengthPercentageOrVar(hint),
};
const stop = parseGradientStopFromParts(gradientParts);
if (stop !== undefined) {
stops.push(stop);
}
}
@@ -2,12 +2,12 @@ import * as csstree from "css-tree";
import { cssTryParseValue } from "../parse-css-value";
import { toValue, type KeywordValue } from "@webstudio-is/css-engine";
import {
getColor,
isColorStop,
mapLengthPercentageOrVar,
formatGradientStops,
normalizeRepeatingGradient,
parseGradientHintFromParts,
parseGradientStopFromParts,
forEachGradientParts,
withoutWhitespaceNodes,
} from "./gradient-utils";
import type { GradientStop, ParsedRadialGradient } from "./types";
@@ -64,22 +64,13 @@ export const parseRadialGradient = (
const stops: GradientStop[] = [];
forEachGradientParts(ast, "radial-gradient", (gradientParts) => {
const filtered = gradientParts.filter((node) => node.type !== "WhiteSpace");
const filtered = withoutWhitespaceNodes(gradientParts);
if (filtered.length === 0) {
return;
}
const colorStopNode = filtered.find(isColorStop);
if (colorStopNode !== undefined) {
const color = getColor(colorStopNode);
const colorIndex = filtered.indexOf(colorStopNode);
const positionNode = filtered[colorIndex + 1];
const hintNode = filtered[colorIndex + 2];
const stop: GradientStop = {
color,
position: mapLengthPercentageOrVar(positionNode),
hint: mapLengthPercentageOrVar(hintNode),
};
const stop = parseGradientStopFromParts(filtered);
if (stop !== undefined) {
stops.push(stop);
return;
}
@@ -120,7 +111,7 @@ export const parseRadialGradient = (
return;
}
const hint = mapLengthPercentageOrVar(directiveTokens[0]);
const hint = parseGradientHintFromParts(directiveTokens);
if (hint !== undefined) {
stops.push({ hint });
}
+130 -48
View File
@@ -157,6 +157,39 @@ const parseUnordered = (syntaxes: string[], value: CssNode) => {
];
};
const assignUnorderedNodes = (syntaxes: string[], value: CssNode) => {
const assigned = syntaxes.map(() => new List<CssNode>());
const unresolved = new List<CssNode>();
for (const node of getValueList(value)) {
const matchingIndexes: number[] = [];
syntaxes.forEach((syntax, index) => {
if (lexer.match(syntax, createValueNode([node])).matched) {
matchingIndexes.push(index);
}
});
if (matchingIndexes.length === 1) {
assigned[matchingIndexes[0]].appendData(node);
continue;
}
unresolved.appendData(node);
}
return {
assigned: assigned.map((list) => {
const nodes = list.toArray();
return nodes.length === 0 ? undefined : createValueNode(nodes);
}),
unresolved:
unresolved.isEmpty === false
? createValueNode(unresolved.toArray())
: undefined,
};
};
/**
*
* border = <line-width> || <line-style> || <color>
@@ -346,16 +379,28 @@ const expandFont = function* (value: CssNode) {
*
*/
const expandFontSynthesis = function* (value: CssNode) {
const [weight, style, smallCaps, position] = parseUnordered(
const {
assigned: [weight, style, smallCaps, position],
unresolved,
} = assignUnorderedNodes(
["weight", "style", "small-caps", "position"],
value
);
const auto = createIdentifier("auto");
const none = createIdentifier("none");
yield ["font-synthesis-weight", weight ? auto : none] as const;
yield ["font-synthesis-style", style ? auto : none] as const;
yield ["font-synthesis-small-caps", smallCaps ? auto : none] as const;
yield ["font-synthesis-position", position ? auto : none] as const;
yield [
"font-synthesis-weight",
weight ? auto : (unresolved ?? none),
] as const;
yield ["font-synthesis-style", style ? auto : (unresolved ?? none)] as const;
yield [
"font-synthesis-small-caps",
smallCaps ? auto : (unresolved ?? none),
] as const;
yield [
"font-synthesis-position",
position ? auto : (unresolved ?? none),
] as const;
};
/**
@@ -374,27 +419,40 @@ const expandFontSynthesis = function* (value: CssNode) {
*
*/
const expandFontVariant = function* (value: CssNode) {
const [ligatures, caps, alternates, numeric, eastAsian, position, emoji] =
parseUnordered(
[
"[ normal | none | <common-lig-values> || <discretionary-lig-values> || <historical-lig-values> || <contextual-alt-values> ]",
"[ small-caps | all-small-caps | petite-caps | all-petite-caps | unicase | titling-caps ]",
"[ stylistic( <feature-value-name> ) || historical-forms || styleset( <feature-value-name># ) || character-variant( <feature-value-name># ) || swash( <feature-value-name> ) || ornaments( <feature-value-name> ) || annotation( <feature-value-name> ) ]",
"[ <numeric-figure-values> || <numeric-spacing-values> || <numeric-fraction-values> || ordinal || slashed-zero ]",
"[ <east-asian-variant-values> || <east-asian-width-values> || ruby ]",
"[ sub | super ]",
"[ text | emoji | unicode ]",
],
value
);
const {
assigned: [
ligatures,
caps,
alternates,
numeric,
eastAsian,
position,
emoji,
],
unresolved,
} = assignUnorderedNodes(
[
"[ normal | none | <common-lig-values> || <discretionary-lig-values> || <historical-lig-values> || <contextual-alt-values> ]",
"[ small-caps | all-small-caps | petite-caps | all-petite-caps | unicase | titling-caps ]",
"[ stylistic( <feature-value-name> ) || historical-forms || styleset( <feature-value-name># ) || character-variant( <feature-value-name># ) || swash( <feature-value-name> ) || ornaments( <feature-value-name> ) || annotation( <feature-value-name> ) ]",
"[ <numeric-figure-values> || <numeric-spacing-values> || <numeric-fraction-values> || ordinal || slashed-zero ]",
"[ <east-asian-variant-values> || <east-asian-width-values> || ruby ]",
"[ sub | super ]",
"[ text | emoji | unicode ]",
],
value
);
const normal = createIdentifier("normal");
yield ["font-variant-ligatures", ligatures ?? normal] as const;
yield ["font-variant-caps", caps ?? normal] as const;
yield ["font-variant-alternates", alternates ?? normal] as const;
yield ["font-variant-numeric", numeric ?? normal] as const;
yield ["font-variant-east-asian", eastAsian ?? normal] as const;
yield ["font-variant-position", position ?? normal] as const;
yield ["font-variant-emoji", emoji ?? normal] as const;
yield ["font-variant-ligatures", ligatures ?? unresolved ?? normal] as const;
yield ["font-variant-caps", caps ?? unresolved ?? normal] as const;
yield [
"font-variant-alternates",
alternates ?? unresolved ?? normal,
] as const;
yield ["font-variant-numeric", numeric ?? unresolved ?? normal] as const;
yield ["font-variant-east-asian", eastAsian ?? unresolved ?? normal] as const;
yield ["font-variant-position", position ?? unresolved ?? normal] as const;
yield ["font-variant-emoji", emoji ?? unresolved ?? normal] as const;
};
const expandFlex = function* (value: CssNode) {
@@ -860,26 +918,41 @@ const expandWhiteSpace = function* (value: CssNode) {
const preserveKeyword = createIdentifier("preserve");
const wrapKeyword = createIdentifier("wrap");
const nowrapKeyword = createIdentifier("nowrap");
let collapse = collapseKeyword;
let wrapMode = wrapKeyword;
[collapse = collapseKeyword, wrapMode = wrapKeyword] = parseUnordered(
if (lexer.match("normal", value).matched) {
yield ["white-space-collapse", collapseKeyword] as const;
yield ["text-wrap-mode", wrapKeyword] as const;
return;
}
if (lexer.match("pre", value).matched) {
yield ["white-space-collapse", preserveKeyword] as const;
yield ["text-wrap-mode", nowrapKeyword] as const;
return;
}
if (lexer.match("pre-wrap", value).matched) {
yield ["white-space-collapse", preserveKeyword] as const;
yield ["text-wrap-mode", wrapKeyword] as const;
return;
}
if (lexer.match("pre-line", value).matched) {
yield [
"white-space-collapse",
createIdentifier("preserve-breaks"),
] as const;
yield ["text-wrap-mode", wrapKeyword] as const;
return;
}
const {
assigned: [collapse, wrapMode],
unresolved,
} = assignUnorderedNodes(
["<'white-space-collapse'>", "<'text-wrap-mode'>"],
value
);
if (lexer.match("normal", value).matched) {
[collapse, wrapMode] = [collapseKeyword, wrapKeyword];
}
if (lexer.match("pre", value).matched) {
[collapse, wrapMode] = [preserveKeyword, nowrapKeyword];
}
if (lexer.match("pre-wrap", value).matched) {
[collapse, wrapMode] = [preserveKeyword, wrapKeyword];
}
if (lexer.match("pre-line", value).matched) {
[collapse, wrapMode] = [createIdentifier("preserve-breaks"), wrapKeyword];
}
yield ["white-space-collapse", collapse] as const;
yield ["text-wrap-mode", wrapMode] as const;
yield [
"white-space-collapse",
collapse ?? unresolved ?? collapseKeyword,
] as const;
yield ["text-wrap-mode", wrapMode ?? unresolved ?? wrapKeyword] as const;
};
/**
@@ -1360,12 +1433,21 @@ const expandShorthand = function* (property: string, value: CssNode) {
break;
case "text-wrap": {
const [
mode = createIdentifier("wrap"),
style = createIdentifier("auto"),
] = parseUnordered(["<'text-wrap-mode'>", "<'text-wrap-style'>"], value);
yield ["text-wrap-mode", mode] as const;
yield ["text-wrap-style", style] as const;
const {
assigned: [mode, style],
unresolved,
} = assignUnorderedNodes(
["<'text-wrap-mode'>", "<'text-wrap-style'>"],
value
);
yield [
"text-wrap-mode",
mode ?? unresolved ?? createIdentifier("wrap"),
] as const;
yield [
"text-wrap-style",
style ?? unresolved ?? createIdentifier("auto"),
] as const;
break;
}
+48 -52
View File
@@ -212,14 +212,8 @@ importers:
specifier: ^2.5.3
version: 2.5.3
'@unocss/core':
specifier: 66.1.2
version: 66.1.2
'@unocss/preset-legacy-compat':
specifier: 66.1.2
version: 66.1.2
'@unocss/preset-wind3':
specifier: 66.1.2
version: 66.1.2
specifier: 66.6.8
version: 66.6.8
'@vercel/remix':
specifier: 2.15.3
version: 2.15.3(@remix-run/dev@2.16.5(patch_hash=yortwzoeu3uj2blmdikhhw5byy)(@remix-run/react@2.16.5(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)(typescript@5.8.2))(@remix-run/serve@2.16.5(typescript@5.8.2))(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3)(typescript@5.8.2)(vite@6.3.4(@types/node@22.13.10)(jiti@2.4.2)(tsx@4.19.3))(wrangler@3.63.2(@cloudflare/workers-types@4.20240701.0)))(@remix-run/node@2.16.5(typescript@5.8.2))(@remix-run/server-runtime@2.16.5(typescript@5.8.2))(react-dom@18.3.0-canary-14898b6a9-20240318(react@18.3.0-canary-14898b6a9-20240318))(react@18.3.0-canary-14898b6a9-20240318)
@@ -443,6 +437,9 @@ importers:
'@types/react-dom':
specifier: ^18.2.25
version: 18.2.25
'@unocss/preset-wind4':
specifier: ^66.6.8
version: 66.6.8
'@vitest/coverage-v8':
specifier: 3.1.2
version: 3.1.2(@vitest/browser@3.1.2)(vitest@3.1.2)
@@ -3793,6 +3790,9 @@ packages:
'@jridgewell/sourcemap-codec@1.5.0':
resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==}
'@jridgewell/sourcemap-codec@1.5.5':
resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==}
'@jridgewell/trace-mapping@0.3.25':
resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==}
@@ -5634,23 +5634,17 @@ packages:
resolution: {integrity: sha512-nnEf37C9ue7OBRnF2zmV/OCBmV5Y7T/K4mCHa+nxgiXcF/1w8sA0cgdFl+gHQ0mysqUJ+Bu5btAMeWgpLyjrgg==}
hasBin: true
'@unocss/core@66.1.2':
resolution: {integrity: sha512-mN9h1hHEuhDcdbI4z74o7UnxlBZYVsJpYcdC1YLWBKROcLYTkuyZ7hgBzpo1FBNox2Bt3JnrSinVDmc44Bxjow==}
'@unocss/core@66.6.8':
resolution: {integrity: sha512-P9IlQfgms+8/nka7fBhiiWU4SPwrTNKbTdK0z1SLnttXMHHjsB2zpG+Vi1JQDpICfY9Y1/2pWtguPE+zeOVu9Q==}
'@unocss/extractor-arbitrary-variants@66.1.2':
resolution: {integrity: sha512-F570wH9VYeFTb4r8qgcbN5QpEVIAvFC1zOnrAPUr6B6kbU2YChMXxHP7PHK0AzLHnEr458Pwpzl6hmP6bzxZ8g==}
'@unocss/extractor-arbitrary-variants@66.6.8':
resolution: {integrity: sha512-cOXstpPTOLt/HYcL0OsqFkNau0e8ktZ5Q8fgnXBZjmLGmi+VzdESNlwxZyCXLuamZGnbrZ8lDsKdsGG7P1pMKQ==}
'@unocss/preset-legacy-compat@66.1.2':
resolution: {integrity: sha512-s1SnCXm9ywmp0plWQsXr8APxRhQEeblF7E6UmanFMj012/DU0bxWVUafnXBbeYUjtfBKCFlDvbW2pm30UGNQOw==}
'@unocss/preset-wind4@66.6.8':
resolution: {integrity: sha512-CheOm7KXOsTI5t4RXgeYz95CO5p589F6jsyYp+inOCk4N0/d+DWiDHrQ+V0x0HWs3JXWlD+/Va/yXjlc3o2sIw==}
'@unocss/preset-mini@66.1.2':
resolution: {integrity: sha512-oiDe+VhwZ8B5Z0UGfggtOwgpRZMLtH1RTDFvmJmJEXYYX5BPWknS6wYcQzxy0i/y9ym0xp2QnEaTpGmR7LKdkg==}
'@unocss/preset-wind3@66.1.2':
resolution: {integrity: sha512-S09imGOngAAOXCBCHb3JAtxD1/L7nDWrgEeX6NT0ElDp3X1T6XxUXYJlpjCfcqV/klMoXyYouKvp0YuG9QSgVg==}
'@unocss/rule-utils@66.1.2':
resolution: {integrity: sha512-nn0ehvDh7yyWq2mcBDLVpmMAivjRATUroZ8ETinyN1rmfsGesm71R0d1gV3K+Z6YC7a3+dMLc+/qzI7VK3AG/Q==}
'@unocss/rule-utils@66.6.8':
resolution: {integrity: sha512-WR35L07mLP6PElD4hlUHo5KbQ48uz2HT/XCuJyAsHP+15Gv6539hPWA5SresPuva9r8rl+PeGIgMSIKf4A5Ihw==}
engines: {node: '>=14'}
'@vanilla-extract/babel-plugin-debug-ids@1.0.6':
@@ -6936,6 +6930,10 @@ packages:
resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==}
engines: {node: '>= 0.4'}
hasown@2.0.3:
resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
engines: {node: '>= 0.4'}
hast-util-to-estree@2.2.1:
resolution: {integrity: sha512-kiGD9WIW3gRKK8Gao3n1f+ahUeTMeJUJILnIT2QNrPigDNdH7rJxzhEbh81UajGeAdAHFecT1a+fLVOCTq9B4Q==}
@@ -7356,6 +7354,9 @@ packages:
magic-string@0.30.17:
resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==}
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
magicast@0.3.5:
resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==}
@@ -8640,10 +8641,6 @@ packages:
resolution: {integrity: sha512-h+z7HKHYXj6wJU+AnS/+IH8Uh9fdcX1Lrhg1/VMdf9PwoBQXFcXiAdsy2tSK0P6gKwJLXp02r90ahUCqHk9rrw==}
engines: {node: '>=8.0.0'}
source-map-js@1.2.0:
resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==}
engines: {node: '>=0.10.0'}
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -10783,6 +10780,8 @@ snapshots:
'@jridgewell/sourcemap-codec@1.5.0': {}
'@jridgewell/sourcemap-codec@1.5.5': {}
'@jridgewell/trace-mapping@0.3.25':
dependencies:
'@jridgewell/resolve-uri': 3.1.0
@@ -12865,32 +12864,22 @@ snapshots:
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260120.1
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260120.1
'@unocss/core@66.1.2': {}
'@unocss/core@66.6.8': {}
'@unocss/extractor-arbitrary-variants@66.1.2':
'@unocss/extractor-arbitrary-variants@66.6.8':
dependencies:
'@unocss/core': 66.1.2
'@unocss/core': 66.6.8
'@unocss/preset-legacy-compat@66.1.2':
'@unocss/preset-wind4@66.6.8':
dependencies:
'@unocss/core': 66.1.2
'@unocss/core': 66.6.8
'@unocss/extractor-arbitrary-variants': 66.6.8
'@unocss/rule-utils': 66.6.8
'@unocss/preset-mini@66.1.2':
'@unocss/rule-utils@66.6.8':
dependencies:
'@unocss/core': 66.1.2
'@unocss/extractor-arbitrary-variants': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/preset-wind3@66.1.2':
dependencies:
'@unocss/core': 66.1.2
'@unocss/preset-mini': 66.1.2
'@unocss/rule-utils': 66.1.2
'@unocss/rule-utils@66.1.2':
dependencies:
'@unocss/core': 66.1.2
magic-string: 0.30.17
'@unocss/core': 66.6.8
magic-string: 0.30.21
'@vanilla-extract/babel-plugin-debug-ids@1.0.6':
dependencies:
@@ -13582,7 +13571,7 @@ snapshots:
css-tree@2.3.1:
dependencies:
mdn-data: 2.0.30
source-map-js: 1.2.0
source-map-js: 1.2.1
css-tree@3.1.0:
dependencies:
@@ -13808,7 +13797,7 @@ snapshots:
es-errors: 1.3.0
get-intrinsic: 1.3.0
has-tostringtag: 1.0.2
hasown: 2.0.2
hasown: 2.0.3
optional: true
esbuild-plugins-node-modules-polyfill@1.6.3(esbuild@0.17.6):
@@ -14218,7 +14207,7 @@ snapshots:
asynckit: 0.4.0
combined-stream: 1.0.8
es-set-tostringtag: 2.1.0
hasown: 2.0.2
hasown: 2.0.3
mime-types: 2.1.35
optional: true
@@ -14294,7 +14283,7 @@ snapshots:
get-proto: 1.0.1
gopd: 1.2.0
has-symbols: 1.1.0
hasown: 2.0.2
hasown: 2.0.3
math-intrinsics: 1.1.0
optional: true
@@ -14432,6 +14421,11 @@ snapshots:
dependencies:
function-bind: 1.1.2
hasown@2.0.3:
dependencies:
function-bind: 1.1.2
optional: true
hast-util-to-estree@2.2.1:
dependencies:
'@types/estree': 1.0.7
@@ -14882,6 +14876,10 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.0
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
magicast@0.3.5:
dependencies:
'@babel/parser': 7.27.0
@@ -16513,8 +16511,6 @@ snapshots:
slugify@1.6.6: {}
source-map-js@1.2.0: {}
source-map-js@1.2.1: {}
source-map-support@0.5.21: