mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
refactor(resume): drop semantic css features without a clear pdf meaning
Remove the revert and unset keywords, vw/vh units, the |= and $= attribute matchers, :nth-child(... of ...), the stylesheet analyzer and the engine-unsupported warnings. inherit and initial remain the CSS-wide keywords.
This commit is contained in:
@@ -114,7 +114,7 @@ describe("Semantic CSS editor extensions", () => {
|
||||
const labels = completionLabels(source, source.length, metadata);
|
||||
|
||||
expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"]));
|
||||
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"]));
|
||||
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "em", "rem"]));
|
||||
});
|
||||
|
||||
it("escapes dynamic IDs and attribute values before inserting selectors", () => {
|
||||
|
||||
@@ -193,8 +193,8 @@ assign a value to a `--resume-*` variable; create an author variable such as `--
|
||||
| Page and layout | `--resume-page-gap-x`, `--resume-page-gap-y`, `--resume-page-margin-x`, `--resume-page-margin-y`, `--resume-page-width`, `--resume-page-height`, `--resume-sidebar-width` |
|
||||
| Picture | `--resume-picture-size`, `--resume-picture-rotation`, `--resume-picture-aspect-ratio`, `--resume-picture-border-radius`, `--resume-picture-border-width`, `--resume-picture-border-color`, `--resume-picture-shadow-width`, `--resume-picture-shadow-color` |
|
||||
|
||||
Use `pt` for predictable PDF spacing and type sizes. Semantic CSS also accepts `px`, `in`, `mm`, `cm`, `%`, `vw`, `vh`, `em`,
|
||||
and `rem` where the property supports a length.
|
||||
Use `pt` for predictable PDF spacing and type sizes. Semantic CSS also accepts `px`, `in`, `mm`, `cm`, `%`, `em`, and `rem`
|
||||
where the property supports a length.
|
||||
|
||||
## Style common resume content
|
||||
|
||||
|
||||
@@ -15,15 +15,7 @@ import { adaptResolvedPdfNode, resolvedPdfFlowProps, resolvedPdfTextProps } from
|
||||
const pictureFixture =
|
||||
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=";
|
||||
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
|
||||
const borderShorthandHints = [
|
||||
"inherit",
|
||||
"initial",
|
||||
"revert",
|
||||
"unset",
|
||||
"1pt dotted",
|
||||
"1pt dashed",
|
||||
"1pt solid",
|
||||
] as const;
|
||||
const borderShorthandHints = ["inherit", "initial", "1pt dotted", "1pt dashed", "1pt solid"] as const;
|
||||
const blankStyle: ResolvedNodeStyle = { style: {}, structural: {}, hidden: false, order: 0 };
|
||||
const baseSettings: BaseSettingsSnapshot = {
|
||||
picture: defaultResumeData.picture,
|
||||
@@ -147,7 +139,7 @@ describe("adaptResolvedPdfNode", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit values equal to the resolver base and distinguishes initial from host-base resets", () => {
|
||||
it("preserves explicit values equal to the resolver base and emits initial resets", () => {
|
||||
const base = {
|
||||
style: { color: "#111111", "font-weight": "700" },
|
||||
structural: {},
|
||||
@@ -158,11 +150,11 @@ describe("adaptResolvedPdfNode", () => {
|
||||
...base,
|
||||
style: { color: "#111111" },
|
||||
specifiedStyleProperties: ["color", "font-weight"],
|
||||
hostBaseStyleProperties: ["color"],
|
||||
} satisfies ResolvedNodeStyle;
|
||||
|
||||
expect(adaptResolvedPdfNode(resolved, base)).toEqual({
|
||||
style: {
|
||||
color: "#111111",
|
||||
fontWeight: undefined,
|
||||
},
|
||||
});
|
||||
@@ -179,7 +171,6 @@ describe("adaptResolvedPdfNode", () => {
|
||||
...base,
|
||||
style: { color: "#111111", "font-size": 10 },
|
||||
specifiedStyleProperties: [],
|
||||
hostBaseStyleProperties: [],
|
||||
} satisfies ResolvedNodeStyle;
|
||||
|
||||
expect(adaptResolvedPdfNode(resolved, base)).toEqual({});
|
||||
|
||||
@@ -47,13 +47,12 @@ const toReactPdfProperty = (property: string) => {
|
||||
|
||||
const styleDelta = (resolved: ResolvedNodeStyle, base: ResolvedNodeStyle["style"] | undefined): Style | undefined => {
|
||||
const specified = new Set(resolved.specifiedStyleProperties);
|
||||
const hostBase = new Set(resolved.hostBaseStyleProperties);
|
||||
const entries: [string, string | number | undefined][] =
|
||||
base === undefined
|
||||
? Object.entries(resolved.style)
|
||||
: Object.entries(resolved.style).filter(([property]) => !hostBase.has(property) && specified.has(property));
|
||||
: Object.entries(resolved.style).filter(([property]) => specified.has(property));
|
||||
for (const property of specified) {
|
||||
if (!hostBase.has(property) && !(property in resolved.style)) entries.push([property, undefined]);
|
||||
if (!(property in resolved.style)) entries.push([property, undefined]);
|
||||
}
|
||||
if (entries.length === 0) return undefined;
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ const finalTextStyle = async (template: Template, text: string, rule = "") => {
|
||||
return mergedStyle(findText(instance.container.document as HostNode, text));
|
||||
};
|
||||
|
||||
const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial" | "revert" | "unset") => {
|
||||
const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial") => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.picture.hidden = true;
|
||||
data.metadata.typography.body.fontWeights = ["400", "500"];
|
||||
@@ -103,14 +103,11 @@ describe("PDF semantic base and reset fidelity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["inherit", "unset", "revert"])(
|
||||
"resets Bronzor's heading weight with %s against the actual host base",
|
||||
async (keyword) => {
|
||||
expect(
|
||||
await finalTextStyle("bronzor", "Expertise", `section-heading { font-weight: ${keyword}; }`),
|
||||
).toMatchObject({ fontWeight: "400" });
|
||||
},
|
||||
);
|
||||
it("resets Bronzor's heading weight with inherit against the actual host base", async () => {
|
||||
expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: inherit; }")).toMatchObject({
|
||||
fontWeight: "400",
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels Bronzor's heading weight with the CSS initial value", async () => {
|
||||
expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: initial; }")).toMatchObject({
|
||||
@@ -125,38 +122,25 @@ describe("PDF semantic base and reset fidelity", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["inherit", "unset"])(
|
||||
"cancels Chikorita's sidebar field color with %s and emits the inherited parent value",
|
||||
async (keyword) => {
|
||||
expect(
|
||||
await finalTextStyle("chikorita", "TypeScript", `field[name='name'] { color: ${keyword}; }`),
|
||||
).toMatchObject({ color: "#111111" });
|
||||
},
|
||||
);
|
||||
|
||||
it("restores Chikorita's sidebar field color with revert", async () => {
|
||||
expect(await finalTextStyle("chikorita", "TypeScript", "field[name='name'] { color: revert; }")).toMatchObject({
|
||||
color: "#eeeeee",
|
||||
it("cancels Chikorita's sidebar field color with inherit and emits the inherited parent value", async () => {
|
||||
expect(await finalTextStyle("chikorita", "TypeScript", "field[name='name'] { color: inherit; }")).toMatchObject({
|
||||
color: "#111111",
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["inherit", "unset"] as const)(
|
||||
"cancels Onyx's local company weight with %s and emits the inherited parent value",
|
||||
async (keyword) => {
|
||||
expect(await finalOnyxCompanyStyle(keyword)).toMatchObject({ fontWeight: "400" });
|
||||
},
|
||||
);
|
||||
it("cancels Onyx's local company weight with inherit and emits the inherited parent value", async () => {
|
||||
expect(await finalOnyxCompanyStyle("inherit")).toMatchObject({ fontWeight: "400" });
|
||||
});
|
||||
|
||||
it("cancels Onyx's local company weight with initial", async () => {
|
||||
expect(await finalOnyxCompanyStyle("initial")).toMatchObject({ fontWeight: undefined });
|
||||
});
|
||||
|
||||
it("restores Onyx's local company weight with revert", async () => {
|
||||
it("keeps Onyx's local company weight without a reset", async () => {
|
||||
// The local value is the template's bold weight for the body family:
|
||||
// IBM Plex Serif stored as ["400", "500"] resolves to its true Bold
|
||||
// face (#3310) — still distinct from the inherited 400 and the initial
|
||||
// undefined, so the reset-keyword contract below stays verifiable.
|
||||
// face (#3310) — distinct from the inherited 400 and the initial undefined,
|
||||
// so the reset-keyword contract above stays verifiable.
|
||||
expect(await finalOnyxCompanyStyle()).toMatchObject({ fontWeight: "700" });
|
||||
expect(await finalOnyxCompanyStyle("revert")).toMatchObject({ fontWeight: "700" });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createElement } from "react";
|
||||
import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { ResumeDocument } from "../document";
|
||||
import { pdf } from "../forme/testing";
|
||||
@@ -129,14 +128,12 @@ describe("item-header-row template part", () => {
|
||||
});
|
||||
|
||||
it("reports no diagnostics for the selector", () => {
|
||||
const data = buildFixture();
|
||||
const page = data.metadata.layout.pages[0];
|
||||
if (!page) throw new Error("Missing authored page");
|
||||
const tree = buildSemanticTree({ data, template: "onyx", page, pageNumber: 1, showHeader: true });
|
||||
const compiled = compileStylesheet({ languageVersion: 1, text: NOWRAP_STYLESHEET });
|
||||
if (!compiled.program) throw new Error("Stylesheet failed to compile");
|
||||
const runtime = resolveResumeRuntime({
|
||||
data: buildFixture(),
|
||||
template: "onyx",
|
||||
source: { languageVersion: 1, text: NOWRAP_STYLESHEET },
|
||||
});
|
||||
|
||||
expect(compiled.diagnostics).toEqual([]);
|
||||
expect(analyzeStylesheet(compiled.program, tree)).toEqual([]);
|
||||
expect(runtime.diagnostics).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -65,9 +65,7 @@ describe("semantic pagination cancellation", () => {
|
||||
it.each([
|
||||
["auto", false, true],
|
||||
["initial", false, true],
|
||||
["unset", false, true],
|
||||
["inherit", false, true],
|
||||
["revert", true, false],
|
||||
] as const)("maps %s over builder pagination to explicit break=%s and wrap=%s", (value, breakBefore, wrap) => {
|
||||
const data = buildFixture(value);
|
||||
const presentation = resolveResumePresentation({ data, template: "onyx" });
|
||||
@@ -79,8 +77,6 @@ describe("semantic pagination cancellation", () => {
|
||||
it.each([
|
||||
["auto", false, true],
|
||||
["initial", false, true],
|
||||
["unset", false, true],
|
||||
["revert", true, false],
|
||||
] as const)("puts the %s cancellation on the final existing section View", async (value, breakBefore, wrap) => {
|
||||
const data = buildFixture(value);
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
|
||||
|
||||
@@ -1,134 +0,0 @@
|
||||
import type { SemanticNode } from "./types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { analyzeStylesheet } from "./analyze";
|
||||
import { compileStylesheet } from "./compile";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
|
||||
const tree: SemanticNode = {
|
||||
key: "resume",
|
||||
kind: "resume",
|
||||
attributes: { template: "onyx" },
|
||||
roles: [],
|
||||
children: [
|
||||
{
|
||||
key: "name",
|
||||
kind: "name",
|
||||
attributes: {},
|
||||
roles: ["primary-text"],
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
key: "picture",
|
||||
kind: "picture",
|
||||
attributes: {},
|
||||
roles: ["picture"],
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function compile(source: string) {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: source });
|
||||
if (!result.program) throw new Error(result.diagnostics.map(({ code }) => code).join(","));
|
||||
return result.program;
|
||||
}
|
||||
|
||||
function semanticTreeOfSize(size: number, shape: "deep" | "wide"): SemanticNode {
|
||||
if (shape === "wide") {
|
||||
return {
|
||||
key: "root",
|
||||
kind: "resume",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: Array.from({ length: size - 1 }, (_, index) => ({
|
||||
key: `item-${index}`,
|
||||
kind: "item",
|
||||
attributes: {},
|
||||
roles: [],
|
||||
children: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
let root: SemanticNode = { key: "node-0", kind: "item", attributes: {}, roles: [], children: [] };
|
||||
for (let index = 1; index < size; index++) {
|
||||
root = { key: `node-${index}`, kind: "item", attributes: {}, roles: [], children: [root] };
|
||||
}
|
||||
return root;
|
||||
}
|
||||
|
||||
function oversizedFrontierTree(): { tree: SemanticNode; childReads: () => number } {
|
||||
let childReads = 0;
|
||||
const children = new Proxy({} as readonly SemanticNode[], {
|
||||
get: (_target, property) => {
|
||||
if (property === "length") return SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1;
|
||||
if (property === Symbol.iterator || (typeof property === "string" && /^\d+$/.test(property))) {
|
||||
childReads++;
|
||||
throw new Error("Oversized frontier entries must not be read.");
|
||||
}
|
||||
},
|
||||
});
|
||||
return {
|
||||
tree: { key: "oversized-root", kind: "resume", attributes: {}, roles: [], children },
|
||||
childReads: () => childReads,
|
||||
};
|
||||
}
|
||||
|
||||
describe("Semantic CSS semantic analysis", () => {
|
||||
it("warns about selectors that match no immutable semantic node", () => {
|
||||
const program = compile('@version 1; section[type="education"] { color: red; }');
|
||||
const diagnostics = analyzeStylesheet(program, tree);
|
||||
|
||||
expect(diagnostics).toContainEqual(expect.objectContaining({ code: "SELECTOR_NO_MATCH", severity: "warning" }));
|
||||
});
|
||||
|
||||
it("warns when a known property cannot apply to any matched node kind", () => {
|
||||
const program = compile("@version 1; picture { color: red; }");
|
||||
const diagnostics = analyzeStylesheet(program, tree);
|
||||
|
||||
expect(diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "PROPERTY_NOT_APPLICABLE", severity: "warning" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not warn for a selector and declaration with a real target", () => {
|
||||
const program = compile("@version 1; name { color: red; }");
|
||||
|
||||
expect(analyzeStylesheet(program, tree)).toEqual([]);
|
||||
});
|
||||
|
||||
it("accepts the exact analysis node budget and rejects deep or wide trees one node over", () => {
|
||||
const program = { languageVersion: 1, rules: [] };
|
||||
expect(analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes, "wide"))).toEqual([]);
|
||||
|
||||
for (const shape of ["deep", "wide"] as const) {
|
||||
expect(
|
||||
analyzeStylesheet(program, semanticTreeOfSize(SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes + 1, shape)),
|
||||
).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an oversized root frontier without reading or queueing child entries", () => {
|
||||
const frontier = oversizedFrontierTree();
|
||||
const diagnostics = analyzeStylesheet({ languageVersion: 1, rules: [] }, frontier.tree);
|
||||
|
||||
expect(diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
|
||||
expect(frontier.childReads()).toBe(0);
|
||||
});
|
||||
|
||||
it("warns about declarations the PDF engine can't draw, and only those", () => {
|
||||
const diagnostics = analyzeStylesheet(
|
||||
compile(
|
||||
"@version 1;\nname { text-indent: 4pt; border: 1pt dashed #000; color: red; } picture { border-style: solid; }",
|
||||
),
|
||||
tree,
|
||||
);
|
||||
const unsupported = diagnostics.filter(({ code }) => code === "ENGINE_UNSUPPORTED");
|
||||
|
||||
expect(unsupported.map(({ message }) => message)).toEqual([
|
||||
expect.stringContaining("text-indent"),
|
||||
expect.stringContaining("dashed"),
|
||||
]);
|
||||
expect(unsupported.every(({ severity }) => severity === "warning")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import type { SemanticCssDiagnostic, SemanticNode, StyleProgram } from "./types";
|
||||
import { createDiagnostic } from "./diagnostics";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
import { isEngineSupportedDeclaration, PROPERTY_REGISTRY_V1 } from "./registry/properties";
|
||||
import { createSelectorMatcher } from "./selector";
|
||||
|
||||
function flatten(root: SemanticNode): SemanticNode[] | null {
|
||||
const nodes: SemanticNode[] = [];
|
||||
const stack = [root];
|
||||
while (stack.length > 0) {
|
||||
const node = stack.pop();
|
||||
if (!node) break;
|
||||
if (nodes.length >= SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes) return null;
|
||||
nodes.push(node);
|
||||
const childCount = node.children.length;
|
||||
if (childCount > SEMANTIC_CSS_LIMITS_V1.maxSemanticNodes - nodes.length - stack.length) return null;
|
||||
for (let index = 0; index < childCount; index++) {
|
||||
const child = node.children[index];
|
||||
if (child) stack.push(child);
|
||||
}
|
||||
}
|
||||
return nodes;
|
||||
}
|
||||
|
||||
export function analyzeStylesheet(program: StyleProgram, tree: SemanticNode): readonly SemanticCssDiagnostic[] {
|
||||
const diagnostics: SemanticCssDiagnostic[] = [];
|
||||
const nodes = flatten(tree);
|
||||
if (!nodes) {
|
||||
return [createDiagnostic("RESOURCE_LIMIT", "error", "The semantic tree exceeds the Semantic CSS node limit.")];
|
||||
}
|
||||
const matchesSelector = createSelectorMatcher(tree);
|
||||
for (const rule of program.rules) {
|
||||
const matches = nodes.filter((node) => matchesSelector(rule.selector, node.key));
|
||||
if (matches.length === 0) {
|
||||
diagnostics.push(
|
||||
createDiagnostic("SELECTOR_NO_MATCH", "warning", "This selector matches no semantic resume node.", rule.range),
|
||||
);
|
||||
continue;
|
||||
}
|
||||
for (const declaration of rule.declarations) {
|
||||
if (declaration.property.startsWith("--")) continue;
|
||||
const definition = PROPERTY_REGISTRY_V1[declaration.property];
|
||||
if (definition && !matches.some((node) => definition.appliesTo.includes(node.kind))) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"PROPERTY_NOT_APPLICABLE",
|
||||
"warning",
|
||||
`${declaration.property} cannot apply to the matched semantic node kinds.`,
|
||||
declaration.range,
|
||||
),
|
||||
);
|
||||
}
|
||||
// A shorthand expands to longhands that share its source range; one warning covers them.
|
||||
const warned = diagnostics.some(
|
||||
({ code, range }) => code === "ENGINE_UNSUPPORTED" && range?.start.offset === declaration.range.start.offset,
|
||||
);
|
||||
if (definition && !warned && !isEngineSupportedDeclaration(declaration.property, declaration.value)) {
|
||||
diagnostics.push(
|
||||
createDiagnostic(
|
||||
"ENGINE_UNSUPPORTED",
|
||||
"warning",
|
||||
`The PDF engine can't draw ${declaration.property}: ${declaration.value} yet, so it has no effect.`,
|
||||
declaration.range,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
@@ -271,9 +271,6 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
`);
|
||||
expect(valid.nodes["heading-experience"]?.style.color).toBe(baseSettings.design.colors.primary);
|
||||
|
||||
const reverted = resolve("section { color: red; } section-heading { color: revert; }");
|
||||
expect(reverted.nodes["heading-experience"]?.style.color).toBe("black");
|
||||
|
||||
const compiled = compileStylesheet({
|
||||
languageVersion: 1,
|
||||
text: "@version 1; :root { --a: var(--b); --b: var(--a); } section { color: var(--a); }",
|
||||
@@ -392,10 +389,10 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "MEDIA_PAGE_SIZE", severity: "error" }));
|
||||
});
|
||||
|
||||
it("resolves a relative authored page size exactly once against authored dimensions", () => {
|
||||
it("resolves an authored page size through a variable exactly once", () => {
|
||||
const result = resolve(
|
||||
`
|
||||
:root { --page-size: 50vw 50vh; }
|
||||
:root { --page-size: 400pt 300pt; }
|
||||
page { size: var(--page-size); }
|
||||
@media (width: 400pt) { :root { --page-size: var(--missing); } }
|
||||
`,
|
||||
@@ -437,8 +434,6 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
|
||||
it.each([
|
||||
{ keyword: "InItIaL", color: undefined, hidden: false, order: 0, fixed: undefined, breakBefore: undefined },
|
||||
{ keyword: "uNsEt", color: "purple", hidden: false, order: 0, fixed: undefined, breakBefore: undefined },
|
||||
{ keyword: "ReVeRt", color: "navy", hidden: true, order: 7, fixed: true, breakBefore: "page" },
|
||||
{ keyword: "InHeRiT", color: "purple", hidden: true, order: 3, fixed: true, breakBefore: "page" },
|
||||
] as const)(
|
||||
"applies case-insensitive $keyword semantics to style, hidden, order, and structural properties",
|
||||
@@ -479,20 +474,9 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("makes size revert expose the builder page size", () => {
|
||||
const result = resolve("page { size: ReVeRt; }", {
|
||||
...context,
|
||||
pages: [{ pageKey: "page-1", width: 800, height: 600 }],
|
||||
});
|
||||
|
||||
expect(result.nodes["page-1"]?.structural.pageSize).toBe("A4");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ keyword: "initial", expected: undefined },
|
||||
{ keyword: "unset", expected: undefined },
|
||||
{ keyword: "inherit", expected: "LETTER" },
|
||||
{ keyword: "revert", expected: { width: 700, height: 900 } },
|
||||
] as const)("applies $keyword to page size structure", ({ keyword, expected }) => {
|
||||
const result = resolve(`page { size: ${keyword}; }`, {
|
||||
...context,
|
||||
|
||||
@@ -44,13 +44,12 @@ type Winner = {
|
||||
};
|
||||
|
||||
type LengthContext = {
|
||||
page: ResolvedPageDimensions;
|
||||
parentFontSize: number;
|
||||
fontSize: number;
|
||||
rootFontSize: number;
|
||||
};
|
||||
|
||||
type CssWideKeyword = "inherit" | "initial" | "revert" | "unset";
|
||||
type CssWideKeyword = "inherit" | "initial";
|
||||
|
||||
type VariableExpansionBudget = {
|
||||
work: number;
|
||||
@@ -64,7 +63,7 @@ const absoluteUnitToPt = {
|
||||
cm: 72 / 2.54,
|
||||
} as const;
|
||||
|
||||
const cssWideKeywords = new Set(["inherit", "initial", "revert", "unset"]);
|
||||
const cssWideKeywords = new Set(["inherit", "initial"]);
|
||||
const maxVariableExpansionOutputCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes;
|
||||
const maxVariableExpansionWorkCodeUnits = SEMANTIC_CSS_LIMITS_V1.maxSourceBytes * 4;
|
||||
|
||||
@@ -161,16 +160,12 @@ function pageFor(
|
||||
}
|
||||
|
||||
function toPoints(value: string, property: string, context: LengthContext): number | string | null {
|
||||
const match = value
|
||||
.trim()
|
||||
.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm|%|vw|vh|em|rem)?$/i);
|
||||
const match = value.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)\s*(pt|px|in|mm|cm|%|em|rem)?$/i);
|
||||
if (!match) return null;
|
||||
const number = Number(match[1]);
|
||||
if (!Number.isFinite(number)) return null;
|
||||
const unit = match[2]?.toLowerCase() ?? "pt";
|
||||
if (unit === "%") return property === "font-size" ? (number / 100) * context.parentFontSize : `${number}%`;
|
||||
if (unit === "vw") return (number / 100) * context.page.width;
|
||||
if (unit === "vh") return (number / 100) * context.page.height;
|
||||
if (unit === "rem") return number * context.rootFontSize;
|
||||
if (unit === "em") {
|
||||
return number * (property === "font-size" ? context.parentFontSize : context.fontSize);
|
||||
@@ -180,7 +175,6 @@ function toPoints(value: string, property: string, context: LengthContext): numb
|
||||
|
||||
function mediaMatches(query: CompiledMediaQuery, dimensions: ResolvedPageDimensions, rootFontSize: number): boolean {
|
||||
const lengthContext = {
|
||||
page: dimensions,
|
||||
parentFontSize: rootFontSize,
|
||||
fontSize: rootFontSize,
|
||||
rootFontSize,
|
||||
@@ -415,7 +409,7 @@ function customProperties(
|
||||
for (const [property, { declaration }] of winners) {
|
||||
if (!property.startsWith("--")) continue;
|
||||
const keyword = cssWideKeyword(declaration.value);
|
||||
if (keyword === "inherit" || keyword === "revert" || keyword === "unset") continue;
|
||||
if (keyword === "inherit") continue;
|
||||
if (keyword === "initial") {
|
||||
if (parent?.has(property)) {
|
||||
custom ??= new Map(parent);
|
||||
@@ -453,24 +447,12 @@ function dimensionsForSize(size: ResolvedPageSize, authored: ResolvedPageDimensi
|
||||
return { width: size.width, height: size.height ?? authored.height };
|
||||
}
|
||||
|
||||
function builderPageSize(context: ResolveStylesheetContext, nodeKey: string): ResolvedPageSize {
|
||||
return (
|
||||
context.baseStyles[nodeKey]?.structural.pageSize ??
|
||||
(context.baseSettings.page.format === "letter" ? "LETTER" : "A4")
|
||||
);
|
||||
}
|
||||
|
||||
function cssWideValue(
|
||||
keyword: CssWideKeyword,
|
||||
property: string,
|
||||
base: Readonly<Record<string, string | number>>,
|
||||
parent: Readonly<Record<string, string | number>> | undefined,
|
||||
): string | number | undefined {
|
||||
const definition = PROPERTY_REGISTRY_V1[property];
|
||||
if (keyword === "inherit") return parent?.[property];
|
||||
if (keyword === "initial") return;
|
||||
if (keyword === "unset") return definition?.inheritable ? parent?.[property] : undefined;
|
||||
return base[property] ?? (definition?.inheritable ? parent?.[property] : undefined);
|
||||
return keyword === "inherit" ? parent?.[property] : undefined;
|
||||
}
|
||||
|
||||
function normalizeValue(property: string, value: string, context: LengthContext): string | number | null {
|
||||
@@ -499,18 +481,11 @@ function applyStructuralCssWide(
|
||||
keyword: CssWideKeyword,
|
||||
property: string,
|
||||
structural: StructuralPresentation,
|
||||
base: ResolvedNodeStyle,
|
||||
parent: ResolvedNodeStyle | undefined,
|
||||
revertPageSize: ResolvedPageSize,
|
||||
): boolean {
|
||||
const key = structuralKeys[property];
|
||||
if (!key) return false;
|
||||
const value =
|
||||
keyword === "inherit"
|
||||
? parent?.structural[key]
|
||||
: keyword === "revert"
|
||||
? (base.structural[key] ?? (key === "pageSize" ? revertPageSize : undefined))
|
||||
: undefined;
|
||||
const value = keyword === "inherit" ? parent?.structural[key] : undefined;
|
||||
if (value === undefined) delete structural[key];
|
||||
else Object.assign(structural, { [key]: value });
|
||||
return true;
|
||||
@@ -684,20 +659,13 @@ export function resolveStylesheet(
|
||||
if (!expanded) continue;
|
||||
const keyword = cssWideKeyword(expanded);
|
||||
const parsed =
|
||||
keyword === "revert"
|
||||
? builderPageSize(context, node.node.key)
|
||||
: keyword === "inherit"
|
||||
? node.parent
|
||||
? context.baseStyles[node.parent.node.key]?.structural.pageSize
|
||||
: undefined
|
||||
: keyword
|
||||
? undefined
|
||||
: parsePageSize(expanded, {
|
||||
page: dimensions,
|
||||
parentFontSize: rootFontSize,
|
||||
fontSize: rootFontSize,
|
||||
rootFontSize,
|
||||
});
|
||||
keyword === "inherit"
|
||||
? node.parent
|
||||
? context.baseStyles[node.parent.node.key]?.structural.pageSize
|
||||
: undefined
|
||||
: keyword
|
||||
? undefined
|
||||
: parsePageSize(expanded, { parentFontSize: rootFontSize, fontSize: rootFontSize, rootFontSize });
|
||||
if (keyword && !parsed) {
|
||||
resolvedPageSizeValues.set(node.node.key, expanded);
|
||||
continue;
|
||||
@@ -732,13 +700,11 @@ export function resolveStylesheet(
|
||||
const parent = node.parent ? resolved[node.parent.node.key] : undefined;
|
||||
const style: Record<string, string | number> = { ...base.style };
|
||||
const specifiedStyleProperties = new Set<string>();
|
||||
const hostBaseStyleProperties = new Set<string>();
|
||||
for (const [property, definition] of Object.entries(PROPERTY_REGISTRY_V1)) {
|
||||
if (!definition?.inheritable) continue;
|
||||
const inheritedFromAuthoredRule = parent?.specifiedStyleProperties?.includes(property) && !winners.has(property);
|
||||
if (inheritedFromAuthoredRule) {
|
||||
specifiedStyleProperties.add(property);
|
||||
if (parent?.hostBaseStyleProperties?.includes(property)) hostBaseStyleProperties.add(property);
|
||||
if (parent?.style[property] === undefined) delete style[property];
|
||||
else style[property] = parent.style[property];
|
||||
} else if (style[property] === undefined && parent?.style[property] !== undefined) {
|
||||
@@ -757,8 +723,7 @@ export function resolveStylesheet(
|
||||
const keyword = cssWideKeyword(expanded);
|
||||
if (keyword) {
|
||||
specifiedStyleProperties.add("font-size");
|
||||
if (keyword === "revert") hostBaseStyleProperties.add("font-size");
|
||||
const wide = cssWideValue(keyword, "font-size", base.style, parent?.style);
|
||||
const wide = cssWideValue(keyword, "font-size", parent?.style);
|
||||
if (wide === undefined) delete style["font-size"];
|
||||
else style["font-size"] = wide;
|
||||
} else {
|
||||
@@ -767,7 +732,6 @@ export function resolveStylesheet(
|
||||
diagnostics.push(createDiagnostic("INVALID_VALUE", "error", syntaxError, fontSizeWinner.declaration.range));
|
||||
} else {
|
||||
const normalized = normalizeValue("font-size", expanded, {
|
||||
page: dimensions,
|
||||
parentFontSize,
|
||||
fontSize: parentFontSize,
|
||||
rootFontSize,
|
||||
@@ -820,14 +784,7 @@ export function resolveStylesheet(
|
||||
if (resolvedValue === undefined) continue;
|
||||
const sizeKeyword = cssWideKeyword(resolvedValue);
|
||||
if (sizeKeyword) {
|
||||
applyStructuralCssWide(
|
||||
sizeKeyword,
|
||||
property,
|
||||
structural,
|
||||
base,
|
||||
parent,
|
||||
builderPageSize(context, node.node.key),
|
||||
);
|
||||
applyStructuralCssWide(sizeKeyword, property, structural, parent);
|
||||
} else {
|
||||
const pageSize = resolvedPageSizes.get(node.node.key);
|
||||
if (pageSize) structuralValue(property, resolvedValue, structural, pageSize);
|
||||
@@ -841,22 +798,17 @@ export function resolveStylesheet(
|
||||
hidden = parent?.hidden ?? false;
|
||||
if (!hidden && parent?.style.display !== undefined) style.display = parent.style.display;
|
||||
else delete style.display;
|
||||
} else if (keyword === "revert") {
|
||||
hidden = base.hidden;
|
||||
if (base.style.display === undefined) delete style.display;
|
||||
else style.display = base.style.display;
|
||||
} else {
|
||||
hidden = false;
|
||||
delete style.display;
|
||||
}
|
||||
} else if (property === "order") {
|
||||
order = keyword === "inherit" ? (parent?.order ?? 0) : keyword === "revert" ? base.order : 0;
|
||||
order = keyword === "inherit" ? (parent?.order ?? 0) : 0;
|
||||
} else if (definition.category === "structural") {
|
||||
applyStructuralCssWide(keyword, property, structural, base, parent, builderPageSize(context, node.node.key));
|
||||
applyStructuralCssWide(keyword, property, structural, parent);
|
||||
} else {
|
||||
specifiedStyleProperties.add(property);
|
||||
if (keyword === "revert") hostBaseStyleProperties.add(property);
|
||||
const wide = cssWideValue(keyword, property, base.style, parent?.style);
|
||||
const wide = cssWideValue(keyword, property, parent?.style);
|
||||
if (wide === undefined) delete style[property];
|
||||
else style[property] = wide;
|
||||
}
|
||||
@@ -869,7 +821,6 @@ export function resolveStylesheet(
|
||||
}
|
||||
|
||||
const normalized = normalizeValue(property, expanded, {
|
||||
page: dimensions,
|
||||
parentFontSize,
|
||||
fontSize,
|
||||
rootFontSize,
|
||||
@@ -920,7 +871,6 @@ export function resolveStylesheet(
|
||||
resolved[node.node.key] = {
|
||||
style,
|
||||
specifiedStyleProperties: [...specifiedStyleProperties],
|
||||
hostBaseStyleProperties: [...hostBaseStyleProperties],
|
||||
structural,
|
||||
hidden,
|
||||
order,
|
||||
|
||||
@@ -93,11 +93,6 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
|
||||
meaning: "The property cannot affect any matched semantic node kind.",
|
||||
action: "Target a node listed in the property's Applies to column.",
|
||||
},
|
||||
ENGINE_UNSUPPORTED: {
|
||||
severity: "warning",
|
||||
meaning: "The declaration is valid Semantic CSS, but the PDF engine can't draw it yet, so it has no effect.",
|
||||
action: "Remove the declaration or reach the same look with a supported property.",
|
||||
},
|
||||
RESOURCE_LIMIT: {
|
||||
severity: "error",
|
||||
meaning: "Compilation, matching, values, variables, or semantic nodes exceeded a bounded Semantic CSS limit.",
|
||||
|
||||
@@ -32,7 +32,6 @@ export type {
|
||||
StructuralPresentation,
|
||||
StyleProgram,
|
||||
} from "./types";
|
||||
export { analyzeStylesheet } from "./analyze";
|
||||
export { resolveStylesheet } from "./cascade";
|
||||
export { compileStylesheet } from "./compile";
|
||||
export { isFatalStylesheetDiagnostic, SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
|
||||
|
||||
@@ -9,11 +9,9 @@ export type { SystemVariableDefinition, SystemVariableRegistry } from "./system-
|
||||
export { escapeCssIdentifier, escapeCssString } from "../css-escape";
|
||||
export { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "../diagnostics";
|
||||
export {
|
||||
isEngineSupportedDeclaration,
|
||||
PROPERTY_REGISTRY_V1,
|
||||
SEMANTIC_CSS_BORDER_STYLE_VALUES_V1,
|
||||
SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1,
|
||||
SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1,
|
||||
SEMANTIC_CSS_LENGTH_PROPERTIES_V1,
|
||||
SEMANTIC_CSS_LENGTH_UNITS_V1,
|
||||
SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1,
|
||||
|
||||
@@ -2,15 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import { PROPERTY_REGISTRY_V1 } from "./properties";
|
||||
|
||||
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
|
||||
const borderShorthandHints = [
|
||||
"inherit",
|
||||
"initial",
|
||||
"revert",
|
||||
"unset",
|
||||
"1pt dotted",
|
||||
"1pt dashed",
|
||||
"1pt solid",
|
||||
] as const;
|
||||
const borderShorthandHints = ["inherit", "initial", "1pt dotted", "1pt dashed", "1pt solid"] as const;
|
||||
|
||||
const expectedProperties = [
|
||||
"align-content",
|
||||
@@ -365,28 +357,18 @@ describe("property registry", () => {
|
||||
});
|
||||
|
||||
it("publishes property-specific fixed value hints", () => {
|
||||
expect(PROPERTY_REGISTRY_V1["border-style"]?.values).toEqual([
|
||||
"inherit",
|
||||
"initial",
|
||||
"revert",
|
||||
"unset",
|
||||
"dotted",
|
||||
"dashed",
|
||||
"solid",
|
||||
]);
|
||||
expect(PROPERTY_REGISTRY_V1["border-style"]?.values).toEqual(["inherit", "initial", "dotted", "dashed", "solid"]);
|
||||
expect(PROPERTY_REGISTRY_V1["object-fit"]?.values).toEqual([
|
||||
"inherit",
|
||||
"initial",
|
||||
"revert",
|
||||
"unset",
|
||||
"contain",
|
||||
"cover",
|
||||
"fill",
|
||||
"none",
|
||||
"scale-down",
|
||||
]);
|
||||
expect(PROPERTY_REGISTRY_V1["font-size"]?.values).toEqual(["inherit", "initial", "revert", "unset"]);
|
||||
expect(PROPERTY_REGISTRY_V1.gap?.values).toEqual(["inherit", "initial", "revert", "unset"]);
|
||||
expect(PROPERTY_REGISTRY_V1["font-size"]?.values).toEqual(["inherit", "initial"]);
|
||||
expect(PROPERTY_REGISTRY_V1.gap?.values).toEqual(["inherit", "initial"]);
|
||||
});
|
||||
|
||||
it.each(borderShorthands)("publishes only complete value hints for the %s shorthand", (property) => {
|
||||
|
||||
@@ -21,8 +21,8 @@ export type PropertyDefinition = {
|
||||
|
||||
export type PropertyRegistry = Readonly<Record<string, PropertyDefinition | undefined>>;
|
||||
|
||||
export const SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1 = ["inherit", "initial", "revert", "unset"] as const;
|
||||
export const SEMANTIC_CSS_LENGTH_UNITS_V1 = ["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"] as const;
|
||||
export const SEMANTIC_CSS_CSS_WIDE_KEYWORDS_V1 = ["inherit", "initial"] as const;
|
||||
export const SEMANTIC_CSS_LENGTH_UNITS_V1 = ["pt", "px", "in", "mm", "cm", "%", "em", "rem"] as const;
|
||||
export const SEMANTIC_CSS_BORDER_STYLE_VALUES_V1 = ["dotted", "dashed", "solid"] as const;
|
||||
export const SEMANTIC_CSS_LENGTH_VALUE_KEYWORDS_V1 = [
|
||||
"auto",
|
||||
@@ -342,45 +342,3 @@ const properties = {
|
||||
} satisfies PropertyRegistry;
|
||||
|
||||
export const PROPERTY_REGISTRY_V1: PropertyRegistry = properties;
|
||||
|
||||
/**
|
||||
* What the PDF engine (Forme) can't draw yet, by property: `true` for any value, or a test for the values it can't.
|
||||
* These stay valid Semantic CSS, so stylesheets keep working when the engine learns them; the editor warns instead.
|
||||
*/
|
||||
export const SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1: Readonly<Record<string, true | ((value: string) => boolean)>> = {
|
||||
"z-index": true,
|
||||
"max-lines": true,
|
||||
"text-indent": true,
|
||||
"vertical-align": true,
|
||||
"object-position": true,
|
||||
"text-decoration-color": true,
|
||||
"text-decoration-style": true,
|
||||
"-resume-min-presence-ahead": true,
|
||||
transform: (value: string) => /rotate\(\s*-?(?:\d*\.)?\d*[1-9]/.test(value),
|
||||
...Object.fromEntries(
|
||||
["padding", "padding-top", "padding-right", "padding-bottom", "padding-left"].map((property) => [
|
||||
property,
|
||||
(value: string) => value.includes("%"),
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
[
|
||||
"border",
|
||||
"border-top",
|
||||
"border-right",
|
||||
"border-bottom",
|
||||
"border-left",
|
||||
"border-style",
|
||||
"border-top-style",
|
||||
"border-right-style",
|
||||
"border-bottom-style",
|
||||
"border-left-style",
|
||||
].map((property) => [property, (value: string) => /\b(?:dashed|dotted)\b/i.test(value)]),
|
||||
),
|
||||
};
|
||||
|
||||
/** Whether the PDF engine draws this declaration (see `SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1`). */
|
||||
export function isEngineSupportedDeclaration(property: string, value: string): boolean {
|
||||
const unsupported = SEMANTIC_CSS_ENGINE_UNSUPPORTED_V1[property];
|
||||
return unsupported === undefined || (unsupported !== true && !unsupported(value));
|
||||
}
|
||||
|
||||
@@ -107,9 +107,7 @@ describe("semantic selector compilation", () => {
|
||||
expect(matches("[type]", "section-experience")).toBe(true);
|
||||
expect(matches('[type="experience"]', "section-experience")).toBe(true);
|
||||
expect(matches('[role~="nested-role"]', "item-second")).toBe(true);
|
||||
expect(matches('[origin|="custom"]', "section-experience")).toBe(true);
|
||||
expect(matches('[type^="exp"]', "section-experience")).toBe(true);
|
||||
expect(matches('[type$="ence"]', "section-experience")).toBe(true);
|
||||
expect(matches('[type*="per"]', "section-experience")).toBe(true);
|
||||
});
|
||||
|
||||
@@ -117,7 +115,6 @@ describe("semantic selector compilation", () => {
|
||||
expect(getSpecificity(":where(#one) section")).toEqual([0, 0, 1]);
|
||||
expect(getSpecificity(":is(#one, section)")).toEqual([1, 0, 0]);
|
||||
expect(getSpecificity(":not([type]) section")).toEqual([0, 1, 1]);
|
||||
expect(getSpecificity("item:nth-child(2 of #one, section)")).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -136,6 +133,9 @@ describe("semantic selector compilation", () => {
|
||||
"page[type]",
|
||||
'[type="experience" i]',
|
||||
"svg|section",
|
||||
'[origin|="custom"]',
|
||||
'[type$="ence"]',
|
||||
"item:nth-child(2 of section)",
|
||||
])("rejects unsupported or unknown selector %s", (selector) => {
|
||||
expect(compileSelector(selector).selector).toBeNull();
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ import { SEMANTIC_NODE_KINDS, SEMANTIC_REGISTRY_V1 } from "./registry/semantic";
|
||||
export type Specificity = readonly [ids: number, classes: number, types: number];
|
||||
|
||||
type Combinator = " " | ">" | "+" | "~";
|
||||
type AttributeMatcher = "=" | "~=" | "|=" | "^=" | "$=" | "*=";
|
||||
type AttributeMatcher = "=" | "~=" | "^=" | "*=";
|
||||
|
||||
type CompiledSimpleSelector =
|
||||
| { type: "universal" }
|
||||
@@ -26,7 +26,6 @@ type CompiledSimpleSelector =
|
||||
name: "nth-child" | "nth-of-type";
|
||||
a: number;
|
||||
b: number;
|
||||
of?: readonly CompiledComplexSelector[];
|
||||
};
|
||||
|
||||
type CompiledCompoundSelector = {
|
||||
@@ -142,11 +141,7 @@ function validateCompound(selectors: readonly CompiledSimpleSelector[]): void {
|
||||
}
|
||||
}
|
||||
|
||||
function compileNth(
|
||||
node: SelectorAst,
|
||||
name: "nth-child" | "nth-of-type",
|
||||
context: CompileContext,
|
||||
): CompiledSimpleSelector {
|
||||
function compileNth(node: SelectorAst, name: "nth-child" | "nth-of-type"): CompiledSimpleSelector {
|
||||
const nth = childrenOf(node);
|
||||
if (nth.length !== 1 || nth[0]?.type !== "Nth" || !nth[0].nth) {
|
||||
throw new Error(`:${name} requires one An+B expression.`);
|
||||
@@ -173,9 +168,8 @@ function compileNth(
|
||||
throw new Error(`Unsupported :${name} expression.`);
|
||||
}
|
||||
|
||||
if (name === "nth-of-type" && nth[0].selector) throw new Error(":nth-of-type does not accept an of selector.");
|
||||
const of = nth[0].selector ? compileSelectorList(nth[0].selector, { depth: context.depth + 1 }) : undefined;
|
||||
return { type: "pseudo", name, a, b, ...(of ? { of } : {}) };
|
||||
if (nth[0].selector) throw new Error(`:${name} does not accept an of selector.`);
|
||||
return { type: "pseudo", name, a, b };
|
||||
}
|
||||
|
||||
function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimpleSelector | null {
|
||||
@@ -193,7 +187,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
|
||||
if (!knownAttributes.has(name)) throw new Error(`Unknown semantic attribute ${name}.`);
|
||||
if (node.flags) throw new Error("Attribute selector flags are not supported.");
|
||||
const matcher = node.matcher as AttributeMatcher | null | undefined;
|
||||
if (matcher !== null && matcher !== undefined && !["=", "~=", "|=", "^=", "$=", "*="].includes(matcher)) {
|
||||
if (matcher !== null && matcher !== undefined && !["=", "~=", "^=", "*="].includes(matcher)) {
|
||||
throw new Error(`Unsupported attribute matcher ${matcher}.`);
|
||||
}
|
||||
const value = attributeValue(node);
|
||||
@@ -220,12 +214,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
|
||||
const selectors = compileSelectorList(nested[0], { depth: context.depth + 1 });
|
||||
return { type: "pseudo", name: name as "is" | "where" | "not", selectors };
|
||||
}
|
||||
if (name === "nth-child" || name === "nth-of-type") {
|
||||
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
|
||||
throw new SelectorResourceLimitError("Selector function nesting is too deep.");
|
||||
}
|
||||
return compileNth(node, name, context);
|
||||
}
|
||||
if (name === "nth-child" || name === "nth-of-type") return compileNth(node, name);
|
||||
throw new Error(`Unsupported pseudo-class :${name}.`);
|
||||
}
|
||||
case "ClassSelector":
|
||||
@@ -336,12 +325,8 @@ function matchesAttribute(actual: string, matcher: AttributeMatcher, expected: s
|
||||
return actual === expected;
|
||||
case "~=":
|
||||
return expected !== "" && actual.split(/\s+/).includes(expected);
|
||||
case "|=":
|
||||
return expected !== "" && (actual === expected || actual.startsWith(`${expected}-`));
|
||||
case "^=":
|
||||
return expected !== "" && actual.startsWith(expected);
|
||||
case "$=":
|
||||
return expected !== "" && actual.endsWith(expected);
|
||||
case "*=":
|
||||
return expected !== "" && actual.includes(expected);
|
||||
}
|
||||
@@ -394,8 +379,6 @@ function matchesSimple(selector: CompiledSimpleSelector, target: TreeNode): bool
|
||||
if (selector.name === "nth-of-type") {
|
||||
values = values.filter((sibling) => sibling.node.kind === target.node.kind);
|
||||
}
|
||||
if (selector.of)
|
||||
values = values.filter((sibling) => selector.of?.some((nested) => matchesComplex(nested, sibling)));
|
||||
const index = values.indexOf(target);
|
||||
return index >= 0 && nthMatches(index + 1, selector.a, selector.b);
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ export type AuthoredPageContext = {
|
||||
export type ResolvedNodeStyle = {
|
||||
style: Readonly<Record<string, string | number>>;
|
||||
specifiedStyleProperties?: readonly string[];
|
||||
hostBaseStyleProperties?: readonly string[];
|
||||
structural: StructuralPresentation;
|
||||
hidden: boolean;
|
||||
order: number;
|
||||
|
||||
@@ -510,9 +510,7 @@ function parseMedia(node: AstNode, diagnostics: SemanticCssDiagnostic[]): readon
|
||||
const invalidValue = queries.some((query) =>
|
||||
query?.features.some((feature) => {
|
||||
if (feature.name === "orientation") return false;
|
||||
const match = feature.value.match(
|
||||
/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:pt|px|in|mm|cm|vw|vh|em|rem)?$/i,
|
||||
);
|
||||
const match = feature.value.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:e[+-]?\d+)?)(?:pt|px|in|mm|cm|em|rem)?$/i);
|
||||
if (!match) {
|
||||
return true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user