mirror of
https://github.com/webstudio-is/webstudio.git
synced 2026-10-23 03:27:27 +09:00
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:
1 parent
5ff6bb7790
commit
f052200d07
22 files changed
+21634
-1814
No files matched your search
@@ -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" })
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`
|
||||
);
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Generated
+48
-52
@@ -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:
|
||||
|
||||
Reference in new issue
Block a user