feat: add semantic CSS stylesheets (#3274)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-30 12:39:15 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 4ac19f81b3
commit d2ffbf9618
320 changed files with 78393 additions and 2915 deletions
@@ -0,0 +1,15 @@
[
{
"id": "portable",
"label": "Portable smoke",
"enabled": true,
"target": { "scope": "global" },
"slots": {
"section": { "marginBottom": 5 },
"heading": { "color": "#224466" },
"text": { "color": "#112233" },
"secondaryText": { "opacity": 0.8 },
"link": { "color": "#2244aa" }
}
}
]
@@ -0,0 +1,16 @@
[
{
"id": "first",
"label": "First",
"enabled": true,
"target": { "scope": "global" },
"slots": { "heading": { "color": "#111111" } }
},
{
"id": "second",
"label": "Second",
"enabled": true,
"target": { "scope": "global" },
"slots": { "heading": { "color": "#222222" } }
}
]
@@ -0,0 +1,9 @@
[
{
"id": "award-weight",
"label": "Award title stays unbold",
"enabled": true,
"target": { "scope": "sectionType", "sectionType": "awards" },
"slots": { "text": { "fontWeight": "400" } }
}
]
@@ -0,0 +1,9 @@
[
{
"id": "clamps",
"label": "PDF clamps",
"enabled": true,
"target": { "scope": "global" },
"slots": { "section": { "borderWidth": 99, "borderRadius": 999 } }
}
]
@@ -0,0 +1,17 @@
[
{
"id": "combined-text",
"label": "Combined text host",
"enabled": true,
"target": { "scope": "global" },
"slots": {
"text": {
"color": "#6b21a8",
"fontSize": 14,
"letterSpacing": 1,
"opacity": 0.65,
"paddingLeft": 2
}
}
}
]
@@ -0,0 +1,9 @@
[
{
"id": "custom-type",
"label": "Every experience-shaped section",
"enabled": true,
"target": { "scope": "sectionType", "sectionType": "experience" },
"slots": { "item": { "paddingLeft": 7 } }
}
]
@@ -0,0 +1,9 @@
[
{
"id": "disabled",
"label": "Disabled */ cannot escape",
"enabled": false,
"target": { "scope": "global" },
"slots": { "section": { "backgroundColor": "#ff0000" } }
}
]
@@ -0,0 +1,9 @@
[
{
"id": "sizes",
"label": "Explicit decoration sizes",
"enabled": true,
"target": { "scope": "sectionType", "sectionType": "skills" },
"slots": { "icon": { "fontSize": 18 }, "level": { "fontSize": 14 } }
}
]
@@ -0,0 +1,12 @@
[
{
"id": "links",
"label": "Legacy builder-owned underline",
"enabled": true,
"target": { "scope": "global" },
"slots": {
"link": { "color": "#2255aa", "textDecoration": "none" },
"richLink": { "color": "#2255aa", "textDecoration": "none" }
}
}
]
@@ -0,0 +1,64 @@
@version 1;
/* Global text */
section field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]) {
color: #111111;
}
/* Global text: combined text host */
section combined-text {
color: #111111;
}
/* Global text: Scizor Bold final color */
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="company"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="school"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="name"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="language"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="network"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="organization"],
resume[template="scizor"] section:not([type="awards"]) field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="title"] {
color: #000000;
}
/* Experience text */
section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]) {
color: #222222;
}
/* Experience text: combined text host */
section[type="experience"] combined-text {
color: #222222;
}
/* Experience text: Scizor Bold final color */
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="company"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="school"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="name"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="language"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="network"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="organization"],
resume[template="scizor"] section[type="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="title"] {
color: #000000;
}
/* One section */
section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]) {
color: #333333;
}
/* One section: combined text host */
section[id="experience"] combined-text {
color: #333333;
}
/* One section: Scizor Bold final color */
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="company"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="school"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="name"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="language"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="network"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="organization"],
resume[template="scizor"] section[id="experience"] field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])[role~="primary-text"][name="title"] {
color: #000000;
}
@@ -0,0 +1,23 @@
[
{
"id": "id",
"label": "One section",
"enabled": true,
"target": { "scope": "sectionId", "sectionId": "experience" },
"slots": { "text": { "color": "rgba(51, 51, 51, 1)" } }
},
{
"id": "type",
"label": "Experience text",
"enabled": true,
"target": { "scope": "sectionType", "sectionType": "experience" },
"slots": { "text": { "color": "rgba(34, 34, 34, 1)" } }
},
{
"id": "global",
"label": "Global text",
"enabled": true,
"target": { "scope": "global" },
"slots": { "text": { "color": "rgba(17, 17, 17, 1)" } }
}
]
@@ -0,0 +1,9 @@
[
{
"id": "primary-weight",
"label": "Bold remains after legacy text rule",
"enabled": true,
"target": { "scope": "sectionType", "sectionType": "experience" },
"slots": { "text": { "fontWeight": "400", "color": "#234567" } }
}
]
@@ -0,0 +1,17 @@
[
{
"id": "rich",
"label": "Every rich slot",
"enabled": true,
"target": { "scope": "global" },
"slots": {
"richParagraph": { "color": "#111111" },
"richList": { "paddingLeft": 8 },
"richListItemRow": { "rowGap": 3 },
"richListItemContent": { "color": "#222222" },
"richLink": { "color": "#333333" },
"richBold": { "fontWeight": "700" },
"richMark": { "backgroundColor": "#ffff00" }
}
}
]
@@ -0,0 +1,16 @@
[
{
"id": "mixed",
"label": "Keep valid intent",
"enabled": true,
"target": { "scope": "global" },
"slots": {
"text": {
"color": "#123456",
"fontSize": "huge",
"unknownProperty": "must disappear"
}
}
},
{ "id": "", "enabled": true, "target": { "scope": "global" }, "slots": { "text": { "color": "#ffffff" } } }
]
@@ -0,0 +1,9 @@
[
{
"id": "uuid",
"label": "Quoted UUID",
"enabled": true,
"target": { "scope": "sectionId", "sectionId": "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37" },
"slots": { "heading": { "color": "#654321" } }
}
]
File diff suppressed because it is too large Load Diff
+208
View File
@@ -0,0 +1,208 @@
import type {
BaseSettingsSnapshot,
ResolvedNodeStyle,
ResolveStylesheetContext,
SemanticNode,
} from "@reactive-resume/resume/stylesheet";
import { describe, expect, it } from "vitest";
import { createElement } from "react";
import { compileStylesheet, PROPERTY_REGISTRY_V1, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Document, Image, Page, renderToBuffer, Text, View } from "#react-pdf-renderer";
import { adaptResolvedPdfNode, resolvedPdfFlowProps, resolvedPdfTextProps } from "./adapter";
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 blankStyle: ResolvedNodeStyle = { style: {}, structural: {}, hidden: false, order: 0 };
const baseSettings: BaseSettingsSnapshot = {
picture: defaultResumeData.picture,
template: defaultResumeData.metadata.template,
design: defaultResumeData.metadata.design,
typography: defaultResumeData.metadata.typography,
page: defaultResumeData.metadata.page,
layout: { sidebarWidth: defaultResumeData.metadata.layout.sidebarWidth },
};
const context: ResolveStylesheetContext = {
baseStyles: {},
baseSettings,
pages: [{ pageKey: "page", width: 595.28, height: 841.89 }],
};
const fixedValueHints = Object.entries(PROPERTY_REGISTRY_V1).flatMap(([property, definition]) => {
const kind = definition?.appliesTo[0];
return kind ? definition.values.map((value) => ({ property, kind, value })) : [];
});
const node = (kind: SemanticNode["kind"]): SemanticNode => ({
key: kind,
kind,
attributes: {},
roles: [],
children: [],
});
async function renderFixedValueHint(property: string, kind: SemanticNode["kind"], value: string) {
const compiled = compileStylesheet({
languageVersion: 1,
text: `@version 1; ${kind} { ${property}: ${value}; }`,
});
expect(
compiled.diagnostics.filter(({ severity }) => severity === "error"),
`${property}: ${value} failed compilation`,
).toEqual([]);
expect(compiled.program, `${property}: ${value} did not compile`).not.toBeNull();
if (!compiled.program) return;
const resolved = resolveStylesheet(compiled.program, node(kind), context);
expect(
resolved.diagnostics.filter(({ severity }) => severity === "error"),
`${property}: ${value} failed cascade resolution`,
).toEqual([]);
const presentation = adaptResolvedPdfNode(resolved.nodes[kind] ?? blankStyle);
const style = presentation.style === undefined ? {} : { style: presentation.style };
const content =
kind === "picture"
? createElement(Image, {
src: pictureFixture,
...style,
...resolvedPdfFlowProps(presentation),
})
: definitionIsText(property)
? createElement(Text, { ...style, ...resolvedPdfTextProps(presentation) }, "Value hint")
: createElement(
View,
{ ...style, ...resolvedPdfFlowProps(presentation) },
createElement(Text, null, "Value hint"),
);
const document = createElement(
Document,
null,
createElement(Page, { size: presentation.size ?? "A4" }, content),
) as unknown as Parameters<typeof renderToBuffer>[0];
await expect(renderToBuffer(document), `${property}: ${value} failed React PDF rendering`).resolves.toBeDefined();
}
const definitionIsText = (property: string) =>
PROPERTY_REGISTRY_V1[property]?.category === "text" || property === "color";
describe("adaptResolvedPdfNode", () => {
it("renders every published fixed value hint through cascade and the React PDF adapter", async () => {
for (const property of borderShorthands) {
expect(fixedValueHints.filter((hint) => hint.property === property).map(({ value }) => value)).toEqual(
borderShorthandHints,
);
}
for (const { property, kind, value } of fixedValueHints) {
await renderFixedValueHint(property, kind, value);
}
}, 30_000);
it("maps resolved CSS names and structural values to the owning React PDF primitive props", () => {
const resolved = {
style: {
"background-color": "#1e293b",
"font-size": 9,
"font-weight": "400",
"text-decoration": "none",
},
structural: {
breakBefore: "page",
breakInside: "avoid",
fixed: true,
minPresenceAhead: 24,
orphans: 2,
widows: 3,
pageSize: "A4",
},
hidden: false,
order: 0,
} satisfies ResolvedNodeStyle;
expect(adaptResolvedPdfNode(resolved)).toEqual({
style: {
backgroundColor: "#1e293b",
fontSize: 9,
fontWeight: "400",
textDecoration: "none",
},
break: true,
wrap: false,
fixed: true,
minPresenceAhead: 24,
orphans: 2,
widows: 3,
size: "A4",
});
});
it("preserves explicit values equal to the resolver base and distinguishes initial from host-base resets", () => {
const base = {
style: { color: "#111111", "font-weight": "700" },
structural: {},
hidden: false,
order: 0,
} satisfies ResolvedNodeStyle;
const resolved = {
...base,
style: { color: "#111111" },
specifiedStyleProperties: ["color", "font-weight"],
hostBaseStyleProperties: ["color"],
} satisfies ResolvedNodeStyle;
expect(adaptResolvedPdfNode(resolved, base)).toEqual({
style: {
fontWeight: undefined,
},
});
});
it("does not materialize inherited resolver base values onto a host that already inherits from the renderer tree", () => {
const base = {
style: {},
structural: {},
hidden: false,
order: 0,
} satisfies ResolvedNodeStyle;
const resolved = {
...base,
style: { color: "#111111", "font-size": 10 },
specifiedStyleProperties: [],
hostBaseStyleProperties: [],
} satisfies ResolvedNodeStyle;
expect(adaptResolvedPdfNode(resolved, base)).toEqual({});
});
it("emits explicit flow cancellations when semantic structure clears builder pagination", () => {
const base = {
style: {},
structural: { breakBefore: "page", breakInside: "avoid" },
hidden: false,
order: 0,
} satisfies ResolvedNodeStyle;
const resolved = {
...base,
structural: {},
} satisfies ResolvedNodeStyle;
expect(adaptResolvedPdfNode(resolved, base)).toEqual({
break: false,
wrap: true,
});
expect(adaptResolvedPdfNode(base, base)).toEqual({
break: true,
wrap: false,
});
});
});
+86
View File
@@ -0,0 +1,86 @@
import type { Style } from "@react-pdf/types";
import type { ResolvedNodeStyle, ResolvedPageSize } from "@reactive-resume/resume/stylesheet";
type ResolvedPdfPageSize = ResolvedPageSize;
export type ResolvedPdfNodePresentation = {
style?: Style;
size?: ResolvedPdfPageSize;
break?: boolean;
wrap?: boolean;
fixed?: boolean;
minPresenceAhead?: number;
orphans?: number;
widows?: number;
};
export type ResolvedPdfFlowProps = Omit<ResolvedPdfNodePresentation, "style" | "size" | "orphans" | "widows">;
export type ResolvedPdfTextProps = Omit<ResolvedPdfNodePresentation, "style" | "size">;
export const resolvedPdfFlowProps = ({
break: breakBefore,
wrap,
fixed,
minPresenceAhead,
}: ResolvedPdfNodePresentation): ResolvedPdfFlowProps => ({
...(breakBefore === undefined ? {} : { break: breakBefore }),
...(wrap === undefined ? {} : { wrap }),
...(fixed === undefined ? {} : { fixed }),
...(minPresenceAhead === undefined ? {} : { minPresenceAhead }),
});
export const resolvedPdfTextProps = ({
orphans,
widows,
...presentation
}: ResolvedPdfNodePresentation): ResolvedPdfTextProps => ({
...resolvedPdfFlowProps(presentation),
...(orphans === undefined ? {} : { orphans }),
...(widows === undefined ? {} : { widows }),
});
const toReactPdfProperty = (property: string) => {
if (property === "-resume-shadow-color") return "shadowColor";
if (property === "-resume-shadow-width") return "shadowWidth";
return property.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase());
};
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));
for (const property of specified) {
if (!hostBase.has(property) && !(property in resolved.style)) entries.push([property, undefined]);
}
if (entries.length === 0) return undefined;
return Object.freeze(
Object.fromEntries(entries.map(([property, value]) => [toReactPdfProperty(property), value])),
) as Style;
};
export function adaptResolvedPdfNode(
resolved: ResolvedNodeStyle,
base?: ResolvedNodeStyle,
): ResolvedPdfNodePresentation {
const style = styleDelta(resolved, base?.style);
const { structural } = resolved;
const breakBefore =
structural.breakBefore === "page" ? true : base?.structural.breakBefore === "page" ? false : undefined;
const wrap = structural.breakInside === "avoid" ? false : base?.structural.breakInside === "avoid" ? true : undefined;
const presentation = {
...(style ? { style } : {}),
...(structural.pageSize === undefined ? {} : { size: structural.pageSize }),
...(breakBefore === undefined ? {} : { break: breakBefore }),
...(wrap === undefined ? {} : { wrap }),
...(structural.fixed === undefined ? {} : { fixed: structural.fixed }),
...(structural.minPresenceAhead === undefined ? {} : { minPresenceAhead: structural.minPresenceAhead }),
...(structural.orphans === undefined ? {} : { orphans: structural.orphans }),
...(structural.widows === undefined ? {} : { widows: structural.widows }),
} satisfies ResolvedPdfNodePresentation;
return Object.freeze(presentation);
}
@@ -0,0 +1,60 @@
import type { Template } from "@reactive-resume/schema/templates";
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
export const comprehensiveStylesheet = {
languageVersion: 1,
text: `@version 1;
:root { --accent: var(--resume-primary-color); }
header > name { color: var(--accent); }
section:is([type="experience"], [type="education"]) > section-heading { text-transform: uppercase; }
section[id="projects"] > section-items > item { padding: 6pt; }
section[id="experience"] item[id="experience-item-2"] field[name="period"] { color: var(--accent); }
rich-text list-item > list-item-content { line-height: 1.25; }
region[placement="sidebar"] section { background-color: rgba(0, 0, 0, 0.04); }
section[type="projects"] { break-inside: avoid; -resume-min-presence-ahead: 24pt; }
@media (max-width: 600pt) { region[placement="sidebar"] section-heading { font-size: 9pt; } }
resume[template="azurill"] template-part[name="timeline-dot"] { background-color: var(--accent); }
`,
} as const;
export const buildAllTemplatesFixture = (template: Template) => {
const data = structuredClone(createSampleResumeData("Semantic CSS Acceptance"));
data.summary.content = [
"<h1>Heading</h1>",
"<blockquote><p>Quote</p></blockquote>",
"<p><strong>Strong</strong> <em>Emphasis</em> <u>Underline</u> <s>Strike</s> <code>Code</code>",
'<span style="color: #ff0000">Span</span> <mark data-color="#ffff00">Mark</mark><br>Break</p>',
"<ul><li>Unordered</li></ul><ol><li>Ordered</li></ol><hr>",
].join("");
const firstExperience = data.sections.experience.items[0];
if (!firstExperience) throw new Error("The comprehensive fixture requires an experience item.");
firstExperience.roles = [
{
id: "experience-role-1",
position: "Technical Lead",
period: "2024",
description: "<p>Led the semantic migration.</p>",
},
];
data.sections.experience.items.push({
...structuredClone(firstExperience),
id: "experience-item-2",
company: "Semantic Systems",
roles: [],
});
const reference = data.sections.references.items[0];
if (!reference) throw new Error("The comprehensive fixture requires a reference item.");
reference.position = "Engineering Director";
reference.phone = "+1 555 0100";
reference.description = "<p>Available for a reference.</p>";
for (const certification of data.sections.certifications.items) {
certification.description = "<p>Verified certification.</p>";
}
data.metadata.template = template;
data.metadata.stylesheet = {
mode: "semantic",
source: comprehensiveStylesheet,
applied: comprehensiveStylesheet,
};
return data;
};
@@ -0,0 +1,74 @@
import type { SemanticNode, SemanticNodeKind } from "@reactive-resume/resume/stylesheet";
import { describe, expect, it } from "vitest";
import { SEMANTIC_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet";
import { templateSchema } from "@reactive-resume/schema/templates";
import { buildAllTemplatesFixture, comprehensiveStylesheet } from "./all-templates-fixture";
import { STANDARD_FIELD_REGISTRY, STANDARD_ROLE_REGISTRY } from "./binding-inventory";
import { resolveResumeRuntime } from "./resolve";
import { getTemplateSemanticManifest } from "./template-manifest";
const flatten = (node: SemanticNode): SemanticNode[] => [node, ...node.children.flatMap(flatten)];
describe("Semantic CSS all-template presentation", () => {
it.each(templateSchema.options)("snapshots the resolved %s presentation map", (template) => {
const data = buildAllTemplatesFixture(template);
const runtime = resolveResumeRuntime({
data,
template,
applied: comprehensiveStylesheet,
mode: "semantic",
});
expect(runtime.diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
expect(runtime.presentation).toMatchSnapshot();
});
it("exercises every semantic kind, conditional field, role, and template part", () => {
const kinds = new Set<SemanticNodeKind>();
const fields = new Set<string>();
const roles = new Set<string>();
const parts = new Map<string, Set<string>>();
for (const template of templateSchema.options) {
const data = buildAllTemplatesFixture(template);
const { sourceTree } = resolveResumeRuntime({
data,
template,
applied: comprehensiveStylesheet,
mode: "semantic",
});
const templateParts = new Set<string>();
const visit = (node: SemanticNode, sectionType?: string, experienceRole = false) => {
const nextSectionType = node.kind === "section" ? node.attributes.type : sectionType;
const nextExperienceRole = experienceRole || node.roles.includes("experience-role");
kinds.add(node.kind);
for (const role of node.roles) roles.add(role);
if (node.kind === "field" && nextSectionType && node.attributes.name) {
fields.add(`${nextExperienceRole ? "experience-role" : nextSectionType}:${node.attributes.name}`);
}
if (node.kind === "template-part" && node.attributes.name) templateParts.add(node.attributes.name);
for (const alias of node.attributes.part?.split(" ").filter(Boolean) ?? []) templateParts.add(alias);
for (const child of node.children) visit(child, nextSectionType, nextExperienceRole);
};
visit(sourceTree);
parts.set(template, templateParts);
}
expect([...kinds].sort()).toEqual(Object.keys(SEMANTIC_REGISTRY_V1).sort());
expect([...STANDARD_ROLE_REGISTRY].filter((role) => !roles.has(role))).toEqual([]);
const expectedFields = Object.entries(STANDARD_FIELD_REGISTRY).flatMap(([section, definitions]) =>
Object.keys(definitions).map((field) => `${section}:${field}`),
);
expect(expectedFields.filter((field) => !fields.has(field))).toEqual([]);
for (const template of templateSchema.options) {
const expectedParts = getTemplateSemanticManifest(template)
.parts.map(({ name }) => name)
.sort();
expect([...(parts.get(template) ?? [])].sort(), `${template} template parts`).toEqual(expectedParts);
}
});
});
@@ -0,0 +1,23 @@
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import { createElement } from "react";
import { templateSchema } from "@reactive-resume/schema/templates";
import { ResumeDocument } from "../document";
import { buildAllTemplatesFixture } from "./all-templates-fixture";
describe("Semantic CSS all-template smoke", () => {
it.each(templateSchema.options)(
"renders %s with the comprehensive stylesheet",
async (template) => {
const element = createElement(ResumeDocument, {
data: buildAllTemplatesFixture(template),
template,
}) as unknown as Parameters<typeof renderToBuffer>[0];
const output = await renderToBuffer(element);
expect(output.byteLength).toBeGreaterThan(1_000);
expect(output.subarray(0, 4).toString()).toBe("%PDF");
},
30_000,
);
});
@@ -0,0 +1,158 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const findText = (node: HostNode, text: string): HostNode | undefined => {
if (node.type === "TEXT" && nodeText(node) === text) return node;
for (const child of node.children ?? []) {
const match = findText(child, text);
if (match) return match;
}
};
const mergedStyle = (node: HostNode | undefined): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node?.style) ? node.style : node?.style ? [node.style] : []));
const buildFixture = (template: Template, rule = ""): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.metadata.design.colors.text = "#111111";
data.metadata.design.colors.background = "#eeeeee";
data.metadata.design.colors.primary = "#663399";
data.metadata.typography.heading.fontWeights = ["400", "700"];
data.sections.skills.title = "Expertise";
data.sections.skills.items = [
{
id: "skill-1",
hidden: false,
icon: "",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 3,
keywords: [],
},
];
data.metadata.layout.pages =
template === "chikorita"
? [{ fullWidth: false, main: [], sidebar: ["skills"] }]
: [{ fullWidth: true, main: ["skills"], sidebar: [] }];
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
return data;
};
const finalTextStyle = async (template: Template, text: string, rule = "") => {
const data = buildFixture(template, rule);
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return mergedStyle(findText(instance.container.document as HostNode, text));
};
const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial" | "revert" | "unset") => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.metadata.typography.body.fontWeights = ["400", "500"];
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }];
const text = `@version 1; ${
keyword ? `section[type="experience"] field[name="company"] { font-weight: ${keyword}; }` : ""
}`;
const stylesheet = { languageVersion: 1, text };
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return mergedStyle(findText(instance.container.document as HostNode, "Analytical Engines"));
};
describe("PDF semantic base and reset fidelity", () => {
it("keeps Bronzor's first heading weight and lets an explicit last weight override it", async () => {
expect(await finalTextStyle("bronzor", "Expertise")).toMatchObject({ fontWeight: "400" });
expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: 700; }")).toMatchObject({
fontWeight: "700",
});
});
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("cancels Bronzor's heading weight with the CSS initial value", async () => {
expect(await finalTextStyle("bronzor", "Expertise", "section-heading { font-weight: initial; }")).toMatchObject({
fontWeight: undefined,
});
});
it("keeps Chikorita's sidebar placement color and lets an explicit body color override it", async () => {
expect(await finalTextStyle("chikorita", "TypeScript")).toMatchObject({ color: "#eeeeee" });
expect(await finalTextStyle("chikorita", "TypeScript", "field[name='name'] { color: #111111; }")).toMatchObject({
color: "#111111",
});
});
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.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 initial", async () => {
expect(await finalOnyxCompanyStyle("initial")).toMatchObject({ fontWeight: undefined });
});
it("restores Onyx's local company weight with revert", async () => {
expect(await finalOnyxCompanyStyle()).toMatchObject({ fontWeight: "500" });
expect(await finalOnyxCompanyStyle("revert")).toMatchObject({ fontWeight: "500" });
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { ResolvedNodeStyle, SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
export type BuildPdfBaseStylesInput = {
data: ResumeData;
template: Template;
tree: SemanticNode;
};
const textKinds = new Set([
"name",
"headline",
"section-heading",
"combined-text",
"field",
"link",
"rich-heading",
"paragraph",
"list-marker",
"list-item-content",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
]);
const headingKinds = new Set(["name", "section-heading", "rich-heading"]);
type SectionBreaks = {
keepTogether?: boolean;
startOnNewPage?: boolean;
};
const resolveSectionBreaks = (data: ResumeData, id: string | undefined): SectionBreaks | undefined => {
if (!id) return {};
if (id === "summary") return data.summary;
if (id in data.sections) return data.sections[id as keyof typeof data.sections];
return data.customSections.find((section) => section.id === id);
};
const pageSize = (format: ResumeData["metadata"]["page"]["format"]) => {
if (format === "letter") return "LETTER" as const;
if (format === "free-form") return { width: 595.28 };
return "A4" as const;
};
export function buildPdfBaseStyles({
data,
tree,
}: BuildPdfBaseStylesInput): Readonly<Record<string, ResolvedNodeStyle>> {
const result: Record<string, ResolvedNodeStyle> = {};
const body = data.metadata.typography.body;
const heading = data.metadata.typography.heading;
const bodyWeight = body.fontWeights[0] ?? "400";
const boldWeight = body.fontWeights.at(-1) ?? "600";
const headingWeight = heading.fontWeights.at(-1) ?? "600";
const visit = (node: SemanticNode) => {
const style: Record<string, string | number> = {};
const structural: ResolvedNodeStyle["structural"] = {};
if (node.kind === "page") {
style.color = data.metadata.design.colors.text;
style["background-color"] = data.metadata.design.colors.background;
style["font-size"] = body.fontSize;
style["font-weight"] = bodyWeight;
style["line-height"] = body.lineHeight;
structural.pageSize = pageSize(data.metadata.page.format);
}
if (textKinds.has(node.kind)) {
style.color = data.metadata.design.colors.text;
style["font-size"] = headingKinds.has(node.kind) ? heading.fontSize : body.fontSize;
style["font-weight"] = headingKinds.has(node.kind)
? headingWeight
: node.roles.includes("primary-text") || node.kind === "strong"
? boldWeight
: bodyWeight;
style["line-height"] = headingKinds.has(node.kind) ? heading.lineHeight : body.lineHeight;
}
if (node.kind === "link" || (node.kind === "contact-item" && node.roles.includes("structured-link"))) {
style["text-decoration"] = data.metadata.page.hideLinkUnderline ? "none" : "underline";
}
if (node.kind === "picture") {
Object.assign(style, {
width: data.picture.size,
height: data.picture.size,
"object-fit": "cover",
"aspect-ratio": data.picture.aspectRatio,
"border-radius": data.picture.borderRadius,
"border-color": data.picture.borderColor,
"border-width": data.picture.borderWidth,
"-resume-shadow-color": data.picture.shadowColor,
"-resume-shadow-width": data.picture.shadowWidth,
transform: `rotate(${data.picture.rotation}deg)`,
});
}
if (node.kind === "section") {
const section = resolveSectionBreaks(data, node.id);
if (section?.keepTogether) structural.breakInside = "avoid";
if (section?.startOnNewPage) structural.breakBefore = "page";
}
result[node.key] = Object.freeze({
style: Object.freeze(style),
structural: Object.freeze(structural),
hidden: false,
order: 0,
});
for (const child of node.children) visit(child);
};
visit(tree);
return Object.freeze(result);
}
@@ -0,0 +1,228 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const mergedStyle = (node: HostNode): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : []));
const findPath = (node: HostNode, predicate: (candidate: HostNode) => boolean): HostNode[] | undefined => {
if (predicate(node)) return [node];
for (const child of node.children ?? []) {
const path = findPath(child, predicate);
if (path) return [node, ...path];
}
};
const nodesWithStyle = (node: HostNode, property: string, value: unknown): HostNode[] => [
...(mergedStyle(node)[property] === value ? [node] : []),
...(node.children ?? []).flatMap((child) => nodesWithStyle(child, property, value)),
];
const buildFixture = (
text: string,
section: "summary" | "skills" | "experience" | "languages" | "references" = "summary",
): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.headline = "Programmer";
data.basics.email = "ada@example.com";
data.basics.phone = "";
data.basics.location = "";
data.basics.website = { url: "", label: "" };
data.basics.customFields = [];
data.summary.content = "<p>Computing pioneer.</p>";
data.sections.skills.title = "Expertise";
data.sections.skills.items = [
{
id: "skill-1",
hidden: false,
icon: "brain",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 0,
keywords: [],
},
];
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [
{
id: "role-1",
position: "Architect",
period: "1843",
description: "<p>Designed the engine.</p>",
},
],
},
];
data.sections.languages.items = [
{ id: "language-1", hidden: false, language: "English", fluency: "Native", level: 0 },
];
data.sections.references.items = [
{
id: "reference-1",
hidden: false,
name: "Charles Babbage",
position: "Inventor",
phone: "+44 123",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.metadata.page.hideSectionIcons = false;
data.metadata.page.hideIcons = false;
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
const stylesheet = { languageVersion: 1, text: `@version 1; ${text}` };
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
return data;
};
const renderFixture = async (template: Template, data: ResumeData): Promise<HostNode> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return instance.container.document as HostNode;
};
describe("semantic binding host fidelity", () => {
it("attaches region, contact-list, icon-row heading, no-border item-header, and item-icon styles", async () => {
const document = await renderFixture(
"onyx",
buildFixture(
`
region[region="main"] { background-color: #101010; }
contact-list { background-color: #202020; }
section-heading { background-color: #303030; }
item-header { background-color: #404040; }
icon { opacity: 0.25; }
`,
"skills",
),
);
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
const headingPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Expertise");
const skillPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "TypeScript");
expect(emailPath?.some((node) => mergedStyle(node).backgroundColor === "#202020")).toBe(true);
expect(headingPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#303030")).toBe(
true,
);
expect(mergedStyle(headingPath?.at(-1) as HostNode).backgroundColor).toBeUndefined();
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#404040")).toBe(true);
expect(skillPath?.some((node) => mergedStyle(node).backgroundColor === "#101010")).toBe(true);
expect(nodesWithStyle(document, "opacity", 0.25).some(({ type }) => type === "SVG")).toBe(true);
});
it.each([
["languages", "English"],
["references", "Charles Babbage"],
] as const)("keeps direct %s Text siblings outside the item-header contract", async (section, text) => {
const document = await renderFixture(
"onyx",
buildFixture(
`
item-header { display: none; background-color: #414141; break-before: page; }
field[role~="primary-text"] { color: #515151; }
`,
section,
),
);
const textPath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === text);
const textNode = textPath?.at(-1);
expect(textNode).toBeDefined();
expect(textNode && mergedStyle(textNode)).toMatchObject({ color: "#515151" });
expect(textPath?.some((node) => mergedStyle(node).backgroundColor === "#414141")).toBe(false);
});
it("binds nested experience-role item and item-header styles to their existing Views", async () => {
const document = await renderFixture(
"onyx",
buildFixture(
`
item[role~="nested-role"] { background-color: #515151; }
item[role~="nested-role"] > item-header { border-top-width: 7pt; }
`,
"experience",
),
);
const rolePath = findPath(document, (node) => node.type === "TEXT" && nodeText(node) === "Architect");
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#515151")).toBe(
true,
);
expect(rolePath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderTopWidth === 7)).toBe(true);
});
it("binds Rhyhorn's outer contact owner, nested content primitive, link alias, and last-owner alias separately", async () => {
const document = await renderFixture(
"rhyhorn",
buildFixture(`
contact-item { background-color: #616161; }
template-part[name="contact-item-content"] { color: #717171; }
link { text-decoration: none; }
contact-item[part~="contact-item-last"] { border-bottom-width: 9pt; }
`),
);
const emailPath = findPath(document, (node) => node.type === "LINK" && nodeText(node) === "ada@example.com");
const link = emailPath?.at(-1);
expect(link && mergedStyle(link)).toMatchObject({ color: "#717171", textDecoration: "none" });
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).backgroundColor === "#616161")).toBe(
true,
);
expect(emailPath?.some((node) => node.type === "VIEW" && mergedStyle(node).borderBottomWidth === 9)).toBe(true);
});
it.each([
["azurill", 4],
["ditgar", 1],
["ditto", 3],
["gengar", 1],
["glalie", 1],
["leafish", 3],
["meowth", 3],
["pikachu", 1],
["rhyhorn", 1],
["scizor", 1],
] as const)("%s attaches every visible primitive template part to an existing host", async (template, expected) => {
const section = template === "meowth" ? "experience" : "summary";
const document = await renderFixture(template, buildFixture("template-part { opacity: 0.37; }", section));
expect(nodesWithStyle(document, "opacity", 0.37)).toHaveLength(expected);
});
it("honors primitive template-part visibility on the exact existing host", async () => {
const document = await renderFixture(
"pikachu",
buildFixture('template-part[name="header-divider"] { display: none; }'),
);
expect(nodeText(document)).not.toContain("Ada Lovelace");
expect(nodeText(document)).toContain("ada@example.com");
});
});
@@ -0,0 +1,406 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createBindingInventory, SHARED_BINDING_REGISTRY } from "./binding-inventory";
import { getTemplateSemanticBindingRegistry } from "./template-manifest";
import { buildSemanticTree } from "./tree";
const node = (key: string, kind: SemanticNode["kind"], children: SemanticNode[] = []): SemanticNode => ({
key,
kind,
attributes: {},
roles: [],
children,
});
const findNode = (tree: SemanticNode, predicate: (candidate: SemanticNode) => boolean): SemanticNode | undefined => {
if (predicate(tree)) return tree;
for (const child of tree.children) {
const match = findNode(child, predicate);
if (match) return match;
}
};
const required = (candidate: SemanticNode | undefined, label: string): SemanticNode => {
if (!candidate) throw new Error(`Missing ${label}`);
return candidate;
};
describe("semantic binding inventory", () => {
it("reports missing and synthetic bindings instead of claiming success", () => {
const tree = node("resume", "resume", [node("resume/part", "template-part")]);
const inventory = createBindingInventory(tree, {
...SHARED_BINDING_REGISTRY,
"template-part": { type: "primitive", primitive: "View", source: "synthetic" },
});
expect(inventory.unboundNodeKeys).toEqual(["resume/part"]);
expect(inventory.syntheticWrapperCount).toBe(1);
});
it("reports an alias unbound when its canonical primitive owner is absent", () => {
const inventory = createBindingInventory(node("rich", "rich-text"), {
"rich-text": {
type: "alias",
canonicalKind: "field",
canonicalNodeKey: "missing-field",
token: "rich-text",
},
});
expect(inventory.bindings).toEqual({});
expect(inventory.unboundNodeKeys).toEqual(["rich"]);
});
it("reports a rich-text alias unbound when its existing canonical node has the wrong kind", () => {
const inventory = createBindingInventory(node("owner", "item", [node("rich", "rich-text")]), {
item: { type: "primitive", primitive: "View", source: "existing" },
"rich-text": {
type: "alias",
canonicalKind: "field",
canonicalNodeKey: "owner",
token: "rich-text",
},
});
expect(inventory.bindings.rich).toBeUndefined();
expect(inventory.unboundNodeKeys).toContain("rich");
expect(inventory.unboundNodeKeys).not.toEqual([]);
});
it("reports a rich-text alias unbound when its field owner binding is synthetic or non-primitive", () => {
const synthetic = createBindingInventory(node("field", "field", [node("rich-synthetic", "rich-text")]), {
field: { type: "primitive", primitive: "View", source: "synthetic" },
"rich-text": {
type: "alias",
canonicalKind: "field",
canonicalNodeKey: "field",
token: "rich-text",
},
});
const nonPrimitive = createBindingInventory(
node("item", "item", [node("field", "field", [node("rich-non-primitive", "rich-text")])]),
{
item: { type: "primitive", primitive: "View", source: "existing" },
field: {
type: "alias",
canonicalKind: "item",
canonicalNodeKey: "item",
token: "field",
},
"rich-text": {
type: "alias",
canonicalKind: "field",
canonicalNodeKey: "field",
token: "rich-text",
},
},
);
expect({
syntheticBinding: synthetic.bindings["rich-synthetic"],
syntheticAliasUnbound: synthetic.unboundNodeKeys.includes("rich-synthetic"),
syntheticInventoryClean: synthetic.unboundNodeKeys.length === 0,
nonPrimitiveBinding: nonPrimitive.bindings["rich-non-primitive"],
nonPrimitiveAliasUnbound: nonPrimitive.unboundNodeKeys.includes("rich-non-primitive"),
nonPrimitiveInventoryClean: nonPrimitive.unboundNodeKeys.length === 0,
}).toEqual({
syntheticBinding: undefined,
syntheticAliasUnbound: true,
syntheticInventoryClean: false,
nonPrimitiveBinding: undefined,
nonPrimitiveAliasUnbound: true,
nonPrimitiveInventoryClean: false,
});
});
it("resolves conditional bindings to the primitive the existing renderer uses", () => {
const heading = node("heading", "section-heading", [node("heading/icon", "icon")]);
const shape = { ...node("level/shape", "icon"), attributes: { type: "circle" }, roles: ["active"] };
const icon = { ...node("level/icon", "icon"), attributes: { type: "icon" }, roles: ["inactive"] };
const inventory = createBindingInventory(node("resume", "resume", [heading, shape, icon]));
expect(inventory.bindings.heading).toMatchObject({ primitive: "View", source: "existing" });
expect(inventory.bindings["level/shape"]).toMatchObject({ primitive: "View", source: "existing" });
expect(inventory.bindings["level/icon"]).toMatchObject({ primitive: "Svg", source: "existing" });
});
it("binds every emitted shared node to one existing primitive without synthetic wrappers", () => {
const data = structuredClone(defaultResumeData);
data.picture.url = "/uploads/picture.png";
data.basics = {
name: "Ada",
headline: "Engineer",
email: "ada@example.com",
phone: "",
location: "Berlin",
website: { url: "", label: "" },
customFields: [],
};
data.summary.content =
"<h1>Heading</h1><blockquote>Quote</blockquote><p><strong>Bold</strong><br></p><ul><li>Item</li></ul><hr>";
data.sections.skills.items = [
{
id: "skill/1",
hidden: false,
icon: "code",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 2,
keywords: ["Types"],
},
];
const page = { fullWidth: false, main: ["summary", "skills"], sidebar: [] };
const tree = buildSemanticTree({ data, template: "onyx", page, pageNumber: 1, showHeader: true });
const inventory = createBindingInventory(tree);
const nodeCount = (candidate: SemanticNode): number =>
1 + candidate.children.reduce((count, child) => count + nodeCount(child), 0);
expect(inventory.unboundNodeKeys).toEqual([]);
expect(inventory.syntheticWrapperCount).toBe(0);
expect(Object.keys(inventory.bindings)).toHaveLength(nodeCount(tree));
expect(
Object.values(inventory.bindings).every((binding) => binding.type === "alias" || binding.source === "existing"),
).toBe(true);
for (const binding of Object.values(inventory.bindings)) {
if (binding.type !== "alias") continue;
expect(inventory.bindings[binding.canonicalNodeKey]).toMatchObject({
type: "primitive",
source: "existing",
});
}
});
it("does not claim item-header Views for direct language and reference Text siblings", () => {
const data = structuredClone(defaultResumeData);
data.sections.languages.items = [
{ id: "language-1", hidden: false, language: "English", fluency: "Native", level: 0 },
];
data.sections.references.items = [
{
id: "reference-1",
hidden: false,
name: "Charles Babbage",
position: "Inventor",
phone: "+44 123",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: ["languages", "references"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const inventory = createBindingInventory(tree);
for (const itemId of ["language-1", "reference-1"]) {
const item = required(
findNode(tree, (candidate) => candidate.kind === "item" && candidate.id === itemId),
`${itemId} item`,
);
expect(item.children.some(({ kind }) => kind === "item-header")).toBe(false);
expect(
item.children
.filter(({ kind }) => kind === "field")
.every(
({ key }) => inventory.bindings[key]?.type === "primitive" && inventory.bindings[key].primitive === "Text",
),
).toBe(true);
}
});
it("aliases each rich-text identity to its field primitive without claiming a second root View", () => {
const data = structuredClone(defaultResumeData);
data.summary.content = "<p>Summary</p>";
data.sections.experience.items = [
{
id: "experience/1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "<p>Description</p>",
roles: [],
},
];
data.customSections = [
{
id: "cover",
type: "cover-letter",
title: "Cover Letter",
icon: "article",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: "cover/1",
hidden: false,
recipient: "<p>Recipient</p>",
content: "<p>Letter</p>",
},
],
},
];
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: ["summary", "experience", "cover"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const inventory = createBindingInventory(tree);
const cases = [
["summary", "content"],
["experience", "description"],
["cover", "recipient"],
["cover", "content"],
] as const;
for (const [sectionId, fieldName] of cases) {
const section = required(
findNode(tree, (candidate) => candidate.kind === "section" && candidate.id === sectionId),
`${sectionId} section`,
);
const field = required(
findNode(section, (candidate) => candidate.kind === "field" && candidate.attributes.name === fieldName),
`${sectionId}.${fieldName} field`,
);
const richText = required(
findNode(field, (candidate) => candidate.kind === "rich-text"),
`${sectionId}.${fieldName} rich text`,
);
expect(inventory.bindings[field.key]).toEqual({
type: "primitive",
primitive: "View",
source: "existing",
});
expect(inventory.bindings[richText.key]).toEqual({
type: "alias",
canonicalKind: "field",
canonicalNodeKey: field.key,
token: "rich-text",
});
expect([field.key, richText.key].filter((key) => inventory.bindings[key]?.type === "primitive")).toEqual([
field.key,
]);
}
});
it.each([
["en-US", "ltr", "View"],
["ar-SA", "rtl", "Text"],
] as const)("binds %s list item content through the renderer direction seam", (locale, direction, primitive) => {
const data = structuredClone(defaultResumeData);
data.metadata.page.locale = locale;
data.summary.content = "<ul><li>Item</li></ul>";
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: ["summary"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const content = required(
findNode(tree, (candidate) => candidate.kind === "list-item-content"),
`${direction} list item content`,
);
expect(content.attributes).toEqual({ direction });
expect(createBindingInventory(tree).bindings[content.key]).toEqual({
type: "primitive",
primitive,
source: "existing",
});
});
it("uses the renderer's trimmed custom contact link decision", () => {
const data = structuredClone(defaultResumeData);
data.basics.customFields = [
{ id: "whitespace", icon: "link", text: "No link", link: " " },
{ id: "linked", icon: "link", text: "Linked", link: " https://example.com " },
];
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: [], sidebar: [] },
pageNumber: 1,
showHeader: true,
});
const inventory = createBindingInventory(tree);
const whitespace = required(
findNode(tree, (candidate) => candidate.id === "whitespace"),
"whitespace contact",
);
const linked = required(
findNode(tree, (candidate) => candidate.id === "linked"),
"linked contact",
);
expect(whitespace.roles).toEqual([]);
expect(inventory.bindings[whitespace.key]).toMatchObject({ type: "primitive", primitive: "View" });
expect(findNode(whitespace, (candidate) => candidate.kind === "link")).toBeUndefined();
expect(linked.roles).toEqual(["structured-link"]);
expect(inventory.bindings[linked.key]).toMatchObject({ type: "primitive", primitive: "Link" });
const linkedAlias = required(
findNode(linked, (candidate) => candidate.kind === "link"),
"linked contact semantic link",
);
expect(inventory.bindings[linkedAlias.key]).toEqual({
type: "alias",
canonicalKind: "contact-item",
canonicalNodeKey: linked.key,
token: "structured-link",
});
expect([linked.key, linkedAlias.key].filter((key) => inventory.bindings[key]?.type === "primitive")).toEqual([
linked.key,
]);
});
it.each([
["chikorita", "contact-row-primary", "View"],
["chikorita", "contact-row-secondary", "View"],
["meowth", "education-grade-row", "Text"],
] as const)("binds %s's %s descriptor to its existing %s host", (template, partName, primitive) => {
const data = structuredClone(defaultResumeData);
data.basics.email = "ada@example.com";
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
area: "Mathematics",
degree: "BSc",
grade: "First",
location: "London",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
const tree = buildSemanticTree({
data,
template,
page: { fullWidth: true, main: ["education"], sidebar: [] },
pageNumber: 1,
showHeader: true,
});
const part = required(
findNode(tree, (candidate) => candidate.kind === "template-part" && candidate.attributes.name === partName),
partName,
);
const inventory = createBindingInventory(tree, getTemplateSemanticBindingRegistry(template));
expect(inventory.bindings[part.key]).toEqual({ type: "primitive", primitive, source: "existing" });
expect(inventory.syntheticWrapperCount).toBe(0);
});
});
@@ -0,0 +1,212 @@
import type { SemanticNode, SemanticNodeKind } from "@reactive-resume/resume/stylesheet/types";
export type StandardFieldRole = "primary-text" | "secondary-text" | "structured-link";
export type StandardFieldDefinition = Readonly<Record<string, readonly StandardFieldRole[]>>;
export const STANDARD_FIELD_REGISTRY = {
summary: { content: [] },
profiles: { network: ["primary-text"], username: ["secondary-text", "structured-link"] },
experience: {
company: ["primary-text"],
position: ["secondary-text"],
location: ["secondary-text"],
period: ["secondary-text"],
description: [],
},
"experience-role": { position: ["primary-text"], period: ["secondary-text"], description: [] },
education: {
school: ["primary-text"],
area: ["secondary-text"],
degree: ["secondary-text"],
grade: ["secondary-text"],
location: ["secondary-text"],
period: ["secondary-text"],
description: [],
},
projects: { name: ["primary-text"], period: ["secondary-text"], description: [] },
skills: { name: ["primary-text"], proficiency: ["secondary-text"], keywords: ["secondary-text"] },
languages: { language: ["primary-text"], fluency: ["secondary-text"] },
interests: { name: ["primary-text"], keywords: ["secondary-text"] },
awards: { title: ["primary-text"], date: ["secondary-text"], awarder: ["secondary-text"], description: [] },
certifications: { title: ["primary-text"], date: ["secondary-text"], issuer: ["secondary-text"], description: [] },
publications: { title: ["primary-text"], date: ["secondary-text"], publisher: ["secondary-text"], description: [] },
volunteer: {
organization: ["primary-text"],
location: ["secondary-text"],
period: ["secondary-text"],
description: [],
},
references: {
name: ["primary-text"],
position: ["secondary-text"],
phone: ["secondary-text"],
description: [],
},
"cover-letter": { recipient: [], content: [] },
} as const satisfies Readonly<Record<string, StandardFieldDefinition>>;
export const STANDARD_ROLE_REGISTRY = [
"primary-text",
"secondary-text",
"structured-link",
"decoration",
"section-title",
"picture",
"experience-role",
"nested-role",
"active",
"inactive",
] as const;
export type PrimitiveBinding = {
type: "primitive";
primitive: "Document" | "Page" | "View" | "Text" | "Link" | "Image" | "Svg";
source: "existing" | "synthetic";
};
type AliasBinding = {
type: "alias";
canonicalKind: SemanticNodeKind;
canonicalNodeKey: string;
token: string;
};
type SemanticBinding = PrimitiveBinding | AliasBinding;
type SemanticBindingContext = {
parent: SemanticNode | undefined;
};
export type SemanticBindingRegistry = Readonly<
Partial<
Record<
SemanticNodeKind,
SemanticBinding | ((node: SemanticNode, context: SemanticBindingContext) => SemanticBinding | undefined)
>
>
>;
export type BindingInventory = {
bindings: Readonly<Record<string, SemanticBinding>>;
aliasTokensByNodeKey: Readonly<Record<string, readonly string[]>>;
unboundNodeKeys: readonly string[];
syntheticWrapperCount: number;
};
const existing = (primitive: PrimitiveBinding["primitive"]): PrimitiveBinding => ({
type: "primitive",
primitive,
source: "existing",
});
export const SHARED_BINDING_REGISTRY = {
resume: existing("Document"),
page: existing("Page"),
region: existing("View"),
header: existing("View"),
picture: existing("Image"),
name: existing("Text"),
headline: existing("Text"),
"contact-list": existing("View"),
"contact-item": (node) => existing(node.roles.includes("structured-link") ? "Link" : "View"),
section: existing("View"),
"section-heading": (node) => existing(node.children.some((child) => child.kind === "icon") ? "View" : "Text"),
"section-items": existing("View"),
item: existing("View"),
"item-header": existing("View"),
"combined-text": (node, { parent }) =>
node.attributes.owner === "parent" && parent
? {
type: "alias",
canonicalKind: parent.kind,
canonicalNodeKey: parent.key,
token: "combined-text",
}
: existing("Text"),
field: (node) => existing(node.children.some((child) => child.kind === "rich-text") ? "View" : "Text"),
link: (_node, { parent }) =>
parent?.kind === "contact-item"
? {
type: "alias",
canonicalKind: "contact-item",
canonicalNodeKey: parent.key,
token: "structured-link",
}
: existing("Link"),
icon: (node) => existing(node.attributes.type && node.attributes.type !== "icon" ? "View" : "Svg"),
level: existing("View"),
"rich-text": (_node, { parent }) =>
parent?.kind === "field"
? {
type: "alias",
canonicalKind: "field",
canonicalNodeKey: parent.key,
token: "rich-text",
}
: undefined,
"rich-heading": existing("Text"),
blockquote: existing("View"),
paragraph: existing("Text"),
list: existing("View"),
"list-item": existing("View"),
"list-item-content": (node) => existing(node.attributes.direction === "rtl" ? "Text" : "View"),
"list-marker": existing("Text"),
strong: existing("Text"),
emphasis: existing("Text"),
underline: existing("Text"),
strike: existing("Text"),
code: existing("Text"),
"text-span": existing("Text"),
mark: existing("Text"),
"hard-break": existing("Text"),
"horizontal-rule": existing("View"),
} as const satisfies SemanticBindingRegistry;
export function createBindingInventory(
tree: SemanticNode,
registry: SemanticBindingRegistry = SHARED_BINDING_REGISTRY,
): BindingInventory {
const bindings: Record<string, SemanticBinding> = {};
const aliasTokensByNodeKey: Record<string, readonly string[]> = {};
const unboundNodeKeys: string[] = [];
const nodes = new Map<string, SemanticNode>();
let syntheticWrapperCount = 0;
const visit = (node: SemanticNode, parent?: SemanticNode) => {
nodes.set(node.key, node);
const aliasTokens = node.attributes.part?.split(" ").filter(Boolean);
if (aliasTokens?.length) aliasTokensByNodeKey[node.key] = aliasTokens;
const declaration = registry[node.kind];
const binding = typeof declaration === "function" ? declaration(node, { parent }) : declaration;
if (!binding) {
unboundNodeKeys.push(node.key);
} else {
bindings[node.key] = binding;
if (binding.type === "primitive" && binding.source === "synthetic") {
syntheticWrapperCount += 1;
unboundNodeKeys.push(node.key);
}
}
for (const child of node.children) visit(child, node);
};
visit(tree);
for (const [nodeKey, binding] of Object.entries(bindings)) {
if (binding.type !== "alias") continue;
const canonicalNode = nodes.get(binding.canonicalNodeKey);
const canonicalBinding = bindings[binding.canonicalNodeKey];
if (
canonicalNode?.kind !== binding.canonicalKind ||
canonicalBinding?.type !== "primitive" ||
canonicalBinding.source !== "existing"
) {
delete bindings[nodeKey];
unboundNodeKeys.push(nodeKey);
}
}
return { bindings, aliasTokensByNodeKey, unboundNodeKeys, syntheticWrapperCount };
}
@@ -0,0 +1,427 @@
import type { Style } from "@react-pdf/types";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { TemplateStyleSlots } from "../templates/shared/types";
import { describe, expect, it, vi } from "vitest";
import { createCanvas } from "@napi-rs/canvas";
import { pdf } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { Document, Page } from "#react-pdf-renderer";
import { RenderProvider } from "../context";
import { ResumeDocument } from "../document";
import { TemplateProvider } from "../templates/shared/context";
import { Text } from "../templates/shared/primitives";
import { SemanticTextRuns } from "../templates/shared/sections";
import { createBindingInventory } from "./binding-inventory";
import { getTemplateSemanticBindingRegistry } from "./template-manifest";
import { buildSemanticTree } from "./tree";
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const mergedStyle = (node: HostNode): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node.style) ? node.style : node.style ? [node.style] : []));
const findTexts = (node: HostNode, text: string): HostNode[] => [
...(node.type === "TEXT" && nodeText(node) === text ? [node] : []),
...(node.children ?? []).flatMap((child) => findTexts(child, text)),
];
const fixture = (mode: "legacy" | "semantic", section: "experience" | "education", rule = ""): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.email = "";
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "Cambridge",
area: "Mathematics",
degree: "BSc",
grade: "First",
location: "Cambridge",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
if (mode === "semantic") {
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
data.metadata.stylesheet = { mode, source: stylesheet, applied: stylesheet };
}
return data;
};
const renderHost = async (template: Template, data: ResumeData): Promise<HostNode> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return instance.container.document as HostNode;
};
const renderPdf = async (template: Template, data: ResumeData): Promise<Uint8Array> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<
typeof renderer.renderToBuffer
>[0];
return new Uint8Array(await renderer.renderToBuffer(element));
};
const rasterizeFirstPage = async (bytes: Uint8Array): Promise<Buffer> => {
const document = await getDocument({ data: bytes }).promise;
const page = await document.getPage(1);
const viewport = page.getViewport({ scale: 1.5 });
const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
const context = canvas.getContext("2d");
await page.render({
canvas: canvas as unknown as HTMLCanvasElement,
canvasContext: context as unknown as CanvasRenderingContext2D,
viewport,
}).promise;
return canvas.toBuffer("image/png");
};
type CombinedFieldRun = {
field: string;
value: string;
prefix?: string;
suffix?: string;
};
type CombinedFieldRasterCase = {
name: string;
runs: readonly CombinedFieldRun[];
separator: string;
style?: Style;
};
const preSplitCombinedText = ({ runs, separator, style }: CombinedFieldRasterCase) => {
const text = runs
.filter(({ value }) => value.trim().length > 0)
.map(({ value, prefix = "", suffix = "" }) => `${prefix}${value}${suffix}`)
.join(separator);
return (
<Text bindSemanticNode={false} {...(style === undefined ? {} : { style })}>
{text}
</Text>
);
};
const rasterFixtureStyles = {
text: {
fontFamily: "Helvetica",
fontSize: 11,
fontWeight: "400",
lineHeight: 1.25,
color: "#111111",
},
} satisfies TemplateStyleSlots;
const CombinedFieldRasterDocument = ({
testCase,
preSplit,
}: {
testCase: CombinedFieldRasterCase;
preSplit: boolean;
}) => {
const data = structuredClone(defaultResumeData);
return (
<Document>
<Page size={{ width: 320, height: 96 }} style={{ padding: 18 }}>
<RenderProvider data={data}>
<TemplateProvider
pageNodeKey="page-1"
styles={rasterFixtureStyles}
colors={{ foreground: "#111111", background: "#ffffff", primary: "#111111" }}
>
{preSplit ? (
preSplitCombinedText(testCase)
) : (
<SemanticTextRuns
host="education-degree-grade"
runs={testCase.runs}
separator={testCase.separator}
style={testCase.style}
/>
)}
</TemplateProvider>
</RenderProvider>
</Page>
</Document>
);
};
const renderCombinedFieldRaster = async (testCase: CombinedFieldRasterCase, preSplit: boolean): Promise<Buffer> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const element = createElement(CombinedFieldRasterDocument, {
testCase,
preSplit,
}) as unknown as Parameters<typeof renderer.renderToBuffer>[0];
const bytes = new Uint8Array(await renderer.renderToBuffer(element));
return rasterizeFirstPage(bytes);
};
const expectColor = (document: HostNode, text: string, color: string) => {
expect(findTexts(document, text).some((node) => mergedStyle(node).color === color)).toBe(true);
};
describe("combined PDF field bindings", () => {
it("binds each Onyx and Meowth combined Text identity once without widening field styles to its host", async () => {
const semanticRule = `
combined-text { color: #334455; font-size: 14pt; opacity: 0.6; margin-left: 3pt; }
field[name="degree"] { opacity: 0.4; }
`;
const onyxData = fixture("semantic", "education", semanticRule);
const meowthData = fixture("semantic", "education", semanticRule);
const onyxTree = buildSemanticTree({
data: onyxData,
template: "onyx",
page: onyxData.metadata.layout.pages[0] as NonNullable<(typeof onyxData.metadata.layout.pages)[number]>,
pageNumber: 1,
showHeader: true,
});
const meowthTree = buildSemanticTree({
data: meowthData,
template: "meowth",
page: meowthData.metadata.layout.pages[0] as NonNullable<(typeof meowthData.metadata.layout.pages)[number]>,
pageNumber: 1,
showHeader: true,
});
const combinedNodes = (root: Parameters<typeof createBindingInventory>[0]) => {
const matches: (typeof root)[] = [];
const visit = (node: typeof root) => {
if ((node.kind as string) === "combined-text") matches.push(node);
for (const child of node.children) visit(child);
};
visit(root);
return matches;
};
for (const [tree, template] of [
[onyxTree, "onyx"],
[meowthTree, "meowth"],
] as const) {
const inventory = createBindingInventory(tree, getTemplateSemanticBindingRegistry(template));
const combined = combinedNodes(tree);
expect(combined).not.toEqual([]);
expect(
combined.filter(({ key }) => inventory.bindings[key]?.type === "primitive").map(({ key }) => key),
).toHaveLength(new Set(combined.map(({ key }) => key)).size - (template === "meowth" ? 1 : 0));
const aliases = combined.flatMap(({ key }) => {
const binding = inventory.bindings[key];
return binding?.type === "alias" ? [{ key, binding }] : [];
});
if (template === "meowth") {
expect(aliases).toHaveLength(1);
expect(aliases[0]?.binding).toEqual({
type: "alias",
canonicalKind: "template-part",
canonicalNodeKey: aliases[0]?.key.replace(/\/combined-text-education-grade-location$/, ""),
token: "combined-text",
});
} else {
expect(aliases).toEqual([]);
}
expect(inventory.syntheticWrapperCount).toBe(0);
}
for (const [template, data, text] of [
["onyx", onyxData, "BSc • First"],
["meowth", meowthData, "Mathematics (BSc)"],
] as const) {
const document = await renderHost(template, data);
const outer = findTexts(document, text).map(mergedStyle);
expect(outer).toContainEqual(
expect.objectContaining({ color: "#334455", fontSize: 14, opacity: 0.6, marginLeft: 3 }),
);
}
});
it("splits all five combined variants into separately styleable existing Text runs", async () => {
const colors = `
field[name="position"] { color: #110000; }
field[name="location"] { color: #220000; }
field[name="area"] { color: #330000; }
field[name="degree"] { color: #440000; }
field[name="grade"] { color: #550000; }
field[name="period"] { color: #660000; }
`;
const experience = await renderHost("meowth", fixture("semantic", "experience", colors));
const inlineEducation = await renderHost("meowth", fixture("semantic", "education", colors));
const splitEducation = await renderHost("onyx", fixture("semantic", "education", colors));
expect(nodeText(experience)).toContain("Engineer (London)");
expectColor(experience, "Engineer", "#110000");
expectColor(experience, "(London)", "#220000");
expect(nodeText(inlineEducation)).toContain("Mathematics (BSc)");
expect(nodeText(inlineEducation)).toContain("First • Cambridge");
expectColor(inlineEducation, "Mathematics", "#330000");
expectColor(inlineEducation, "(BSc)", "#440000");
expectColor(inlineEducation, "First", "#550000");
expectColor(inlineEducation, "Cambridge", "#220000");
expect(nodeText(splitEducation)).toContain("BSc • First");
expect(nodeText(splitEducation)).toContain("Cambridge • 1835");
expectColor(splitEducation, "BSc", "#440000");
expectColor(splitEducation, "First", "#550000");
expectColor(splitEducation, "Cambridge", "#220000");
expectColor(splitEducation, "1835", "#660000");
});
it("uses the promoted single field's real semantic key", async () => {
const experienceData = fixture(
"semantic",
"experience",
'field[name="period"] { color: #660000; } field[name="location"] { color: #220000; }',
);
const experience = experienceData.sections.experience.items[0];
if (!experience) throw new Error("Expected experience fixture.");
experience.location = "";
const educationData = fixture(
"semantic",
"education",
'field[name="period"] { color: #660000; } field[name="degree"] { color: #440000; }',
);
const education = educationData.sections.education.items[0];
if (!education) throw new Error("Expected education fixture.");
education.degree = "";
education.grade = "";
education.location = "";
expectColor(await renderHost("onyx", experienceData), "1842", "#660000");
expectColor(await renderHost("onyx", educationData), "1835", "#660000");
});
it("keeps non-inheritable split-field styles on their final field hosts", async () => {
const document = await renderHost(
"meowth",
fixture(
"semantic",
"experience",
'field[name="position"] { opacity: 0.6; } field[name="location"] { margin-left: 3pt; }',
),
);
const combined = findTexts(document, "Engineer (London)");
const position = findTexts(document, "Engineer");
const location = findTexts(document, "(London)");
expect(combined.some((node) => mergedStyle(node).opacity === 0.6)).toBe(false);
expect(combined.some((node) => mergedStyle(node).marginLeft === 3)).toBe(false);
expect(position.map(mergedStyle)).toContainEqual(expect.objectContaining({ opacity: 0.6 }));
expect(location.map(mergedStyle)).toContainEqual(expect.objectContaining({ marginLeft: 3 }));
});
it("publishes one existing Text binding for every split field key", () => {
const data = fixture("semantic", "education");
const tree = buildSemanticTree({
data,
template: "meowth",
page: data.metadata.layout.pages[0] as NonNullable<(typeof data.metadata.layout.pages)[number]>,
pageNumber: 1,
showHeader: true,
});
const inventory = createBindingInventory(tree, getTemplateSemanticBindingRegistry("meowth"));
const fieldBindings = Object.entries(inventory.bindings).filter(([key]) =>
["area", "degree", "grade", "location", "period"].some((field) => key.endsWith(`/field-${field}`)),
);
expect(fieldBindings).toHaveLength(5);
expect(fieldBindings.map(([, binding]) => binding)).toEqual(
Array.from({ length: 5 }, () => ({ type: "primitive", primitive: "Text", source: "existing" })),
);
});
it.each([
["meowth", "experience"],
["meowth", "education"],
["onyx", "education"],
] as const)(
"keeps the real %s %s section raster-identical between legacy and empty semantic mode",
async (template, section) => {
const legacy = await rasterizeFirstPage(await renderPdf(template, fixture("legacy", section)));
const semantic = await rasterizeFirstPage(await renderPdf(template, fixture("semantic", section)));
expect(semantic.equals(legacy)).toBe(true);
},
);
it.each([
{
name: "Meowth experience position and location",
runs: [
{ field: "position", value: "Engineer" },
{ field: "location", value: "London", prefix: "(", suffix: ")" },
],
separator: " ",
},
{
name: "Meowth education area and degree",
runs: [
{ field: "area", value: "Mathematics" },
{ field: "degree", value: "BSc", prefix: "(", suffix: ")" },
],
separator: " ",
},
{
name: "Meowth education grade and location",
runs: [
{ field: "grade", value: "First" },
{ field: "location", value: "Cambridge" },
],
separator: " • ",
},
{
name: "Onyx education degree and grade",
runs: [
{ field: "degree", value: "BSc" },
{ field: "grade", value: "First" },
],
separator: " • ",
},
{
name: "Onyx education location and period",
runs: [
{ field: "location", value: "Cambridge" },
{ field: "period", value: "1835" },
],
separator: " • ",
style: { textAlign: "right" },
},
] satisfies CombinedFieldRasterCase[])(
"keeps the current split $name raster-identical to a test-only pre-split single Text",
async (testCase) => {
const preSplit = await renderCombinedFieldRaster(testCase, true);
const split = await renderCombinedFieldRaster(testCase, false);
expect(split.equals(preSplit)).toBe(true);
},
);
});
+198
View File
@@ -0,0 +1,198 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { StylesheetMode } from "@reactive-resume/schema/resume/stylesheet";
import type { ReactNode } from "react";
import type { ResolvedPdfNodePresentation } from "./adapter";
import { createContext, use, useMemo } from "react";
import { semanticNodeKeys } from "./node-keys";
export type ResolvedResumePresentation = Readonly<Record<string, ResolvedPdfNodePresentation>>;
type SemanticRenderProviderProps = {
presentation: ResolvedResumePresentation;
mode: StylesheetMode;
sourceTree: SemanticNode;
renderTree: SemanticNode;
children: ReactNode;
};
const EMPTY_NODE = Object.freeze({}) satisfies ResolvedPdfNodePresentation;
const SemanticRenderContext = createContext<{
presentation: ResolvedResumePresentation;
mode: StylesheetMode;
renderedNodeKeys: ReadonlySet<string>;
sourceNodeKeys: ReadonlySet<string>;
renderedChildKeys: ReadonlyMap<string, readonly string[]>;
sourceChildKeys: ReadonlyMap<string, readonly string[]>;
sourceNodes: readonly SemanticNode[];
renderOrder: ReadonlyMap<string, number>;
} | null>(null);
const SemanticNodeKeyContext = createContext<string | undefined>(undefined);
const flattenTree = (root: SemanticNode): SemanticNode[] => {
const nodes: SemanticNode[] = [];
const visit = (node: SemanticNode) => {
nodes.push(node);
for (const child of node.children) visit(child);
};
visit(root);
return nodes;
};
const indexRenderTree = (root: SemanticNode) => {
const keys = new Set<string>();
const childKeys = new Map<string, readonly string[]>();
const order = new Map<string, number>();
const visit = (node: SemanticNode) => {
keys.add(node.key);
order.set(node.key, order.size);
childKeys.set(
node.key,
node.children.map(({ key }) => key),
);
for (const child of node.children) visit(child);
};
visit(root);
return {
renderedNodeKeys: keys as ReadonlySet<string>,
renderedChildKeys: childKeys as ReadonlyMap<string, readonly string[]>,
renderOrder: order as ReadonlyMap<string, number>,
};
};
export function SemanticRenderProvider({
presentation,
mode,
sourceTree,
renderTree,
children,
}: SemanticRenderProviderProps) {
const treeIndex = useMemo(() => indexRenderTree(renderTree), [renderTree]);
const sourceNodes = useMemo(() => flattenTree(sourceTree), [sourceTree]);
const sourceNodeKeys = useMemo(
() => new Set(sourceNodes.map(({ key }) => key)) as ReadonlySet<string>,
[sourceNodes],
);
const sourceChildKeys = useMemo(
() =>
new Map(
sourceNodes.map(({ key, children: sourceChildren }) => [key, sourceChildren.map(({ key }) => key)]),
) as ReadonlyMap<string, readonly string[]>,
[sourceNodes],
);
const value = useMemo(
() => ({ presentation, mode, sourceNodeKeys, sourceChildKeys, sourceNodes, ...treeIndex }),
[mode, presentation, sourceChildKeys, sourceNodeKeys, sourceNodes, treeIndex],
);
return <SemanticRenderContext.Provider value={value}>{children}</SemanticRenderContext.Provider>;
}
export function useResolvedNode(nodeKey: string | undefined): ResolvedPdfNodePresentation {
const context = use(SemanticRenderContext);
if (!context || !nodeKey) return EMPTY_NODE;
return context.presentation[nodeKey] ?? EMPTY_NODE;
}
export const useSemanticRenderMode = (): StylesheetMode => use(SemanticRenderContext)?.mode ?? "legacy";
export const useSemanticNodeVisible = (nodeKey: string | undefined): boolean => {
const context = use(SemanticRenderContext);
if (context?.mode !== "semantic" || !nodeKey) return true;
return context.renderedNodeKeys.has(nodeKey);
};
export const useSemanticNodeExists = (nodeKey: string | undefined): boolean => {
const context = use(SemanticRenderContext);
return Boolean(nodeKey && context?.sourceNodeKeys.has(nodeKey));
};
export const useRenderedChildKeys = (nodeKey: string | undefined): readonly string[] | undefined => {
const context = use(SemanticRenderContext);
if (context?.mode !== "semantic" || !nodeKey) return undefined;
const rendered = context.renderedChildKeys.get(nodeKey) ?? [];
const source = context.sourceChildKeys.get(nodeKey) ?? [];
return rendered.length === source.length && rendered.every((key, index) => key === source[index])
? undefined
: rendered;
};
export type RenderedChildEntry<T> = {
nodeKey: string;
value: T;
};
export const projectRenderedChildren = <T,>(
renderedChildKeys: readonly string[] | undefined,
entries: readonly RenderedChildEntry<T>[],
): T[] => {
if (!renderedChildKeys) return entries.map(({ value }) => value);
const valuesByNodeKey = new Map(entries.map(({ nodeKey, value }) => [nodeKey, value]));
return renderedChildKeys.flatMap((nodeKey) => {
const value = valuesByNodeKey.get(nodeKey);
return value === undefined ? [] : [value];
});
};
export const useSemanticSectionNodeKey = (pageNodeKey: string, sectionId: string): string => {
const context = use(SemanticRenderContext);
const section = context?.sourceNodes.find(
(node) => node.kind === "section" && node.id === sectionId && node.key.startsWith(`${pageNodeKey}/`),
);
return section?.key ?? semanticNodeKeys.section(semanticNodeKeys.region(pageNodeKey, "main"), sectionId);
};
export const useRenderedSectionIds = (pageNodeKey: string, authoredIds: readonly string[]): string[] => {
const context = use(SemanticRenderContext);
if (context?.mode !== "semantic") return [...authoredIds];
const authored = new Set(authoredIds);
return context.sourceNodes
.filter(
(node) =>
node.kind === "section" &&
node.id !== undefined &&
authored.has(node.id) &&
node.key.startsWith(`${pageNodeKey}/`) &&
context.renderedNodeKeys.has(node.key),
)
.sort(
(left, right) =>
(context.renderOrder.get(left.key) ?? Number.MAX_SAFE_INTEGER) -
(context.renderOrder.get(right.key) ?? Number.MAX_SAFE_INTEGER),
)
.flatMap(({ id }) => (id ? [id] : []));
};
export const useSemanticNodeBindings = () => {
const context = use(SemanticRenderContext);
return {
resolveNode: (nodeKey: string | undefined): ResolvedPdfNodePresentation => {
if (!context || !nodeKey) return EMPTY_NODE;
return context.presentation[nodeKey] ?? EMPTY_NODE;
},
isNodeVisible: (nodeKey: string | undefined): boolean => {
if (context?.mode !== "semantic" || !nodeKey) return true;
return context.renderedNodeKeys.has(nodeKey);
},
renderedChildKeysFor: (nodeKey: string | undefined): readonly string[] | undefined => {
if (context?.mode !== "semantic" || !nodeKey) return undefined;
const rendered = context.renderedChildKeys.get(nodeKey) ?? [];
const source = context.sourceChildKeys.get(nodeKey) ?? [];
return rendered.length === source.length && rendered.every((key, index) => key === source[index])
? undefined
: rendered;
},
};
};
export function SemanticNodeKeyProvider({ nodeKey, children }: { nodeKey: string | undefined; children: ReactNode }) {
return <SemanticNodeKeyContext.Provider value={nodeKey}>{children}</SemanticNodeKeyContext.Provider>;
}
export const useSemanticNodeKey = (): string | undefined => use(SemanticNodeKeyContext);
export function SemanticItemNodeKeyProvider({ itemId, children }: { itemId: string; children: ReactNode }) {
const parentNodeKey = useSemanticNodeKey();
const nodeKey = parentNodeKey ? semanticNodeKeys.item(parentNodeKey, itemId) : undefined;
return <SemanticNodeKeyProvider nodeKey={nodeKey}>{children}</SemanticNodeKeyProvider>;
}
@@ -0,0 +1,43 @@
import { describe, expect, it } from "vitest";
import { STANDARD_FIELD_REGISTRY, STANDARD_ROLE_REGISTRY } from "./binding-inventory";
const expectedFields = {
summary: ["content"],
profiles: ["network", "username"],
experience: ["company", "position", "location", "period", "description"],
"experience-role": ["position", "period", "description"],
education: ["school", "area", "degree", "grade", "location", "period", "description"],
projects: ["name", "period", "description"],
skills: ["name", "proficiency", "keywords"],
languages: ["language", "fluency"],
interests: ["name", "keywords"],
awards: ["title", "date", "awarder", "description"],
certifications: ["title", "date", "issuer", "description"],
publications: ["title", "date", "publisher", "description"],
volunteer: ["organization", "location", "period", "description"],
references: ["name", "position", "phone", "description"],
"cover-letter": ["recipient", "content"],
};
describe("standard semantic fields", () => {
it("registers the complete rendered field matrix and only supported role tokens", () => {
expect(
Object.fromEntries(Object.entries(STANDARD_FIELD_REGISTRY).map(([type, fields]) => [type, Object.keys(fields)])),
).toEqual(expectedFields);
expect(STANDARD_FIELD_REGISTRY.experience.company).toEqual(["primary-text"]);
expect(STANDARD_FIELD_REGISTRY.experience.description).toEqual([]);
expect(STANDARD_FIELD_REGISTRY.profiles.username).toEqual(["secondary-text", "structured-link"]);
expect(STANDARD_ROLE_REGISTRY).toEqual([
"primary-text",
"secondary-text",
"structured-link",
"decoration",
"section-title",
"picture",
"experience-role",
"nested-role",
"active",
"inactive",
]);
});
});
+47
View File
@@ -0,0 +1,47 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { StylesheetMode, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Template } from "@reactive-resume/schema/templates";
import type { ResolvedResumeRuntime } from "./resolve";
import { resolveResumeRuntime, resolveStylesheetMode } from "./resolve";
export type InspectResumePdfOptions = {
data: ResumeData;
template?: Template | undefined;
applied?: StylesheetSource | undefined;
mode?: StylesheetMode | undefined;
};
export type ResumePdfRenderResult<T> =
| { ok: true; value: T; diagnostics: ResolvedResumeRuntime["diagnostics"] }
| { ok: false; diagnostics: ResolvedResumeRuntime["diagnostics"] };
export const inspectResumePdf = ({
data,
template = data.metadata.template,
applied,
mode = resolveStylesheetMode(data),
}: InspectResumePdfOptions): ResolvedResumeRuntime =>
resolveResumeRuntime({
data,
template,
mode,
...(applied ? { applied } : {}),
});
export const hasSemanticErrors = ({ diagnostics }: Pick<ResolvedResumeRuntime, "diagnostics">): boolean =>
diagnostics.some(({ severity }) => severity === "error");
export type {
ResolvedResumeRuntime,
ResolveResumePresentationInput,
} from "./resolve";
export * from "./legacy-converter";
export * from "./legacy-parity";
export * from "./preflight-core";
export * from "./public-projection";
export {
resolveResumePresentation,
resolveResumeRuntime,
resolveStylesheetMode,
} from "./resolve";
export * from "./tree";
@@ -0,0 +1,270 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { semanticNodeKeys } from "./node-keys";
import { resolveResumePresentation, resolveStylesheetMode } from "./resolve";
const applied = (text: string) => ({ languageVersion: 1, text });
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const findText = (node: HostNode, text: string): HostNode | undefined => {
if (node.type === "TEXT" && nodeText(node) === text) return node;
for (const child of node.children ?? []) {
const match = findText(child, text);
if (match) return match;
}
};
const findPrimitive = (node: HostNode, type: string, text: string): HostNode | undefined => {
if (node.type === type && nodeText(node) === text) return node;
for (const child of node.children ?? []) {
const match = findPrimitive(child, type, text);
if (match) return match;
}
};
const mergedStyle = (node: HostNode | undefined): Record<string, unknown> =>
Object.assign({}, ...(Array.isArray(node?.style) ? node.style : node?.style ? [node.style] : []));
const containsStyle = (node: HostNode, property: string, value: unknown): boolean =>
mergedStyle(node)[property] === value || (node.children ?? []).some((child) => containsStyle(child, property, value));
const textRuns = (node: HostNode): string[] => [
...(node.type === "TEXT" ? [nodeText(node)] : []),
...(node.children ?? []).flatMap((child) => textRuns(child)),
];
const buildIssueFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
data.basics = {
...data.basics,
name: "Ada Lovelace",
email: "ada@example.com",
};
data.sections.experience.items = [
{
id: "item-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.sections.skills.items = [
{
id: "item-1",
hidden: false,
icon: "",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 3,
keywords: [],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience", "skills"], sidebar: [] }];
return data;
};
const resolveIssueFixture = (text: string) => {
const data = buildIssueFixture();
return resolveResumePresentation({
data,
template: "onyx",
applied: applied(text),
mode: "semantic",
});
};
const pageKey = semanticNodeKeys.page(1);
const headerKey = semanticNodeKeys.header(semanticNodeKeys.region(pageKey, "header"));
const sectionItemKey = (section: string) =>
semanticNodeKeys.item(
semanticNodeKeys.sectionItems(semanticNodeKeys.section(semanticNodeKeys.region(pageKey, "main"), section)),
"item-1",
);
describe("semantic issue fixtures", () => {
it("lets semantic field font weight override template bold defaults (#3146)", () => {
const presentation = resolveIssueFixture(`
@version 1;
section[type="experience"] field[name="company"] { font-weight: 400; }
`);
const company = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("experience")), "company");
expect(presentation[company]?.style?.fontWeight).toBe("400");
});
it("lets semantic link decoration override the builder underline base (#3134)", () => {
const presentation = resolveIssueFixture(`
@version 1;
link { text-decoration: none; }
`);
const contact = semanticNodeKeys.contactItem(semanticNodeKeys.contactList(headerKey), "email");
const link = semanticNodeKeys.link(contact, "contact");
expect(presentation[link]?.style?.textDecoration).toBe("none");
expect(presentation[contact]?.style?.textDecoration).toBe("none");
});
it("cascades contact-link and field-rich-text aliases before selecting winners", () => {
const data = buildIssueFixture();
const experience = data.sections.experience.items[0];
if (!experience) throw new Error("Expected experience fixture.");
experience.description = "<p>Description</p>";
const contact = semanticNodeKeys.contactItem(semanticNodeKeys.contactList(headerKey), "email");
const field = semanticNodeKeys.field(sectionItemKey("experience"), "description");
const resolve = (text: string) =>
resolveResumePresentation({
data,
template: "onyx",
applied: applied(`@version 1;${text}`),
mode: "semantic",
});
expect(resolve("contact-item { color: red; } link { color: blue; }")[contact]?.style?.color).toBe("blue");
expect(resolve("link { color: blue; } contact-item { color: red; }")[contact]?.style?.color).toBe("red");
expect(resolve("field { color: red; } rich-text { color: blue; }")[field]?.style?.color).toBe("blue");
expect(resolve("rich-text { color: blue; } field { color: red; }")[field]?.style?.color).toBe("red");
expect(resolve("link { color: blue; } contact-item[name='email'] { color: red; }")[contact]?.style?.color).toBe(
"red",
);
});
it("styles Basics/header nodes while rejecting unsupported gradients (#3137)", () => {
const valid = resolveIssueFixture(`
@version 1;
header { background-color: #1e293b; }
name { color: white; }
`);
const invalid = compileStylesheet(applied("@version 1; header { background-image: linear-gradient(red, blue); }"));
expect(valid[headerKey]?.style?.backgroundColor).toBe("#1e293b");
expect(valid[semanticNodeKeys.headerPart(headerKey, "name")]?.style?.color).toBe("white");
expect(invalid.program).toBeNull();
});
it("unbolds only skill names and leaves experience titles unchanged (#2223)", () => {
const presentation = resolveIssueFixture(`
@version 1;
section[type="skills"] field[name="name"] { font-weight: 400; }
`);
const skillName = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("skills")), "name");
const company = semanticNodeKeys.field(semanticNodeKeys.itemHeader(sectionItemKey("experience")), "company");
expect(presentation[skillName]?.style?.fontWeight).toBe("400");
expect(presentation[company]?.style?.fontWeight).not.toBe("400");
});
it("never applies legacy and semantic custom styles together", () => {
const semanticData = buildIssueFixture();
semanticData.metadata.stylesheet = {
mode: "semantic",
source: applied("@version 1;"),
applied: applied("@version 1;"),
};
const legacyData = buildIssueFixture();
expect(resolveStylesheetMode(semanticData)).toBe("semantic");
expect(resolveStylesheetMode(legacyData)).toBe("legacy");
expect(
resolveResumePresentation({
data: legacyData,
template: "onyx",
applied: applied("@version 1; name { color: red; }"),
mode: "legacy",
}),
).toEqual({});
});
it("applies issue-regression styles to the final existing PDF primitives", async () => {
const data = buildIssueFixture();
const stylesheet = applied(`
@version 1;
header { background-color: #1e293b; }
name { color: white; }
link { text-decoration: none; }
section[type="experience"] field[name="company"] { font-weight: 400; }
section[type="skills"] field[name="name"] { font-weight: 400; }
level icon[role~="active"] { opacity: 0.2; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(mergedStyle(findText(document, "Ada Lovelace"))).toMatchObject({ color: "white" });
expect(mergedStyle(findText(document, "Analytical Engines"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findText(document, "TypeScript"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findPrimitive(document, "LINK", "ada@example.com"))).toMatchObject({
textDecoration: "none",
});
expect(containsStyle(document, "backgroundColor", "#1e293b")).toBe(true);
expect(containsStyle(document, "opacity", 0.2)).toBe(true);
});
it("unbolds only the final skill-name primitive and preserves the experience title weight (#2223)", async () => {
const data = buildIssueFixture();
const stylesheet = applied(`
@version 1;
section[type="skills"] field[name="name"] { font-weight: 400; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const document = instance.container.document as HostNode;
expect(mergedStyle(findText(document, "TypeScript"))).toMatchObject({ fontWeight: "400" });
expect(mergedStyle(findText(document, "Analytical Engines")).fontWeight).not.toBe("400");
});
it("renders descriptor filtering and stable item order instead of remapping raw arrays", async () => {
const data = buildIssueFixture();
data.sections.skills.items = ["First", "Hidden", "Last"].map((name, index) => ({
id: `item-${index + 1}`,
hidden: false,
icon: "",
iconColor: "",
name,
proficiency: `${name} proficiency`,
level: 0,
keywords: [],
}));
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
const stylesheet = applied(`
@version 1;
section[type="skills"] item:nth-child(2) { display: none; }
section[type="skills"] item:last-child { order: -1; }
`);
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const runs = textRuns(instance.container.document as HostNode);
expect(runs.indexOf("Last")).toBeLessThan(runs.indexOf("First"));
expect(runs).not.toContain("Hidden");
});
});
@@ -0,0 +1,118 @@
import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { convertLegacyStyleRules } from "./legacy-converter";
const fixtureUrl = (name: string) => new URL(`./__fixtures__/legacy/${name}`, import.meta.url);
const readFixture = (name: string): unknown => JSON.parse(readFileSync(fixtureUrl(`${name}.json`), "utf8"));
const readExpected = (name: string): string => readFileSync(fixtureUrl(`${name}.expected.css`), "utf8");
const dataWithRules = (name: string): ResumeData => {
const data = structuredClone(defaultResumeData);
data.metadata.styleRules = readFixture(name) as StyleRule[];
return data;
};
describe("convertLegacyStyleRules", () => {
it("preserves legacy specificity and source-order ties deterministically", () => {
const converted = convertLegacyStyleRules(dataWithRules("merge-specificity"));
const tie = convertLegacyStyleRules(dataWithRules("array-order-tie"));
expect(converted.source).toEqual({
languageVersion: 1,
text: readExpected("merge-specificity"),
});
expect(converted.sanitizedRules.map(({ id }) => id)).toEqual(["id", "type", "global"]);
expect(tie.source.text.indexOf("/* First */")).toBeLessThan(tie.source.text.indexOf("/* Second */"));
expect(compileStylesheet(converted.source).program).not.toBeNull();
});
it("sanitizes #3199 intent data and applies the existing PDF clamps", () => {
const sanitized = convertLegacyStyleRules(dataWithRules("sanitized-intent-3199"));
const clamped = convertLegacyStyleRules(dataWithRules("clamped-spacing"));
expect(sanitized.sanitizedRules).toEqual(styleRulesSchema.parse(readFixture("sanitized-intent-3199")));
expect(sanitized.source.text).toContain("color: #123456;");
expect(sanitized.source.text).not.toMatch(/huge|unknown-property|unknown-slot|#ffffff/i);
expect(clamped.source.text).toContain("border-width: 24pt;");
expect(clamped.source.text).toContain("border-radius: 72pt;");
});
it("comments disabled and final-host no-effect declarations without inventing @resume-disabled", () => {
const disabled = convertLegacyStyleRules(dataWithRules("disabled-rules")).source.text;
const links = convertLegacyStyleRules(dataWithRules("link-underline-3134")).source.text;
expect(disabled).toContain("Disabled legacy rule");
expect(disabled).toContain("Disabled *\\/ cannot escape");
expect(disabled).not.toContain("@resume-disabled");
expect(links).toContain("color: #2255aa;");
expect(links).toContain("No effect in legacy rendering: text-decoration");
expect(links).not.toMatch(/^[^/]*\btext-decoration:/m);
});
it("maps every rich-text slot to its real semantic host", () => {
const source = convertLegacyStyleRules(dataWithRules("rich-text-all-slots")).source.text;
expect(source).toContain("rich-text paragraph");
expect(source).toContain("rich-text list {");
expect(source).toContain("rich-text list-item {");
expect(source).toContain("rich-text list-item-content {");
expect(source).toContain("rich-text link {");
expect(source).toContain("rich-text strong {");
expect(source).toContain("rich-text mark {");
expect(compileStylesheet({ languageVersion: 1, text: source }).program).not.toBeNull();
});
it("preserves Bold-after-text #3146 while retaining the award unbold exception", () => {
const primary = convertLegacyStyleRules(dataWithRules("primary-text-bold-3146")).source.text;
const award = convertLegacyStyleRules(dataWithRules("award-unbold")).source.text;
expect(primary).toContain('[role~="primary-text"]');
expect(primary).not.toContain('section[type="awards"] field[name="title"]');
expect(primary).toContain("font-weight: 400;");
expect(primary).toContain('resume[template="scizor"] section[type="experience"]');
expect(primary).toContain("Scizor Bold final color");
expect(award).toContain('section[type="awards"] field[name="title"]');
});
it("applies legacy text weight to a nested role position without overriding real Bold hosts", () => {
const source = convertLegacyStyleRules(dataWithRules("primary-text-bold-3146")).source.text;
expect(source).toContain('item[role~="nested-role"] > item-header > field[name="position"]');
expect(source).not.toContain('field[role~="nested-role"]');
});
it("emits legacy text declarations for the combined outer Text without widening field selectors", () => {
const source = convertLegacyStyleRules(dataWithRules("combined-text-host")).source.text;
expect(source).toContain("combined-text");
expect(source).toContain("font-size: 14pt;");
expect(source).toContain("opacity: 0.65;");
expect(source).toContain("padding-left: 2pt;");
expect(source).toContain('field:not([name="content"])');
});
it("translates icon and level font sizes to explicit geometry", () => {
const source = convertLegacyStyleRules(dataWithRules("icon-level-size")).source.text;
expect(source).toMatch(/icon \{[\s\S]*height: 18pt;[\s\S]*width: 18pt;/);
expect(source).toMatch(/level \{[\s\S]*font-size: 14pt;/);
expect(source).toMatch(/level > icon \{[\s\S]*height: 14pt;[\s\S]*width: 14pt;/);
});
it("uses portable custom-section types and quoted UUID section IDs", () => {
const custom = convertLegacyStyleRules(dataWithRules("custom-section-type")).source.text;
const uuid = convertLegacyStyleRules(dataWithRules("section-id-uuid")).source.text;
expect(custom).toContain('section[type="experience"] > section-items > item');
expect(uuid).toContain('section[id="1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37"] > section-heading');
});
it("compiles the all-template portable smoke fixture", () => {
const result = convertLegacyStyleRules(dataWithRules("all-templates-smoke"));
expect(compileStylesheet(result.source).diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
});
});
@@ -0,0 +1,266 @@
import type { Style } from "@react-pdf/types";
import type { ResumeData, StyleIntent, StyleRule, StyleSlot } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "@reactive-resume/resume/stylesheet";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { getSectionStyleRuleContext } from "@reactive-resume/schema/resume/style-rules";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { resolveStyleRuleSlot } from "../templates/shared/style-rules";
export type LegacyStyleConversion = {
source: StylesheetSource;
sanitizedRules: readonly StyleRule[];
};
const styleSlots = [
"section",
"heading",
"item",
"text",
"secondaryText",
"link",
"icon",
"level",
"richParagraph",
"richList",
"richListItemRow",
"richListItemContent",
"richLink",
"richBold",
"richMark",
] as const satisfies readonly StyleSlot[];
const lengthProperties = new Set<keyof StyleIntent>([
"fontSize",
"letterSpacing",
"padding",
"paddingTop",
"paddingRight",
"paddingBottom",
"paddingLeft",
"marginTop",
"marginRight",
"marginBottom",
"marginLeft",
"rowGap",
"columnGap",
"borderWidth",
"borderRadius",
]);
const slotSelectors = {
section: "",
heading: " > section-heading",
item: " > section-items > item",
text: ' field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"])',
secondaryText: ' field[name="keywords"]',
link: " item > link",
icon: " icon",
level: " level",
richParagraph: " rich-text paragraph",
richList: " rich-text list",
richListItemRow: " rich-text list-item",
richListItemContent: " rich-text list-item-content",
richLink: " rich-text link",
richBold: " rich-text strong",
richMark: " rich-text mark",
} as const satisfies Record<StyleSlot, string>;
const selectorForSlot = (base: string, slot: StyleSlot): string => {
if (slot === "link") {
return [
`${base} item > link`,
`${base} item > template-part > link`,
`${base} item-header > link`,
`${base} item-header > template-part > link`,
].join(",\n");
}
return `${base}${slotSelectors[slot]}`;
};
const ruleBaseSelector = (rule: StyleRule): string => {
if (rule.target.scope === "sectionType") return `section[type=${escapeCssString(rule.target.sectionType)}]`;
if (rule.target.scope === "sectionId") return `section[id=${escapeCssString(rule.target.sectionId)}]`;
return "section";
};
const resolvedRuleStyle = (data: ResumeData, rule: StyleRule, slot: StyleSlot): Style => {
const isolated = {
...data,
metadata: { ...data.metadata, styleRules: [rule] },
};
const options =
rule.target.scope === "sectionType"
? { slot, sectionId: "", sectionType: rule.target.sectionType }
: rule.target.scope === "sectionId"
? { slot, ...getSectionStyleRuleContext(data, rule.target.sectionId) }
: { slot, sectionId: "" };
return resolveStyleRuleSlot(isolated, options);
};
const declarationsFromStyle = (style: Style): Record<string, string | number> =>
Object.fromEntries(
Object.entries(style).flatMap(([property, value]) => {
if (value === undefined) return [];
const converted =
typeof value === "number" && lengthProperties.has(property as keyof StyleIntent) ? `${value}pt` : value;
return [[property, converted]];
}),
);
const serializeBlock = (
selector: string,
declarations: Readonly<Record<string, string | number>>,
comment?: string,
): string =>
serializeGeneratedStylesheet({
languageVersion: 1,
blocks: [{ selector, declarations, ...(comment === undefined ? {} : { comment }) }],
})
.replace(/^@version 1;\n\n/, "")
.trimEnd();
const appliesToAwards = (data: ResumeData, rule: StyleRule): boolean => {
if (rule.target.scope === "global") return true;
if (rule.target.scope === "sectionType") return rule.target.sectionType === "awards";
return getSectionStyleRuleContext(data, rule.target.sectionId).sectionType === "awards";
};
const textWeightSelector = (data: ResumeData, rule: StyleRule, base: string): string => {
const selectors = [
`${base} field:not([name="content"]):not([name="description"]):not([name="recipient"]):not([name="keywords"]):not([role~="primary-text"])`,
`${base} item[role~="nested-role"] > item-header > field[name="position"]`,
];
if (appliesToAwards(data, rule)) selectors.push(`${base} field[name="title"]`);
return selectors.join(",\n");
};
const scizorBoldColorSelector = (data: ResumeData, rule: StyleRule, base: string): string | undefined => {
const sectionType =
rule.target.scope === "sectionType"
? rule.target.sectionType
: rule.target.scope === "sectionId"
? getSectionStyleRuleContext(data, rule.target.sectionId).sectionType
: undefined;
if (sectionType === "awards") return;
const scopedBase =
rule.target.scope === "global"
? 'resume[template="scizor"] section:not([type="awards"])'
: `resume[template="scizor"] ${base}`;
return ["company", "school", "name", "language", "network", "organization", "title"]
.map((name) => `${scopedBase}${slotSelectors.text}[role~="primary-text"][name=${escapeCssString(name)}]`)
.join(",\n");
};
const levelDecorationDeclarations = (
data: ResumeData,
fontSize: string | number | undefined,
): Record<string, string | number> => {
if (fontSize === undefined) return {};
const type = data.metadata.design.level.type;
if (type === "progress-bar" || type === "rectangle" || type === "rectangle-full") return { height: fontSize };
return { width: fontSize, height: fontSize };
};
const activeSlotChunks = (data: ResumeData, rule: StyleRule, slot: StyleSlot): string[] => {
const base = ruleBaseSelector(rule);
const selector = selectorForSlot(base, slot);
const declarations = declarationsFromStyle(resolvedRuleStyle(data, rule, slot));
const combinedTextDeclarations = slot === "text" ? { ...declarations } : undefined;
const chunks: string[] = [];
const comments: string[] = [];
if ((slot === "link" || slot === "richLink") && "textDecoration" in declarations) {
delete declarations.textDecoration;
comments.push("No effect in legacy rendering: text-decoration is owned by the builder link preference.");
}
if (slot === "text" && "fontWeight" in declarations) {
const { fontWeight } = declarations;
delete declarations.fontWeight;
if (Object.keys(declarations).length > 0)
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
chunks.push(serializeBlock(textWeightSelector(data, rule, base), { fontWeight }, rule.label || rule.id));
comments.push(
"Legacy Bold hosts keep the template bold weight; award titles remain the explicit unbold exception.",
);
} else {
if (slot === "icon" && declarations.fontSize !== undefined) {
declarations.width = declarations.fontSize;
declarations.height = declarations.fontSize;
}
if (Object.keys(declarations).length > 0)
chunks.push(serializeBlock(selector, declarations, rule.label || rule.id));
}
if (combinedTextDeclarations && Object.keys(combinedTextDeclarations).length > 0) {
chunks.push(
serializeBlock(`${base} combined-text`, combinedTextDeclarations, `${rule.label || rule.id}: combined text host`),
);
}
if (slot === "text" && declarations.color !== undefined) {
const restorationSelector = scizorBoldColorSelector(data, rule, base);
if (restorationSelector) {
chunks.push(
serializeBlock(
restorationSelector,
{ color: rgbaStringToHex(data.metadata.design.colors.text) },
`${rule.label || rule.id}: Scizor Bold final color`,
),
);
}
}
if (slot === "level" && declarations.fontSize !== undefined) {
const geometry = levelDecorationDeclarations(data, declarations.fontSize);
if (Object.keys(geometry).length > 0) {
chunks.push(serializeBlock(`${base} level > icon`, geometry, `${rule.label || rule.id}: level decorations`));
}
}
return [...chunks, ...comments.map((comment) => `/* ${escapeCssComment(comment)} */`)];
};
const disabledRuleChunk = (data: ResumeData, rule: StyleRule): string => {
const blocks = styleSlots.flatMap((slot) => {
if (!rule.slots[slot]) return [];
const declarations = declarationsFromStyle(resolvedRuleStyle(data, { ...rule, enabled: true }, slot));
if (Object.keys(declarations).length === 0) return [];
const base = ruleBaseSelector(rule);
return [
serializeBlock(selectorForSlot(base, slot), declarations),
...(slot === "text" ? [serializeBlock(`${base} combined-text`, declarations)] : []),
];
});
const label = escapeCssComment(rule.label || rule.id);
const body = escapeCssComment(blocks.join("\n\n"));
return `/* Disabled legacy rule: ${label}${body ? `\n${body}` : ""}\n*/`;
};
export function convertLegacyStyleRules(data: ResumeData): LegacyStyleConversion {
const sanitizedRules = styleRulesSchema.parse(data.metadata.styleRules ?? []);
const sanitizedData = {
...data,
metadata: { ...data.metadata, styleRules: sanitizedRules },
};
const scopePrecedence = { global: 0, sectionType: 1, sectionId: 2 } as const;
const orderedRules = sanitizedRules
.map((rule, index) => ({ rule, index }))
.sort(
(left, right) =>
scopePrecedence[left.rule.target.scope] - scopePrecedence[right.rule.target.scope] || left.index - right.index,
)
.map(({ rule }) => rule);
const chunks = orderedRules.flatMap((rule) => {
if (!rule.enabled) return [disabledRuleChunk(sanitizedData, rule)];
return styleSlots.flatMap((slot) => (rule.slots[slot] ? activeSlotChunks(sanitizedData, rule, slot) : []));
});
const text = `@version 1;\n${chunks.length > 0 ? `\n${chunks.join("\n\n")}\n` : ""}`;
return {
source: { languageVersion: 1, text },
sanitizedRules,
};
}
@@ -0,0 +1,318 @@
import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { LegacyParityHostNode } from "./legacy-parity";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { convertLegacyStyleRules } from "./legacy-converter";
import { compareLegacyParityHostNodes, compareLegacySemanticPresentation } from "./legacy-parity";
const fixtureNames = [
"all-templates-smoke",
"array-order-tie",
"award-unbold",
"clamped-spacing",
"combined-text-host",
"custom-section-type",
"disabled-rules",
"icon-level-size",
"link-underline-3134",
"merge-specificity",
"primary-text-bold-3146",
"rich-text-all-slots",
"sanitized-intent-3199",
"section-id-uuid",
] as const;
const templates = [
"azurill",
"bronzor",
"chikorita",
"ditgar",
"ditto",
"gengar",
"glalie",
"kakuna",
"lapras",
"leafish",
"meowth",
"onyx",
"pikachu",
"rhyhorn",
"scizor",
] as const satisfies readonly Template[];
const readRules = (name: string): StyleRule[] =>
styleRulesSchema.parse(
JSON.parse(readFileSync(new URL(`./__fixtures__/legacy/${name}.json`, import.meta.url), "utf8")),
);
const buildFixture = (rules: StyleRule[]): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics = {
name: "Ada Lovelace",
headline: "Engineer",
email: "ada@example.com",
phone: "",
location: "London",
website: { url: "", label: "" },
customFields: [],
};
data.summary.hidden = false;
data.summary.content =
'<p>Paragraph <strong>bold</strong> <mark>mark</mark> <a href="https://example.com">link</a></p><ul><li>List item</li></ul>';
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "https://example.com/work", label: "Work", inlineLink: true },
description: "<p>Built engines.</p>",
roles: [
{
id: "role-1",
position: "Senior Engineer",
period: "1843",
description: "<p>Led the engine team.</p>",
},
],
},
];
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
degree: "BSc",
area: "Mathematics",
grade: "First",
location: "London",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "<p>Studied analytical engines.</p>",
},
];
data.sections.skills.items = [
{
id: "skill-1",
hidden: false,
icon: "code",
iconColor: "",
name: "Mathematics",
proficiency: "Expert",
level: 3,
keywords: ["Analysis"],
},
];
data.sections.awards.items = [
{
id: "award-1",
hidden: false,
title: "Prize",
awarder: "Society",
date: "1843",
website: { url: "", label: "", inlineLink: false },
description: "<p>First programmer.</p>",
},
];
data.customSections = [
{
id: "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37",
type: "experience",
title: "Consulting",
icon: "briefcase",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: "custom-experience-1",
hidden: false,
company: "Difference Engines",
position: "Consultant",
location: "London",
period: "1844",
website: { url: "", label: "", inlineLink: false },
description: "<p>Advised builders.</p>",
roles: [],
},
],
},
];
data.metadata.layout.pages = [
{
fullWidth: true,
main: ["summary", "experience", "education", "skills", "awards", "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37"],
sidebar: [],
},
];
data.metadata.styleRules = rules;
return data;
};
describe("compareLegacySemanticPresentation", () => {
it("renders the mandatory target shapes in the shared parity document", () => {
const data = buildFixture([]);
expect(data.sections.experience.items[0]?.roles[0]?.position).toBe("Senior Engineer");
expect(data.customSections).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37",
type: "experience",
}),
]),
);
expect(data.metadata.layout.pages[0]?.main).toContain("1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37");
});
it.each(fixtureNames)("matches final primitive props for %s", async (fixture) => {
const data = buildFixture(readRules(fixture));
const conversion = convertLegacyStyleRules(data);
const comparison = await compareLegacySemanticPresentation({
data,
convertedSource: conversion.source,
templates: ["onyx"],
});
expect(comparison.pageCountMismatches).toEqual([]);
expect(comparison.primitivePropMismatches).toEqual([]);
expect(comparison.mismatches).toEqual([]);
});
it("matches final primitive props on every template", async () => {
const data = buildFixture(readRules("all-templates-smoke"));
const conversion = convertLegacyStyleRules(data);
const comparison = await compareLegacySemanticPresentation({
data,
convertedSource: conversion.source,
templates,
});
expect(comparison.pageCountMismatches).toEqual([]);
expect(comparison.primitivePropMismatches).toEqual([]);
expect(comparison.mismatches).toEqual([]);
}, 30_000);
it("matches the real sample on every template for a substantive custom-section rule", async () => {
const data = structuredClone(sampleResumeData);
data.metadata.styleRules = readRules("custom-section-type");
const conversion = convertLegacyStyleRules(data);
const comparison = await compareLegacySemanticPresentation({
data,
convertedSource: conversion.source,
templates,
});
expect(comparison.pageCountMismatches).toEqual([]);
expect(comparison.primitivePropMismatches).toEqual([]);
expect(comparison.mismatches).toEqual([]);
}, 30_000);
it("preserves Scizor template-after Bold color for converted legacy text rules", async () => {
const data = buildFixture(readRules("primary-text-bold-3146"));
data.metadata.template = "scizor";
const conversion = convertLegacyStyleRules(data);
const comparison = await compareLegacySemanticPresentation({
data,
convertedSource: conversion.source,
templates: ["scizor"],
});
expect(comparison.pageCountMismatches).toEqual([]);
expect(comparison.primitivePropMismatches).toEqual([]);
expect(comparison.mismatches).toEqual([]);
});
it.each(["onyx", "meowth"] as const)(
"matches combined separator and box-style primitives on %s",
async (template) => {
const data = buildFixture(readRules("combined-text-host"));
const conversion = convertLegacyStyleRules(data);
const comparison = await compareLegacySemanticPresentation({
data,
convertedSource: conversion.source,
templates: [template],
});
expect(comparison.mismatches).toEqual([]);
},
);
it.each([
["text", { type: "TEXT", value: "legacy" }, { type: "TEXT", value: "semantic" }],
[
"props",
{ type: "LINK", props: { src: "https://legacy.example" } },
{ type: "LINK", props: { src: "https://semantic.example" } },
],
["alpha", { type: "TEXT", style: { color: "#11223380" } }, { type: "TEXT", style: { color: "#112233" } }],
["transparent", { type: "TEXT", style: { color: "transparent" } }, { type: "TEXT", style: { color: "black" } }],
["unsupported color", { type: "TEXT", style: { color: "brand-ink" } }, { type: "TEXT", style: { color: "black" } }],
["units", { type: "TEXT", style: { fontSize: "12pt" } }, { type: "TEXT", style: { fontSize: "12px" } }],
["hierarchy", { type: "VIEW", children: [{ type: "TEXT", value: "same" }] }, { type: "TEXT", value: "same" }],
["geometry", { type: "VIEW", style: { width: 10 } }, { type: "VIEW", style: { width: 11 } }],
[
"split wrapper inherited style",
{
type: "TEXT",
style: { color: "#112233" },
children: [{ type: "TEXT", style: { color: "#112233" }, value: "A" }],
},
{
type: "TEXT",
style: { color: "#000000" },
children: [{ type: "TEXT", style: { color: "#112233" }, value: "A" }],
},
],
] as const)("detects %s tampering", (_name, legacy, semantic) => {
expect(compareLegacyParityHostNodes(legacy, semantic)).not.toEqual([]);
});
it("normalizes only presentation-neutral empty Text artifacts", () => {
expect(
compareLegacyParityHostNodes(
{
type: "VIEW",
style: { color: "#112233" },
children: [{ type: "TEXT" }, { type: "TEXT", value: "same" }],
},
{
type: "VIEW",
style: { color: "#112233" },
children: [{ type: "TEXT", value: "same" }],
},
),
).toEqual([]);
for (const meaningfulEmptyText of [
{ type: "TEXT", style: { backgroundColor: "#112233" } },
{ type: "TEXT", style: { width: 1 } },
{ type: "TEXT", props: { id: "semantic-payload" } },
] satisfies LegacyParityHostNode[]) {
expect(
compareLegacyParityHostNodes({ type: "VIEW", children: [meaningfulEmptyText] }, { type: "VIEW", children: [] }),
).not.toEqual([]);
}
});
it("retains legitimate inheritance and renderer-default equivalence", () => {
expect(
compareLegacyParityHostNodes(
{
type: "TEXT",
style: { color: "rgb(18, 52, 86)", fontWeight: 400 },
children: [{ type: "TEXT", value: "same" }],
},
{ type: "TEXT", style: { color: "#123456" }, children: [{ type: "TEXT", value: "same" }] },
),
).toEqual([]);
});
});
+312
View File
@@ -0,0 +1,312 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Template } from "@reactive-resume/schema/templates";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { ResumeDocument } from "../document";
export type CompareLegacySemanticPresentationInput = {
data: ResumeData;
convertedSource: StylesheetSource;
templates: readonly Template[];
};
export type LegacySemanticPresentationComparison = {
pageCountMismatches: readonly string[];
primitivePropMismatches: readonly string[];
mismatches: readonly string[];
};
export type LegacyParityHostNode = {
type: string;
props?: Readonly<Record<string, unknown>>;
style?: unknown;
value?: string;
children?: readonly LegacyParityHostNode[];
};
type PrimitiveSnapshot = {
type: string;
text: string;
props: Readonly<Record<string, unknown>>;
style: Readonly<Record<string, unknown>>;
children: readonly PrimitiveSnapshot[];
};
const primitiveTypes = new Set(["PAGE", "VIEW", "TEXT", "LINK", "IMAGE", "SVG"]);
const inheritableProperties = new Set(
Object.entries(PROPERTY_REGISTRY_V1)
.filter(([, definition]) => definition?.inheritable)
.map(([property]) => property.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase())),
);
const textPrimitiveTypes = new Set(["TEXT", "LINK"]);
const textOnlyInheritedProperties = new Set([
"fontFamily",
"fontSize",
"fontStyle",
"fontWeight",
"letterSpacing",
"lineHeight",
"textAlign",
"textIndent",
"textTransform",
]);
const rendererInheritedProperties = new Set([
...inheritableProperties,
...textOnlyInheritedProperties,
"color",
"direction",
]);
const rendererDefaults: Readonly<Record<string, unknown>> = { fontWeight: 400 };
const emptyTextResetStyle: Readonly<Record<string, unknown>> = {
flexShrink: 1,
maxWidth: "100%",
minWidth: 0,
};
const nodeText = (node: LegacyParityHostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const normalizeValue = (value: unknown): unknown => {
if (value === undefined) return "[undefined]";
if (typeof value === "function") return `[function:${value.name || "anonymous"}]`;
if (Array.isArray(value)) return value.map(normalizeValue);
if (value && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>)
.filter(([, entry]) => entry !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entry]) => [key, normalizeValue(entry)]),
);
}
return value;
};
const normalizeHexColor = (value: string): string | undefined => {
const match = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(value);
if (!match) return;
const digits = match[1]?.toLowerCase();
if (!digits) return;
return digits.length <= 4 ? `#${[...digits].map((digit) => `${digit}${digit}`).join("")}` : `#${digits}`;
};
const normalizeRgbColor = (value: string): string | undefined => {
const match =
/^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d*\.?\d+)\s*)?\)$/i.exec(value);
if (!match) return;
const channels = match.slice(1, 4).map(Number);
if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) return;
const alpha = match[4] === undefined ? 1 : Number(match[4]);
if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return;
const hex = channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join("");
const alphaHex = Math.round(alpha * 255)
.toString(16)
.padStart(2, "0");
return `#${hex}${alphaHex === "ff" ? "" : alphaHex}`;
};
const normalizeColor = (value: string): string => {
const normalized = value.trim().toLowerCase();
if (normalized === "transparent") return "#00000000";
return normalizeHexColor(normalized) ?? normalizeRgbColor(normalized) ?? normalized;
};
const normalizeStyleValue = (property: string, value: unknown): unknown => {
if (typeof value === "string" && (property === "color" || property.endsWith("Color"))) {
return normalizeColor(value);
}
if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return Number.parseFloat(value);
return normalizeValue(value);
};
const mergedStyle = (style: unknown): Readonly<Record<string, unknown>> => {
const entries = Array.isArray(style) ? style : style ? [style] : [];
const merged = Object.assign({}, ...entries) as Record<string, unknown>;
if (merged.borderWidth !== undefined) {
for (const side of ["Top", "Right", "Bottom", "Left"]) {
const property = `border${side}Width`;
if (merged[property] === undefined) merged[property] = merged.borderWidth;
}
delete merged.borderWidth;
}
if (merged.borderRadius !== undefined) {
for (const corner of ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]) {
const property = `border${corner}Radius`;
if (merged[property] === undefined) merged[property] = merged.borderRadius;
}
delete merged.borderRadius;
}
return Object.fromEntries(
Object.entries(merged)
.filter(([, value]) => value !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([property, value]) => [property, normalizeStyleValue(property, value)]),
);
};
const collectPrimitiveSnapshots = (
node: LegacyParityHostNode,
inheritedStyle: Readonly<Record<string, unknown>> = rendererDefaults,
): PrimitiveSnapshot[] => {
const localStyle = mergedStyle(node.style);
const computedStyle: Record<string, unknown> = { ...localStyle };
for (const property of rendererInheritedProperties) {
if (!(property in computedStyle) && property in inheritedStyle) computedStyle[property] = inheritedStyle[property];
}
const nextInherited = Object.fromEntries(
[...rendererInheritedProperties].flatMap((property) =>
property in computedStyle ? [[property, computedStyle[property]]] : [],
),
);
const children = (node.children ?? []).flatMap((child) => collectPrimitiveSnapshots(child, nextInherited));
if (!primitiveTypes.has(node.type)) return children;
const snapshotStyle = Object.fromEntries(
Object.entries(computedStyle).filter(
([property]) => textPrimitiveTypes.has(node.type) || !textOnlyInheritedProperties.has(property),
),
);
const props = Object.fromEntries(
Object.entries(node.props ?? {}).filter(([property]) => property !== "children" && property !== "style"),
);
const isInheritedOnlyStyle = Object.entries(snapshotStyle).every(([property, value]) =>
Object.is(value, inheritedStyle[property]),
);
const hasCompleteEmptyTextReset =
Object.entries(emptyTextResetStyle).every(([property, value]) => Object.is(snapshotStyle[property], value)) &&
(snapshotStyle.direction === "ltr" || snapshotStyle.direction === "rtl");
const isRendererEmptyTextStyle =
hasCompleteEmptyTextReset &&
Object.entries(snapshotStyle).every(([property, value]) => {
if (property === "direction") return true;
if (property in emptyTextResetStyle) return Object.is(value, emptyTextResetStyle[property]);
return Object.is(value, inheritedStyle[property]);
});
const isPresentationNeutralEmptyText =
node.type === "TEXT" &&
nodeText(node) === "" &&
children.length === 0 &&
Object.keys(props).length === 0 &&
(isInheritedOnlyStyle || isRendererEmptyTextStyle);
if (isPresentationNeutralEmptyText) return [];
return [
{
type: node.type,
text: nodeText(node),
props: normalizeValue(props) as Readonly<Record<string, unknown>>,
style: snapshotStyle,
children,
},
];
};
const waitForDocument = async (instance: ReturnType<typeof pdf>): Promise<LegacyParityHostNode> => {
const deadline = Date.now() + 5_000;
while (!instance.container.document) {
if (Date.now() >= deadline) throw new Error("Timed out while rendering legacy parity document.");
await new Promise((resolve) => setTimeout(resolve, 0));
}
return instance.container.document as LegacyParityHostNode;
};
const renderSnapshots = async (data: ResumeData, template: Template): Promise<PrimitiveSnapshot[]> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
return collectPrimitiveSnapshots(await waitForDocument(pdf(element)));
};
const arrayEntryLabel = (value: unknown): string => {
if (!value || typeof value !== "object" || Array.isArray(value)) return "";
const entry = value as { type?: unknown; text?: unknown };
if (typeof entry.type !== "string") return "";
const text = typeof entry.text === "string" ? entry.text.replace(/\s+/g, " ").trim().slice(0, 32) : "";
return `<${entry.type}${text ? `:${JSON.stringify(text)}` : ""}>`;
};
const diffValues = (legacy: unknown, semantic: unknown, path: string, mismatches: string[]): void => {
if (Object.is(legacy, semantic)) return;
if (Array.isArray(legacy) && Array.isArray(semantic)) {
if (legacy.length !== semantic.length) {
mismatches.push(`${path}.length: legacy=${legacy.length} semantic=${semantic.length}`);
}
for (let index = 0; index < Math.max(legacy.length, semantic.length); index++) {
const label = arrayEntryLabel(legacy[index] ?? semantic[index]);
diffValues(legacy[index], semantic[index], `${path}[${index}]${label}`, mismatches);
}
return;
}
if (
legacy &&
semantic &&
typeof legacy === "object" &&
typeof semantic === "object" &&
!Array.isArray(legacy) &&
!Array.isArray(semantic)
) {
const keys = new Set([
...Object.keys(legacy as Record<string, unknown>),
...Object.keys(semantic as Record<string, unknown>),
]);
for (const key of [...keys].sort()) {
diffValues(
(legacy as Record<string, unknown>)[key],
(semantic as Record<string, unknown>)[key],
`${path}.${key}`,
mismatches,
);
}
return;
}
mismatches.push(`${path}: legacy=${JSON.stringify(legacy)} semantic=${JSON.stringify(semantic)}`);
};
export function compareLegacyParityHostNodes(
legacy: LegacyParityHostNode,
semantic: LegacyParityHostNode,
): readonly string[] {
const mismatches: string[] = [];
diffValues(collectPrimitiveSnapshots(legacy), collectPrimitiveSnapshots(semantic), "root", mismatches);
return mismatches;
}
export async function compareLegacySemanticPresentation(
input: CompareLegacySemanticPresentationInput,
): Promise<LegacySemanticPresentationComparison> {
const sanitizedRules = styleRulesSchema.parse(input.data.metadata.styleRules ?? []);
const { stylesheet: _stylesheet, ...metadata } = input.data.metadata;
const legacyData: ResumeData = {
...input.data,
metadata: { ...metadata, styleRules: sanitizedRules },
};
const semanticData: ResumeData = {
...input.data,
metadata: {
...input.data.metadata,
styleRules: sanitizedRules,
stylesheet: {
mode: "semantic",
source: input.convertedSource,
applied: input.convertedSource,
},
},
};
const pageCountMismatches: string[] = [];
const primitivePropMismatches: string[] = [];
for (const template of input.templates) {
const legacy = await renderSnapshots(legacyData, template);
const semantic = await renderSnapshots(semanticData, template);
const legacyPages = legacy.filter(({ type }) => type === "PAGE").length;
const semanticPages = semantic.filter(({ type }) => type === "PAGE").length;
if (legacyPages !== semanticPages) {
pageCountMismatches.push(`${template}: legacy=${legacyPages} semantic=${semanticPages}`);
}
diffValues(legacy, semantic, template, primitivePropMismatches);
}
return {
pageCountMismatches,
primitivePropMismatches,
mismatches: [...pageCountMismatches, ...primitivePropMismatches],
};
}
@@ -0,0 +1,252 @@
import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
import { renderToBuffer } from "@react-pdf/renderer";
import pixelmatch from "pixelmatch";
import { createElement } from "react";
import { styleRulesSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { convertLegacyStyleRules } from "./legacy-converter";
import { rasterizePdf } from "./test/rasterize-pdf";
const templates = [
"azurill",
"bronzor",
"chikorita",
"ditgar",
"ditto",
"gengar",
"glalie",
"kakuna",
"lapras",
"leafish",
"meowth",
"onyx",
"pikachu",
"rhyhorn",
"scizor",
] as const satisfies readonly Template[];
const fixtureNames = [
"all-templates-smoke",
"array-order-tie",
"award-unbold",
"clamped-spacing",
"combined-text-host",
"custom-section-type",
"disabled-rules",
"icon-level-size",
"link-underline-3134",
"merge-specificity",
"primary-text-bold-3146",
"rich-text-all-slots",
"sanitized-intent-3199",
"section-id-uuid",
] as const;
const readRules = (name: string): StyleRule[] =>
styleRulesSchema.parse(
JSON.parse(readFileSync(new URL(`./__fixtures__/legacy/${name}.json`, import.meta.url), "utf8")),
);
const buildFixture = (rules: StyleRule[]): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.headline = "Engineer";
data.basics.email = "ada@example.com";
data.summary.hidden = false;
data.summary.content =
'<p>Paragraph <strong>bold</strong> <mark>mark</mark> <a href="https://example.com">link</a></p><ul><li>List item</li></ul>';
data.sections.skills.items = [
{
id: "skill-1",
hidden: false,
icon: "code",
iconColor: "",
name: "Mathematics",
proficiency: "Expert",
level: 3,
keywords: ["Analysis"],
},
];
data.sections.experience.items = [
{
id: "experience",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "<p>Built engines.</p>",
roles: [
{
id: "role-1",
position: "Senior Engineer",
period: "1843",
description: "<p>Led the engine team.</p>",
},
],
},
];
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
degree: "BSc",
area: "Mathematics",
grade: "First",
location: "London",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "<p>Studied analytical engines.</p>",
},
];
data.sections.awards.items = [
{
id: "award-1",
hidden: false,
title: "Prize",
awarder: "Society",
date: "1843",
website: { url: "", label: "", inlineLink: false },
description: "<p>First programmer.</p>",
},
];
data.customSections = [
{
id: "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37",
type: "experience",
title: "Consulting",
icon: "briefcase",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: "custom-experience-1",
hidden: false,
company: "Difference Engines",
position: "Consultant",
location: "London",
period: "1844",
website: { url: "", label: "", inlineLink: false },
description: "<p>Advised builders.</p>",
roles: [],
},
],
},
];
data.metadata.layout.pages = [
{
fullWidth: true,
main: ["summary", "experience", "education", "skills", "awards", "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37"],
sidebar: [],
},
];
data.metadata.styleRules = [...rules];
return data;
};
const render = async (data: ResumeData, template: Template): Promise<Uint8Array> => {
const document = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof renderToBuffer>[0];
return new Uint8Array(await renderToBuffer(document));
};
const semanticData = (data: ResumeData): ResumeData => {
const conversion = convertLegacyStyleRules(data);
const semantic = structuredClone(data);
semantic.metadata.styleRules = [...conversion.sanitizedRules];
semantic.metadata.stylesheet = { mode: "semantic", source: conversion.source, applied: conversion.source };
return semantic;
};
type RasterComparison = {
pixelDiffRatio: number;
mismatches: readonly string[];
};
const comparePdfRasters = async (legacy: Uint8Array, semantic: Uint8Array): Promise<RasterComparison> => {
const legacyPages = await rasterizePdf(legacy);
const semanticPages = await rasterizePdf(semantic);
const mismatches: string[] = [];
if (legacyPages.length !== semanticPages.length) {
mismatches.push(`page count: legacy=${legacyPages.length} semantic=${semanticPages.length}`);
}
let changed = 0;
let pixels = 0;
for (const [index, legacyPage] of legacyPages.entries()) {
const semanticPage = semanticPages[index];
if (!semanticPage) continue;
if (semanticPage.width !== legacyPage.width || semanticPage.height !== legacyPage.height) {
mismatches.push(
`page ${index + 1} dimensions: legacy=${legacyPage.width}x${legacyPage.height} semantic=${semanticPage.width}x${semanticPage.height}`,
);
continue;
}
const changedOnPage = pixelmatch(
legacyPage.data,
semanticPage.data,
undefined,
legacyPage.width,
legacyPage.height,
{ threshold: 0 },
);
if (changedOnPage > 0) {
mismatches.push(`page ${index + 1} pixels: changed=${changedOnPage}/${legacyPage.width * legacyPage.height}`);
}
changed += changedOnPage;
pixels += legacyPage.width * legacyPage.height;
}
return { pixelDiffRatio: pixels === 0 ? (mismatches.length > 0 ? 1 : 0) : changed / pixels, mismatches };
};
describe("legacy activation raster parity", () => {
it.each(fixtureNames)(
"has zero real-PDF pixel drift for %s",
async (fixture) => {
const legacy = buildFixture(readRules(fixture));
const semantic = semanticData(legacy);
expect(await comparePdfRasters(await render(legacy, "onyx"), await render(semantic, "onyx"))).toEqual({
pixelDiffRatio: 0,
mismatches: [],
});
},
30_000,
);
it.each(templates)(
"smoke-renders %s without activation pixel drift",
async (template) => {
const legacy = buildFixture(readRules("all-templates-smoke"));
const semantic = semanticData(legacy);
expect(await comparePdfRasters(await render(legacy, template), await render(semantic, template))).toEqual({
pixelDiffRatio: 0,
mismatches: [],
});
},
30_000,
);
it.each(["onyx", "meowth"] as const)(
"has zero combined-separator and box-style pixel drift on %s",
async (template) => {
const legacy = buildFixture(readRules("combined-text-host"));
const semantic = semanticData(legacy);
expect(await comparePdfRasters(await render(legacy, template), await render(semantic, template))).toEqual({
pixelDiffRatio: 0,
mismatches: [],
});
},
30_000,
);
});
@@ -0,0 +1,74 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
type HostNode = {
type: string;
style?: unknown;
value?: string;
children?: HostNode[];
};
const emptyStylesheet = { languageVersion: 1, text: "@version 1;" };
const buildFixture = (
mode: "missing" | "legacy" | "semantic",
hideLinkUnderline: boolean,
text = emptyStylesheet.text,
): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.email = "ada@example.com";
data.metadata.page.hideLinkUnderline = hideLinkUnderline;
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
if (mode !== "missing") {
const stylesheet = { languageVersion: 1, text };
data.metadata.stylesheet = { mode, source: stylesheet, applied: stylesheet };
}
return data;
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const findEmailLink = (node: HostNode): HostNode | undefined => {
if (node.type === "LINK" && nodeText(node) === "ada@example.com") return node;
for (const child of node.children ?? []) {
const match = findEmailLink(child);
if (match) return match;
}
};
const finalLinkDecoration = async (data: ResumeData) => {
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
const link = findEmailLink(instance.container.document as HostNode);
const styles = Array.isArray(link?.style) ? link.style : link?.style ? [link.style] : [];
return Object.assign({}, ...styles).textDecoration;
};
describe("PDF link decoration fidelity", () => {
it.each([
["missing", false, "underline"],
["missing", true, "none"],
["legacy", false, "underline"],
["legacy", true, "none"],
["semantic", false, "underline"],
["semantic", true, "none"],
] as const)("%s stylesheet with hideLinkUnderline=%s resolves to %s", async (mode, hidden, expected) => {
expect(await finalLinkDecoration(buildFixture(mode, hidden))).toBe(expected);
});
it("lets semantic none override an underlined builder baseline (#3134)", async () => {
const data = buildFixture("semantic", false, "@version 1; link { text-decoration: none; }");
expect(await finalLinkDecoration(data)).toBe("none");
});
});
@@ -0,0 +1,104 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { templateSchema } from "@reactive-resume/schema/templates";
import { ResumeDocument } from "../document";
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
}));
type HostNode = {
type: string;
props?: Readonly<Record<string, unknown>>;
children?: HostNode[];
value?: string;
};
const semanticSource = (text = "@version 1;\n") => ({
languageVersion: 1,
text,
});
const buildFixture = (mode: "legacy" | "semantic"): ResumeData => {
const data = structuredClone(sampleResumeData);
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.basics.headline = "Programmer";
data.basics.email = "ada@example.com";
data.summary.content = [
"<h2>Computing pioneer</h2>",
"<blockquote><p>Quote <strong>bold</strong> <em>emphasis</em> <u>underline</u> <s>strike</s>",
'<code>code</code> <span>span</span> <mark>mark</mark> <a href="https://example.com">link</a><br>next</p></blockquote>',
"<ul><li>Unordered</li></ul><ol><li>Ordered</li></ol><hr>",
].join("");
data.sections.experience.items = data.sections.experience.items.slice(0, 1);
data.sections.education.items = data.sections.education.items.slice(0, 1);
data.sections.projects.items = data.sections.projects.items.slice(0, 1);
data.sections.skills.items = data.sections.skills.items.slice(0, 1);
data.sections.languages.items = data.sections.languages.items.slice(0, 1);
data.metadata.page.hideIcons = false;
data.metadata.page.hideSectionIcons = false;
data.metadata.layout.pages = [
{
fullWidth: false,
main: ["summary", "experience", "education", "projects"],
sidebar: ["skills", "languages"],
},
];
if (mode === "semantic") {
data.metadata.stylesheet = {
mode,
source: semanticSource(),
applied: semanticSource(),
};
}
return data;
};
const renderHostTree = async (data: ResumeData, template: Template): Promise<HostNode> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
return instance.container.document as HostNode;
};
const primitiveTypes = (node: HostNode): string[] => [
node.type,
...(node.children ?? []).flatMap((child) => primitiveTypes(child)),
];
const textValues = (node: HostNode): string[] => [
...(node.type === "TEXT_INSTANCE" && node.value ? [node.value] : []),
...(node.children ?? []).flatMap((child) => textValues(child)),
];
describe("semantic PDF bindings do not add layout wrappers", () => {
it("uses a split-page fixture with chrome, ordinary sections, rich lists, and levels", () => {
const data = buildFixture("legacy");
expect(data.metadata.layout.pages[0]).toMatchObject({
fullWidth: false,
main: ["summary", "experience", "education", "projects"],
sidebar: ["skills", "languages"],
});
expect(data.summary.content).toContain("<blockquote>");
expect(data.summary.content).toContain("<ul>");
expect(data.sections.skills.items[0]?.level).toBeGreaterThan(0);
expect(data.sections.languages.items[0]?.level).toBeGreaterThan(0);
expect(data.basics.email).toBe("ada@example.com");
});
it.each(templateSchema.options)(
"%s preserves primitive type, count, and order for an empty stylesheet",
async (template) => {
const legacy = await renderHostTree(buildFixture("legacy"), template);
const semantic = await renderHostTree(buildFixture("semantic"), template);
expect(textValues(semantic)).toEqual(textValues(legacy));
expect(primitiveTypes(semantic)).toEqual(primitiveTypes(legacy));
},
);
});
+56
View File
@@ -0,0 +1,56 @@
export type CombinedTextName =
| "experience-position-location"
| "experience-location"
| "experience-period"
| "education-area-degree"
| "education-degree-grade"
| "education-location-period"
| "education-grade-location";
export type SemanticNodeKeyFactory = {
resume: () => string;
page: (pageNumber: number) => string;
region: (pageKey: string, region: string) => string;
header: (regionKey: string) => string;
headerPart: (headerKey: string, kind: "picture" | "name" | "headline") => string;
contactList: (headerKey: string) => string;
contactItem: (contactListKey: string, name: string, id?: string) => string;
section: (regionKey: string, id: string) => string;
sectionHeading: (sectionKey: string) => string;
sectionItems: (sectionKey: string) => string;
item: (parentKey: string, id: string) => string;
itemHeader: (itemKey: string) => string;
combinedText: (parentKey: string, name: CombinedTextName) => string;
field: (parentKey: string, name: string) => string;
link: (parentKey: string, role: string) => string;
icon: (parentKey: string, role: string) => string;
level: (parentKey: string) => string;
richText: (parentKey: string, name: string) => string;
richTextNode: (parentKey: string, kind: string, index: number) => string;
};
const encodeKeyPart = (value: string | number): string => encodeURIComponent(String(value)).replaceAll("~", "%7E");
const childKey = (parentKey: string, segment: string): string => `${parentKey}/${segment}`;
export const semanticNodeKeys: SemanticNodeKeyFactory = {
resume: () => "resume",
page: (pageNumber) => `page-${encodeKeyPart(pageNumber)}`,
region: (pageKey, region) => childKey(pageKey, `region-${encodeKeyPart(region)}`),
header: (regionKey) => childKey(regionKey, "header"),
headerPart: (headerKey, kind) => childKey(headerKey, kind),
contactList: (headerKey) => childKey(headerKey, "contact-list"),
contactItem: (contactListKey, name, id) =>
childKey(contactListKey, `contact-${encodeKeyPart(name)}${id === undefined ? "" : `~${encodeKeyPart(id)}`}`),
section: (regionKey, id) => childKey(regionKey, `section-${encodeKeyPart(id)}`),
sectionHeading: (sectionKey) => childKey(sectionKey, "section-heading"),
sectionItems: (sectionKey) => childKey(sectionKey, "section-items"),
item: (parentKey, id) => childKey(parentKey, `item-${encodeKeyPart(id)}`),
itemHeader: (itemKey) => childKey(itemKey, "item-header"),
combinedText: (parentKey, name) => childKey(parentKey, `combined-text-${encodeKeyPart(name)}`),
field: (parentKey, name) => childKey(parentKey, `field-${encodeKeyPart(name)}`),
link: (parentKey, role) => childKey(parentKey, `link-${encodeKeyPart(role)}`),
icon: (parentKey, role) => childKey(parentKey, `icon-${encodeKeyPart(role)}`),
level: (parentKey) => childKey(parentKey, "level"),
richText: (parentKey, name) => childKey(parentKey, `rich-text-${encodeKeyPart(name)}`),
richTextNode: (parentKey, kind, index) => childKey(parentKey, `${encodeKeyPart(kind)}-${encodeKeyPart(index)}`),
};
@@ -0,0 +1,93 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { semanticNodeKeys } from "./node-keys";
import { resolveResumePresentation } from "./resolve";
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
}));
type HostNode = {
type: string;
value?: string;
break?: boolean;
wrap?: boolean;
props?: { break?: boolean; wrap?: boolean };
children?: HostNode[];
};
const nodeText = (node: HostNode): string =>
node.value ?? (node.children ?? []).map((child) => nodeText(child)).join("");
const findSectionView = (node: HostNode, text: string): HostNode | undefined => {
for (const child of node.children ?? []) {
const match = findSectionView(child, text);
if (match) return match;
}
const props = flowProps(node);
return node.type === "VIEW" && nodeText(node) === text && (props.break !== undefined || props.wrap !== undefined)
? node
: undefined;
};
const flowProps = (node: HostNode | undefined) => ({
break: node?.break ?? node?.props?.break,
wrap: node?.wrap ?? node?.props?.wrap,
});
const buildFixture = (value: string): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics.name = "";
data.basics.headline = "";
data.basics.email = "";
data.basics.phone = "";
data.basics.location = "";
data.basics.customFields = [];
data.summary.content = "<p>Pagination sentinel</p>";
data.summary.keepTogether = true;
data.summary.startOnNewPage = true;
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
const source = {
languageVersion: 1,
text: `@version 1; section[type="summary"] { break-before: ${value}; break-inside: ${value}; }`,
};
data.metadata.stylesheet = { mode: "semantic", source, applied: source };
return data;
};
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", mode: "semantic" });
const sectionKey = semanticNodeKeys.section(semanticNodeKeys.region(semanticNodeKeys.page(1), "main"), "summary");
expect(presentation[sectionKey]).toMatchObject({ break: breakBefore, wrap });
});
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];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const section = findSectionView(instance.container.document as HostNode, "Pagination sentinel");
expect(flowProps(section)).toEqual({ break: breakBefore, wrap });
});
});
@@ -0,0 +1,132 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
}));
type HostNode = {
type: string;
props?: Readonly<Record<string, unknown>>;
children?: HostNode[];
};
const findFirst = (node: HostNode, type: string): HostNode | undefined => {
if (node.type === type) return node;
for (const child of node.children ?? []) {
const match = findFirst(child, type);
if (match) return match;
}
};
const renderHostTree = async (data: ResumeData): Promise<HostNode> => {
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
return instance.container.document as HostNode;
};
const renderPdf = async (data: ResumeData): Promise<Uint8Array> => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<
typeof renderer.renderToBuffer
>[0];
return new Uint8Array(await renderer.renderToBuffer(element));
};
type PdfTextItem = {
str: string;
transform: readonly number[];
};
type ParsedPdfPage = {
getTextContent: () => Promise<{ items: PdfTextItem[] }>;
getViewport: (options: { scale: number }) => { width: number };
};
type ParsedPdf = {
numPages: number;
getPage: (pageNumber: number) => Promise<ParsedPdfPage>;
};
const parsePdf = (data: Uint8Array): Promise<ParsedPdf> => getDocument({ data }).promise as Promise<ParsedPdf>;
const overflowingFixture = (pageSize: "A4" | "LETTER"): ResumeData => {
const data = structuredClone(defaultResumeData);
const applied = {
languageVersion: 1,
text: `
@version 1;
page[page-number="1"] { size: ${pageSize}; }
header { -resume-fixed: true; }
@media (max-width: 600pt) { section-heading { font-size: 9pt; } }
`,
};
data.picture.hidden = true;
data.basics.name = "FIXED HEADER TOKEN";
data.summary.title = "Summary";
data.summary.content = Array.from(
{ length: 180 },
(_value, index) => `<p>Overflow line ${index + 1} with enough text to occupy the authored page.</p>`,
).join("");
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
return data;
};
const readPhysicalPages = async (document: ParsedPdf) => {
const pages = [];
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
const content = await page.getTextContent();
pages.push({
width: page.getViewport({ scale: 1 }).width,
items: content.items,
text: content.items.map(({ str }) => str).join(" "),
});
}
return pages;
};
describe("semantic pagination bindings", () => {
it("passes resolved authored-page size to the existing Page primitive", async () => {
const data = structuredClone(defaultResumeData);
const applied = {
languageVersion: 1,
text: '@version 1;\npage[page-number="1"] { size: LETTER; }',
};
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
const page = findFirst(await renderHostTree(data), "PAGE");
expect(page?.props?.size).toBe("LETTER");
});
it("keeps authored-page selectors and media width stable across wrapped physical pages", async () => {
const document = await parsePdf(await renderPdf(overflowingFixture("A4")));
const pages = await readPhysicalPages(document);
const heading = pages.flatMap(({ items }) => items).find(({ str }) => str === "Summary");
expect(document.numPages).toBeGreaterThan(1);
expect(pages.every(({ width }) => Math.abs(width - 595.28) < 0.1)).toBe(true);
expect(pages.filter(({ text }) => text.includes("FIXED HEADER TOKEN"))).toHaveLength(document.numPages);
expect(Math.abs((heading?.transform[3] ?? 0) - 9)).toBeLessThan(0.1);
});
it("resolves page size before evaluating media queries", async () => {
const document = await parsePdf(await renderPdf(overflowingFixture("LETTER")));
const pages = await readPhysicalPages(document);
const heading = pages.flatMap(({ items }) => items).find(({ str }) => str === "Summary");
expect(pages.every(({ width }) => Math.abs(width - 612) < 0.1)).toBe(true);
expect(Math.abs((heading?.transform[3] ?? 0) - 9)).toBeGreaterThan(0.1);
});
});
@@ -0,0 +1,204 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
let renderPreflightPdf: typeof import("./preflight-core").renderPreflightPdf;
const rendererMock = vi.hoisted(() => ({
pdf: vi.fn(() => ({
toBlob: vi.fn(async () => new Blob(["%PDF-1.7"], { type: "application/pdf" })),
})),
}));
vi.mock("#react-pdf-renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
pdf: rendererMock.pdf,
}));
beforeAll(async () => {
vi.resetModules();
({ renderPreflightPdf } = await import("./preflight-core"));
});
afterAll(() => {
vi.doUnmock("#react-pdf-renderer");
vi.resetModules();
});
const validStylesheet = {
languageVersion: 1,
text: "@version 1;",
} as const;
const pageLimits = {
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
} as const;
const createRendererUnsafeResumeData = (): ResumeData => {
const data = structuredClone(defaultResumeData);
data.customSections = [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
} as never,
];
return data;
};
const createLegacyRendererSafeResumeData = (): ResumeData =>
({
...structuredClone(defaultResumeData),
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
{
id: "experience-item",
hidden: false,
company: "Analytical Engines",
position: "Programmer",
location: "London",
period: "18421843",
description: "<p>Wrote the first algorithm.</p>",
content: "<p>Compatible overlap</p>",
},
],
},
],
}) as unknown as ResumeData;
describe("renderPreflightPdf", () => {
beforeEach(() => {
rendererMock.pdf.mockReset();
rendererMock.pdf.mockImplementation(() => ({
toBlob: vi.fn(async () => new Blob(["%PDF-1.7"], { type: "application/pdf" })),
}));
});
it("renders a valid semantic candidate to transferable PDF bytes", async () => {
const result = await renderPreflightPdf(
{
data: createLegacyRendererSafeResumeData(),
template: defaultResumeData.metadata.template,
stylesheet: validStylesheet,
},
pageLimits,
);
expect(result).toMatchObject({ ok: true, diagnostics: [] });
expect(result.ok && new TextDecoder().decode(result.bytes)).toBe("%PDF-1.7");
expect(rendererMock.pdf).toHaveBeenCalledWith(
expect.objectContaining({
props: expect.objectContaining({
data: expect.objectContaining({
customSections: [
expect.objectContaining({
items: [
expect.objectContaining({
content: "<p>Compatible overlap</p>",
roles: [],
website: { url: "", label: "", inlineLink: false },
}),
],
}),
],
}),
}),
}),
);
});
it("returns compiler diagnostics without starting the renderer", async () => {
const result = await renderPreflightPdf(
{
data: defaultResumeData,
template: defaultResumeData.metadata.template,
stylesheet: { languageVersion: 1, text: "@version 1; page { color: ; }" },
},
pageLimits,
);
expect(result).toMatchObject({
ok: false,
code: "STYLESHEET_PREFLIGHT_INVALID",
diagnostics: [expect.objectContaining({ severity: "error" })],
});
expect(rendererMock.pdf).not.toHaveBeenCalled();
});
it("rejects renderer-unsafe data before semantic inspection or React PDF dispatch", async () => {
const result = renderPreflightPdf(
{
data: createRendererUnsafeResumeData(),
template: defaultResumeData.metadata.template,
stylesheet: validStylesheet,
},
pageLimits,
);
await expect(result).rejects.toMatchObject({
name: "ZodError",
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
});
expect(rendererMock.pdf).not.toHaveBeenCalled();
});
it("returns a stable public failure when React PDF throws", async () => {
rendererMock.pdf.mockReturnValueOnce({
toBlob: vi.fn(() => Promise.reject(new Error("sensitive renderer details"))),
});
const result = await renderPreflightPdf(
{
data: defaultResumeData,
template: defaultResumeData.metadata.template,
stylesheet: validStylesheet,
},
pageLimits,
);
expect(result).toMatchObject({
ok: false,
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
message: "PDF rendering failed.",
diagnostics: [],
});
});
it.each([
["width", "2001pt 1000pt"],
["height", "1000pt 20001pt"],
["area", "1500pt 15000pt"],
])("rejects authored page %s limits before starting the renderer", async (_limit, size) => {
const result = await renderPreflightPdf(
{
data: defaultResumeData,
template: defaultResumeData.metadata.template,
stylesheet: { languageVersion: 1, text: `@version 1; page { size: ${size}; }` },
},
pageLimits,
);
expect(result).toMatchObject({
ok: false,
code: "STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT",
});
expect(rendererMock.pdf).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,138 @@
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Template } from "@reactive-resume/schema/templates";
import type { PdfPreflightFailureCode } from "./preflight-reference";
import { createElement } from "react";
import { parseResumeData } from "@reactive-resume/schema/resume/data";
import { pdf } from "#react-pdf-renderer";
import { ResumeDocument } from "../document";
import { getTemplatePageSize } from "../templates/shared/page-size";
import { semanticNodeKeys } from "./node-keys";
import { resolveResumeRuntime } from "./resolve";
export type { PdfPreflightFailureCode } from "./preflight-reference";
export type StylesheetPreflightInput = {
data: ResumeData;
template: Template;
stylesheet: StylesheetSource;
};
export type PdfPreflightPageLimits = {
maxPageWidthPt: number;
maxPageHeightPt: number;
maxPageAreaPt2: number;
};
export type PdfPreflightFailure = {
ok: false;
code: PdfPreflightFailureCode;
message: string;
diagnostics: readonly SemanticCssDiagnostic[];
};
export type PdfPreflightResult =
| {
ok: true;
pageCount: number;
byteCount: number;
diagnostics: readonly SemanticCssDiagnostic[];
}
| PdfPreflightFailure;
export type RenderPreflightPdfResult =
| {
ok: true;
bytes: Uint8Array;
diagnostics: readonly SemanticCssDiagnostic[];
}
| PdfPreflightFailure;
export type StylesheetPreflightRunner = {
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult>;
};
export type BrowserPdfPreflightResult =
| (Extract<PdfPreflightResult, { ok: true }> & { pdf: ArrayBuffer })
| PdfPreflightFailure;
const pageDimensions = (size: "A4" | "LETTER" | { width: number; height?: number }) => {
if (size === "LETTER") return { width: 612, height: 792 };
if (size === "A4") return { width: 595.28, height: 841.89 };
return { width: size.width, height: size.height ?? 841.89 };
};
const pageSizeFailure = (
data: ResumeData,
presentation: ReturnType<typeof resolveResumeRuntime>["presentation"],
limits: PdfPreflightPageLimits,
): PdfPreflightFailure | undefined => {
const fallbackSize = getTemplatePageSize(data.metadata.page.format);
for (const index of data.metadata.layout.pages.keys()) {
const size = presentation[semanticNodeKeys.page(index + 1)]?.size ?? fallbackSize;
const { width, height } = pageDimensions(size);
if (width > limits.maxPageWidthPt || height > limits.maxPageHeightPt || width * height > limits.maxPageAreaPt2) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT",
message: "The authored page size exceeds the PDF preflight limit.",
diagnostics: [],
};
}
}
};
export async function renderPreflightPdf(
input: StylesheetPreflightInput,
pageLimits: PdfPreflightPageLimits,
): Promise<RenderPreflightPdfResult> {
const parsedData = parseResumeData(input.data);
const data = {
...parsedData,
metadata: {
...parsedData.metadata,
stylesheet: {
mode: "semantic" as const,
source: input.stylesheet,
applied: input.stylesheet,
},
},
};
const inspection = resolveResumeRuntime({
data,
template: input.template,
applied: input.stylesheet,
mode: "semantic",
});
if (inspection.diagnostics.some(({ severity }) => severity === "error")) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_INVALID",
message: "The stylesheet cannot be rendered.",
diagnostics: inspection.diagnostics,
};
}
const pageFailure = pageSizeFailure(data, inspection.presentation, pageLimits);
if (pageFailure) return { ...pageFailure, diagnostics: inspection.diagnostics };
try {
const document = createElement(ResumeDocument, { data, template: input.template }) as Parameters<typeof pdf>[0];
const blob = await pdf(document).toBlob();
return {
ok: true,
bytes: new Uint8Array(await blob.arrayBuffer()),
diagnostics: inspection.diagnostics,
};
} catch {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
message: "PDF rendering failed.",
diagnostics: inspection.diagnostics,
};
}
}
@@ -0,0 +1,52 @@
export const PDF_PREFLIGHT_DIAGNOSTIC_CATALOG = {
STYLESHEET_PREFLIGHT_INVALID: {
meaning: "The stylesheet has compiler or semantic errors.",
action: "Fix the accompanying Semantic CSS diagnostics.",
},
STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT: {
meaning: "An authored page exceeds the PDF dimension or area budget.",
action: "Use a smaller page size.",
},
STYLESHEET_PREFLIGHT_BYTE_LIMIT: {
meaning: "The rendered PDF exceeds the byte budget.",
action: "Reduce pages, images, or styled content.",
},
STYLESHEET_PREFLIGHT_PAGE_LIMIT: {
meaning: "The rendered PDF exceeds the page-count budget.",
action: "Reduce content or pagination.",
},
STYLESHEET_PREFLIGHT_TIMEOUT: {
meaning: "PDF preflight exceeded its deadline.",
action: "Reduce stylesheet or document complexity and retry.",
},
STYLESHEET_PREFLIGHT_MEMORY_LIMIT: {
meaning: "PDF preflight exceeded its memory budget.",
action: "Reduce document, image, or layout complexity.",
},
STYLESHEET_PREFLIGHT_RENDER_FAILED: {
meaning: "The PDF renderer could not render the candidate stylesheet.",
action: "Simplify the candidate and inspect accompanying diagnostics.",
},
STYLESHEET_PREFLIGHT_PARSE_FAILED: {
meaning: "The rendered PDF could not be inspected.",
action: "Retry after simplifying the candidate.",
},
STYLESHEET_PREFLIGHT_WORKER_FAILED: {
meaning: "The isolated PDF preflight worker failed or its queue was full.",
action: "Retry; simplify the candidate if the failure repeats.",
},
} as const;
export type PdfPreflightFailureCode = keyof typeof PDF_PREFLIGHT_DIAGNOSTIC_CATALOG;
export const STYLESHEET_PREFLIGHT_LIMITS = Object.freeze({
timeoutMs: 5_000,
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
maxOldGenerationMb: 256,
maxConcurrentWorkers: 1,
maxQueuedRequests: 32,
});
@@ -0,0 +1,135 @@
import type { PublicStyleProjection } from "./public-projection";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import {
createPublicStyleProjection,
PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
SEMANTIC_TREE_VERSION,
validatePublicStyleProjection,
} from "./public-projection";
const buildData = () => {
const data = structuredClone(defaultResumeData);
const applied = {
languageVersion: 1,
text: "@version 1;\nname { color: #123456; }\n",
};
data.basics.name = "Ada Lovelace";
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
return data;
};
describe("public semantic style projection", () => {
it("contains only resolved, JSON-safe presentation keyed by stable node key", async () => {
const projection = await createPublicStyleProjection({ data: buildData() });
const serialized = JSON.stringify(projection);
expect(projection).toMatchObject({
formatVersion: PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
languageVersion: 1,
semanticTreeVersion: SEMANTIC_TREE_VERSION,
registryFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
adapterFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
renderDataHash: expect.stringMatching(/^[a-f0-9]{64}$/),
});
expect(projection.nodes["page-1/region-header/header/name"]).toEqual({
style: { color: "#123456" },
});
expect(serialized).not.toContain("@version");
expect(serialized).not.toMatch(/source|comment|diagnostic|selector|variable|range/i);
expect(serialized).not.toContain("undefined");
});
it("carries final sibling visibility and order without stylesheet source", async () => {
const data = buildData();
data.basics.email = "ada@example.com";
data.basics.phone = "+44 123";
data.basics.location = "London";
const applied = {
languageVersion: 1,
text: `@version 1;
contact-item[name="location"] { display: none; }
contact-item[name="phone"] { order: -1; }
`,
};
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
const projection = await createPublicStyleProjection({ data });
expect(projection.nodes["page-1/region-header/header/contact-list/contact-location"]).toMatchObject({
hidden: true,
});
expect(projection.nodes["page-1/region-header/header/contact-list/contact-phone"]).toMatchObject({ order: 0 });
expect(projection.nodes["page-1/region-header/header/contact-list/contact-email"]).toMatchObject({ order: 1 });
});
it("rejects changed nodes and every version or fingerprint mismatch", async () => {
const data = buildData();
const valid = await createPublicStyleProjection({ data });
const cases = [
{ ...valid, formatVersion: 2 },
{ ...valid, languageVersion: 2 },
{ ...valid, semanticTreeVersion: 2 },
{ ...valid, registryFingerprint: "0".repeat(64) },
{ ...valid, adapterFingerprint: "0".repeat(64) },
{ ...valid, renderDataHash: "0".repeat(64) },
{
...valid,
nodes: {
...valid.nodes,
"page-1/region-header/header/name": { style: { color: "#ff0000" } },
},
},
];
for (const projection of cases) {
await expect(validatePublicStyleProjection(data, projection as unknown as PublicStyleProjection)).resolves.toBe(
false,
);
}
});
it("rejects projections hashed for different public render data", async () => {
const data = buildData();
const projection = await createPublicStyleProjection({ data });
const changed = structuredClone(data);
changed.basics.name = "Grace Hopper";
await expect(validatePublicStyleProjection(changed, projection)).resolves.toBe(false);
});
it("changes the projection hash when only the applied presentation changes", async () => {
const red = buildData();
const blue = buildData();
const blueApplied = {
languageVersion: 1,
text: "@version 1;\nname { color: #654321; }\n",
};
blue.metadata.stylesheet = { mode: "semantic", source: blueApplied, applied: blueApplied };
const redProjection = await createPublicStyleProjection({ data: red });
const blueProjection = await createPublicStyleProjection({ data: blue });
expect(redProjection.nodes["page-1/region-header/header/name"]).not.toEqual(
blueProjection.nodes["page-1/region-header/header/name"],
);
expect(redProjection.renderDataHash).not.toBe(blueProjection.renderDataHash);
});
it("rejects extra or non-JSON node fields instead of exposing compiler internals", async () => {
const data = buildData();
const projection = await createPublicStyleProjection({ data });
const node = projection.nodes["page-1/region-header/header/name"];
await expect(
validatePublicStyleProjection(data, {
...projection,
nodes: {
...projection.nodes,
"page-1/region-header/header/name": { ...node, diagnostics: [{ message: "private" }] },
},
} as unknown as PublicStyleProjection),
).resolves.toBe(false);
});
});
@@ -0,0 +1,369 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ResolvedPdfNodePresentation } from "./adapter";
import type { ResolvedResumeRuntime } from "./resolve";
import {
computeRenderDataHash,
PROPERTY_REGISTRY_V1,
projectPublicRenderData,
SEMANTIC_REGISTRY_V1,
SUPPORTED_SEMANTIC_CSS_VERSIONS,
TEMPLATE_PART_CHILD_KINDS_V1,
} from "@reactive-resume/resume/stylesheet";
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
import { resolveResumeRuntime, resolveStylesheetMode } from "./resolve";
import { getTemplateSemanticRegistryFingerprintInput } from "./template-manifest";
export const PUBLIC_STYLE_PROJECTION_FORMAT_VERSION = 1;
export const SEMANTIC_TREE_VERSION = 1;
const PDF_ADAPTER_VERSION = 1;
const REACT_PDF_RENDERER_VERSION = "4.5";
type PublicPdfStyleValue = string | number | null;
type PublicPdfPageSize = "A4" | "LETTER" | { width: number; height?: number };
export type PublicPdfNodePresentation = {
style?: Readonly<Record<string, PublicPdfStyleValue>>;
size?: PublicPdfPageSize;
break?: boolean;
wrap?: boolean;
fixed?: boolean;
minPresenceAhead?: number;
orphans?: number;
widows?: number;
hidden?: boolean;
order?: number;
};
export type PublicStyleProjection = {
formatVersion: typeof PUBLIC_STYLE_PROJECTION_FORMAT_VERSION;
languageVersion: number;
semanticTreeVersion: typeof SEMANTIC_TREE_VERSION;
registryFingerprint: string;
adapterFingerprint: string;
renderDataHash: string;
nodes: Readonly<Record<string, PublicPdfNodePresentation>>;
};
type ProjectionFingerprints = Pick<
PublicStyleProjection,
"formatVersion" | "languageVersion" | "semanticTreeVersion" | "registryFingerprint" | "adapterFingerprint"
>;
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
const prototype = Object.getPrototypeOf(value);
return prototype === Object.prototype || prototype === null;
};
const hasExactKeys = (value: Record<string, unknown>, allowed: readonly string[]): boolean =>
Object.keys(value).every((key) => allowed.includes(key)) && Object.getOwnPropertySymbols(value).length === 0;
const finiteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
const isPageSize = (value: unknown): value is PublicPdfPageSize => {
if (value === "A4" || value === "LETTER") return true;
if (!isPlainObject(value) || !hasExactKeys(value, ["width", "height"]) || !finiteNumber(value.width)) return false;
return value.height === undefined || finiteNumber(value.height);
};
const isPublicNode = (value: unknown): value is PublicPdfNodePresentation => {
if (
!isPlainObject(value) ||
!hasExactKeys(value, [
"style",
"size",
"break",
"wrap",
"fixed",
"minPresenceAhead",
"orphans",
"widows",
"hidden",
"order",
])
) {
return false;
}
if (value.style !== undefined) {
if (!isPlainObject(value.style)) return false;
for (const styleValue of Object.values(value.style)) {
if (styleValue !== null && typeof styleValue !== "string" && !finiteNumber(styleValue)) {
return false;
}
}
}
if (value.size !== undefined && !isPageSize(value.size)) return false;
for (const key of ["break", "wrap", "fixed"] as const) {
if (value[key] !== undefined && typeof value[key] !== "boolean") return false;
}
for (const key of ["minPresenceAhead", "orphans", "widows"] as const) {
if (value[key] !== undefined && !finiteNumber(value[key])) return false;
}
if (value.hidden !== undefined && typeof value.hidden !== "boolean") return false;
if (value.order !== undefined && (!Number.isInteger(value.order) || (value.order as number) < 0)) return false;
return true;
};
const isProjectionShape = (value: unknown): value is PublicStyleProjection => {
if (
!isPlainObject(value) ||
!hasExactKeys(value, [
"formatVersion",
"languageVersion",
"semanticTreeVersion",
"registryFingerprint",
"adapterFingerprint",
"renderDataHash",
"nodes",
]) ||
!Number.isInteger(value.formatVersion) ||
!Number.isInteger(value.languageVersion) ||
!Number.isInteger(value.semanticTreeVersion) ||
typeof value.registryFingerprint !== "string" ||
typeof value.adapterFingerprint !== "string" ||
typeof value.renderDataHash !== "string" ||
!isPlainObject(value.nodes)
) {
return false;
}
return Object.values(value.nodes).every(isPublicNode);
};
type PublicNodeStructure = Pick<PublicPdfNodePresentation, "hidden" | "order">;
const toPublicNode = (
presentation: ResolvedPdfNodePresentation,
structure: PublicNodeStructure,
): PublicPdfNodePresentation => ({
...(presentation.style
? {
style: Object.freeze(
Object.fromEntries(
Object.entries(presentation.style).map(([property, value]) => [
property,
value === undefined ? null : (value as string | number),
]),
),
),
}
: {}),
...(presentation.size === undefined ? {} : { size: presentation.size }),
...(presentation.break === undefined ? {} : { break: presentation.break }),
...(presentation.wrap === undefined ? {} : { wrap: presentation.wrap }),
...(presentation.fixed === undefined ? {} : { fixed: presentation.fixed }),
...(presentation.minPresenceAhead === undefined ? {} : { minPresenceAhead: presentation.minPresenceAhead }),
...(presentation.orphans === undefined ? {} : { orphans: presentation.orphans }),
...(presentation.widows === undefined ? {} : { widows: presentation.widows }),
...(structure.hidden === undefined ? {} : { hidden: structure.hidden }),
...(structure.order === undefined ? {} : { order: structure.order }),
});
const indexChildren = (tree: SemanticNode) => {
const children = new Map<string, readonly string[]>();
const visit = (node: SemanticNode) => {
children.set(
node.key,
node.children.map(({ key }) => key),
);
for (const child of node.children) visit(child);
};
visit(tree);
return children;
};
const projectNodeStructure = (
sourceTree: SemanticNode,
renderTree: SemanticNode,
): Readonly<Record<string, PublicNodeStructure>> => {
const renderedChildren = indexChildren(renderTree);
const structure: Record<string, PublicNodeStructure> = {};
const visit = (node: SemanticNode) => {
const rendered = renderedChildren.get(node.key) ?? [];
const order = new Map(rendered.map((key, index) => [key, index]));
for (const [sourceIndex, child] of node.children.entries()) {
const renderedIndex = order.get(child.key);
structure[child.key] =
renderedIndex === undefined ? { hidden: true } : renderedIndex === sourceIndex ? {} : { order: renderedIndex };
visit(child);
}
};
visit(sourceTree);
return structure;
};
const fingerprints = Promise.all([
computeRenderDataHash({
domainVersion: 1,
data: {
semanticTreeVersion: SEMANTIC_TREE_VERSION,
semanticRegistry: SEMANTIC_REGISTRY_V1,
templateParts: TEMPLATE_PART_CHILD_KINDS_V1,
templateManifests: getTemplateSemanticRegistryFingerprintInput(),
},
}),
computeRenderDataHash({
domainVersion: 1,
data: {
adapterVersion: PDF_ADAPTER_VERSION,
reactPdfRendererVersion: REACT_PDF_RENDERER_VERSION,
propertyRegistry: PROPERTY_REGISTRY_V1,
},
}),
]);
const getFingerprints = async () => {
const [registryFingerprint, adapterFingerprint] = await fingerprints;
return { registryFingerprint, adapterFingerprint };
};
export const getPublicStyleProjectionFingerprints = getFingerprints;
const dataForPublicProjection = (data: ResumeData, languageVersion: number): ResumeData => {
if (data.metadata.stylesheet?.mode === "semantic") return data;
const source = { languageVersion, text: EMPTY_SEMANTIC_CSS_SOURCE };
return {
...data,
metadata: {
...data.metadata,
stylesheet: { mode: "semantic", source, applied: source },
},
};
};
const projectionFingerprints = async (data: ResumeData, languageVersion?: number): Promise<ProjectionFingerprints> => ({
formatVersion: PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
languageVersion:
languageVersion ??
(data.metadata.stylesheet?.mode === "semantic" ? data.metadata.stylesheet.applied.languageVersion : 1),
semanticTreeVersion: SEMANTIC_TREE_VERSION,
...(await getFingerprints()),
});
const hashProjection = (
data: ResumeData,
nodes: PublicStyleProjection["nodes"],
projection: ProjectionFingerprints,
): Promise<string> =>
computeRenderDataHash({
domainVersion: 1,
data: projectPublicRenderData(data),
resolvedNodes: nodes,
projectionFingerprints: projection,
});
export async function createPublicStyleProjection(input: { data: ResumeData }): Promise<PublicStyleProjection> {
const runtime = resolveResumeRuntime({
data: input.data,
template: input.data.metadata.template,
mode: resolveStylesheetMode(input.data),
});
if (runtime.diagnostics.some(({ severity }) => severity === "error")) {
throw new Error("Applied semantic stylesheet cannot be projected");
}
const structure = projectNodeStructure(runtime.sourceTree, runtime.renderTree);
const nodes = Object.freeze(
Object.fromEntries(
Object.entries(runtime.presentation).map(([nodeKey, presentation]) => [
nodeKey,
toPublicNode(presentation, structure[nodeKey] ?? {}),
]),
),
);
const projection = await projectionFingerprints(input.data);
return Object.freeze({
...projection,
renderDataHash: await hashProjection(input.data, nodes, projection),
nodes,
});
}
export async function validatePublicStyleProjection(
data: ResumeData,
projection: PublicStyleProjection,
): Promise<boolean> {
if (!isProjectionShape(projection)) return false;
if (!SUPPORTED_SEMANTIC_CSS_VERSIONS.includes(projection.languageVersion as 1)) return false;
if (
data.metadata.stylesheet?.mode === "semantic" &&
projection.languageVersion !== data.metadata.stylesheet.applied.languageVersion
) {
return false;
}
const projectionData = dataForPublicProjection(data, projection.languageVersion);
const expected = await projectionFingerprints(projectionData, projection.languageVersion);
if (
projection.formatVersion !== expected.formatVersion ||
projection.languageVersion !== expected.languageVersion ||
projection.semanticTreeVersion !== expected.semanticTreeVersion ||
projection.registryFingerprint !== expected.registryFingerprint ||
projection.adapterFingerprint !== expected.adapterFingerprint
) {
return false;
}
try {
return projection.renderDataHash === (await hashProjection(projectionData, projection.nodes, expected));
} catch {
return false;
}
}
const toResolvedPresentation = (
nodes: PublicStyleProjection["nodes"],
): Readonly<Record<string, ResolvedPdfNodePresentation>> =>
Object.freeze(
Object.fromEntries(
Object.entries(nodes).map(([nodeKey, { hidden: _hidden, order: _order, style, ...presentation }]) => [
nodeKey,
{
...presentation,
...(style
? {
style: Object.fromEntries(
Object.entries(style).map(([property, value]) => [property, value === null ? undefined : value]),
) as NonNullable<ResolvedPdfNodePresentation["style"]>,
}
: {}),
},
]),
),
);
const applyProjectedStructure = (node: SemanticNode, nodes: PublicStyleProjection["nodes"]): SemanticNode => ({
...node,
attributes: { ...node.attributes },
roles: [...node.roles],
children: node.children
.map((child, sourceIndex) => ({ child, sourceIndex, structure: nodes[child.key] }))
.filter(({ structure }) => !structure?.hidden)
.sort(
(left, right) =>
(left.structure?.order ?? left.sourceIndex) - (right.structure?.order ?? right.sourceIndex) ||
left.sourceIndex - right.sourceIndex,
)
.map(({ child }) => applyProjectedStructure(child, nodes)),
});
export async function resolvePublicStyleProjectionRuntime(
data: ResumeData,
projection: PublicStyleProjection,
): Promise<ResolvedResumeRuntime> {
if (!(await validatePublicStyleProjection(data, projection))) {
throw new Error("Public style projection does not match the resume render data");
}
const projectionData = dataForPublicProjection(data, projection.languageVersion);
const base = resolveResumeRuntime({
data: projectionData,
template: projectionData.metadata.template,
mode: "legacy",
});
return {
presentation: toResolvedPresentation(projection.nodes),
sourceTree: base.sourceTree,
renderTree: applyProjectedStructure(base.sourceTree, projection.nodes),
diagnostics: [],
};
}
+16
View File
@@ -0,0 +1,16 @@
import { describe, expect, it } from "vitest";
import { inspectResumePdf } from "@reactive-resume/pdf/semantic";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
describe("@reactive-resume/pdf/semantic", () => {
it("publicly exposes invalid applied-source diagnostics", () => {
const data = structuredClone(sampleResumeData);
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
const inspection = inspectResumePdf({ data });
expect(inspection.diagnostics).toContainEqual(expect.objectContaining({ severity: "error" }));
expect(inspection.presentation).toEqual({});
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { ResolveStylesheetResult, SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { StylesheetMode, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Template } from "@reactive-resume/schema/templates";
import type { ResolvedResumePresentation } from "./context";
import { compileStylesheet, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
import { shouldShowResumeHeader } from "../templates/shared/cover-letter";
import { getTemplatePageSize } from "../templates/shared/page-size";
import { adaptResolvedPdfNode } from "./adapter";
import { buildPdfBaseStyles } from "./base-styles";
import { createBindingInventory } from "./binding-inventory";
import { semanticNodeKeys } from "./node-keys";
import { getTemplateSemanticBindingRegistry } from "./template-manifest";
import { buildSemanticTree } from "./tree";
export type ResolveResumePresentationInput = {
data: ResumeData;
template: Template;
applied?: StylesheetSource;
mode: StylesheetMode;
};
export type ResolvedResumeRuntime = {
presentation: ResolvedResumePresentation;
sourceTree: SemanticNode;
renderTree: SemanticNode;
diagnostics: readonly SemanticCssDiagnostic[];
};
const EMPTY_PRESENTATION = Object.freeze({}) satisfies ResolvedResumePresentation;
const mergeAuthoredPageTrees = (data: ResumeData, template: Template): SemanticNode => {
const pageTrees = data.metadata.layout.pages.map((page, index) =>
buildSemanticTree({
data,
template,
page,
pageNumber: index + 1,
showHeader: shouldShowResumeHeader(data, index),
}),
);
const root = pageTrees[0];
return {
key: semanticNodeKeys.resume(),
kind: "resume",
attributes: { template },
roles: [],
children: pageTrees.flatMap((tree) => tree.children),
...(root?.id === undefined ? {} : { id: root.id }),
};
};
const authoredPageDimensions = (data: ResumeData) => {
const size = getTemplatePageSize(data.metadata.page.format);
const dimensions =
size === "LETTER"
? { width: 612, height: 792 }
: typeof size === "object"
? { width: size.width, height: 841.89 }
: { width: 595.28, height: 841.89 };
return data.metadata.layout.pages.map((_page, index) => ({
pageKey: semanticNodeKeys.page(index + 1),
...dimensions,
}));
};
const toPresentation = (
resolved: ResolveStylesheetResult["nodes"],
base: ReturnType<typeof buildPdfBaseStyles>,
): ResolvedResumePresentation => {
return Object.freeze(
Object.fromEntries(
Object.entries(resolved).map(([nodeKey, node]) => [nodeKey, adaptResolvedPdfNode(node, base[nodeKey])]),
),
) as ResolvedResumePresentation;
};
export function resolveStylesheetMode(data: ResumeData): StylesheetMode {
return data.metadata.stylesheet?.mode ?? "legacy";
}
export function resolveResumeRuntime({
data,
template,
applied,
mode,
}: ResolveResumePresentationInput): ResolvedResumeRuntime {
const sourceTree = mergeAuthoredPageTrees(data, template);
if (mode !== "semantic") {
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: [] };
}
const source = applied ??
data.metadata.stylesheet?.applied ?? {
languageVersion: 1,
text: EMPTY_SEMANTIC_CSS_SOURCE,
};
const compiled = compileStylesheet(source);
if (!compiled.program) {
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: compiled.diagnostics };
}
const baseStyles = buildPdfBaseStyles({ data, template, tree: sourceTree });
const inventory = createBindingInventory(sourceTree, getTemplateSemanticBindingRegistry(template));
const aliases: Record<string, string[]> = {};
for (const [aliasKey, binding] of Object.entries(inventory.bindings)) {
if (binding.type !== "alias") continue;
aliases[binding.canonicalNodeKey] = [...(aliases[binding.canonicalNodeKey] ?? []), aliasKey];
}
const resolved = resolveStylesheet(compiled.program, sourceTree, {
baseStyles,
baseSettings: {
picture: data.picture,
template,
design: data.metadata.design,
typography: data.metadata.typography,
page: data.metadata.page,
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
},
pages: authoredPageDimensions(data),
aliases,
});
if (resolved.diagnostics.some(({ severity }) => severity === "error")) {
return {
presentation: EMPTY_PRESENTATION,
sourceTree,
renderTree: sourceTree,
diagnostics: [...compiled.diagnostics, ...resolved.diagnostics],
};
}
return {
presentation: toPresentation(resolved.nodes, baseStyles),
sourceTree,
renderTree: resolved.renderTree,
diagnostics: [...compiled.diagnostics, ...resolved.diagnostics],
};
}
export function resolveResumePresentation(input: ResolveResumePresentationInput): ResolvedResumePresentation {
return resolveResumeRuntime(input).presentation;
}
@@ -0,0 +1,139 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it, vi } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { parseNormalizedRichTextHtml, richTextMarkClassName } from "../templates/shared/rich-text-html";
import { getRichTextSemanticNodeKey } from "./rich-text-keys";
import { buildSemanticTree } from "./tree";
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
}));
type HostNode = {
type: string;
style?: unknown;
props?: { style?: unknown };
children?: HostNode[];
};
const collectStyles = (node: HostNode): Record<string, unknown>[] => {
const style = node.style ?? node.props?.style;
const current = Array.isArray(style) ? style : style ? [style] : [];
return [...(current as Record<string, unknown>[]), ...(node.children ?? []).flatMap(collectStyles)];
};
const collectNodeKinds = (node: SemanticNode): SemanticNode["kind"][] => [
node.kind,
...node.children.flatMap(collectNodeKinds),
];
const collectNodeKeys = (node: SemanticNode): string[] => [node.key, ...node.children.flatMap(collectNodeKeys)];
const findSemanticNode = (
node: SemanticNode,
predicate: (candidate: SemanticNode) => boolean,
): SemanticNode | undefined => {
if (predicate(node)) return node;
for (const child of node.children) {
const match = findSemanticNode(child, predicate);
if (match) return match;
}
};
const buildFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
const applied = {
languageVersion: 1,
text: `
@version 1;
paragraph { color: #123456; }
strong { color: #654321; }
list-marker { color: #abcdef; }
`,
};
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.summary.content = "<p>First <strong>bold</strong></p><ul><li>Item</li></ul>";
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
return data;
};
describe("semantic rich-text bindings", () => {
it("applies occurrence-specific paragraph, strong, and marker styles to existing primitives", async () => {
const element = createElement(ResumeDocument, {
data: buildFixture(),
template: "onyx",
}) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
const colors = collectStyles(instance.container.document as HostNode).map(({ color }) => color);
expect(colors).toEqual(expect.arrayContaining(["#123456", "#654321", "#abcdef"]));
});
it.each([
["ltr", "en-US", "<p>- Alpha<br>- Beta</p>", ["paragraph", "hard-break"]],
["rtl", "he-IL", "<p>- אלפא<br>- בטא</p>", ["list", "list-item", "list-marker", "list-item-content"]],
] as const)(
"builds the %s descriptor from the same normalized rich-text elements rendered by the PDF",
(_direction, locale, content, expectedKinds) => {
const data = buildFixture();
data.metadata.page.locale = locale;
data.summary.content = content;
const page = data.metadata.layout.pages[0];
if (!page) throw new Error("Expected a page fixture.");
const tree = buildSemanticTree({
data,
template: "onyx",
page,
pageNumber: 1,
showHeader: false,
});
const kinds = collectNodeKinds(tree);
for (const kind of expectedKinds) expect(kinds).toContain(kind);
if (locale === "he-IL") {
expect(kinds).not.toContain("paragraph");
expect(kinds).not.toContain("hard-break");
}
},
);
it.each([
["ltr", "en-US", ""],
["rtl", "he-IL", "<p>- אלפא<br>- בטא</p>"],
] as const)("keeps every %s descriptor key aligned with the renderer traversal", (direction, locale, suffix) => {
const content = [
"<h2>Heading</h2>",
"<blockquote><p>Quote <strong>bold</strong> <em>em</em> <u>u</u> <s>s</s> <code>c</code>",
'<span>span</span> <mark>mark</mark> <a href="https://example.com">link</a><br>next</p></blockquote>',
"<ul><li>Item</li></ul><ol><li>One</li></ol><hr>",
suffix,
].join("");
const data = buildFixture();
data.metadata.page.locale = locale;
data.summary.content = content;
const page = data.metadata.layout.pages[0];
if (!page) throw new Error("Expected a page fixture.");
const tree = buildSemanticTree({ data, template: "onyx", page, pageNumber: 1, showHeader: false });
const richText = findSemanticNode(tree, (candidate) => candidate.kind === "rich-text");
if (!richText) throw new Error("Expected a rich-text descriptor.");
const parsed = parseNormalizedRichTextHtml(content, { direction });
const rendererKeys = parsed.querySelectorAll("*").flatMap((element) => {
const key = getRichTextSemanticNodeKey(richText.key, element, richTextMarkClassName);
if (element.rawTagName.toLowerCase() !== "li") return [key];
return [`${key}/list-marker-0`, `${key}/list-item-content-0`, key];
});
expect(new Set(rendererKeys)).toEqual(new Set(collectNodeKeys(richText).slice(1)));
});
});
@@ -0,0 +1,72 @@
import type { SemanticNodeKind } from "@reactive-resume/resume/stylesheet";
import { semanticNodeKeys } from "./node-keys";
export type RichTextElement = {
nodeType: number;
rawTagName: string;
parentNode?: unknown;
childNodes: readonly unknown[];
getAttribute: (name: string) => string | undefined;
};
const isElement = (node: unknown): node is RichTextElement =>
typeof node === "object" &&
node !== null &&
"nodeType" in node &&
(node as { nodeType: number }).nodeType === 1 &&
"rawTagName" in node;
export const getRichTextSemanticKind = (
element: RichTextElement,
markClassName: string,
): SemanticNodeKind | undefined => {
const tag = element.rawTagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) return "rich-heading";
if (tag === "p") return "paragraph";
if (tag === "blockquote") return "blockquote";
if (tag === "ul" || tag === "ol") return "list";
if (tag === "li") return "list-item";
if (tag === "a") return "link";
if (tag === "strong" || tag === "b") return "strong";
if (tag === "em" || tag === "i") return "emphasis";
if (tag === "u") return "underline";
if (tag === "s" || tag === "strike") return "strike";
if (tag === "code") return "code";
if (tag === "br") return "hard-break";
if (tag === "hr") return "horizontal-rule";
if (tag === "mark") return "mark";
if (tag === "span") {
return element.getAttribute("class")?.split(/\s+/).includes(markClassName) ? "mark" : "text-span";
}
return undefined;
};
export const getRichTextSemanticNodeKey = (
rootNodeKey: string,
element: RichTextElement,
markClassName: string,
): string => {
const ancestry: RichTextElement[] = [];
let current: RichTextElement | undefined = element;
while (current?.rawTagName) {
ancestry.push(current);
current = isElement(current.parentNode) ? current.parentNode : undefined;
}
let nodeKey = rootNodeKey;
for (const [ancestryIndex, ancestor] of ancestry.reverse().entries()) {
const parent = ancestor.parentNode;
const elementIndex = isElement(parent) ? parent.childNodes.filter(isElement).indexOf(ancestor) : 0;
const kind = getRichTextSemanticKind(ancestor, markClassName);
nodeKey = semanticNodeKeys.richTextNode(nodeKey, kind ?? `html-${ancestor.rawTagName.toLowerCase()}`, elementIndex);
if (kind === "list-item" && ancestryIndex < ancestry.length - 1) {
nodeKey = semanticNodeKeys.richTextNode(nodeKey, "list-item-content", 0);
}
}
return nodeKey;
};
@@ -0,0 +1,136 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { Buffer } from "node:buffer";
import { describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumePdfBlob } from "../browser";
import { createResumePdfFile } from "../server";
const captured = vi.hoisted(() => ({
browser: undefined as unknown,
server: undefined as unknown,
}));
vi.mock("#react-pdf-renderer", async (importOriginal) => ({
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
pdf: (element: unknown) => {
captured.browser = element;
return { toBlob: async () => new Blob(["%PDF"], { type: "application/pdf" }) };
},
renderToBuffer: (element: unknown) => {
captured.server = element;
return Promise.resolve(Buffer.from("%PDF"));
},
}));
type HostNode = {
type: string;
props?: Readonly<Record<string, unknown>>;
style?: unknown;
children?: HostNode[];
};
const findFirst = (node: HostNode, predicate: (candidate: HostNode) => boolean): HostNode | undefined => {
if (predicate(node)) return node;
for (const child of node.children ?? []) {
const match = findFirst(child, predicate);
if (match) return match;
}
};
const buildFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
const applied = {
languageVersion: 1,
text: `
@version 1;
page { size: LETTER; }
header { -resume-fixed: true; background-color: #1e293b; }
`,
};
data.picture.hidden = true;
data.basics.name = "Ada Lovelace";
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
return data;
};
const buildNodeBudgetFixture = (mode: "legacy" | "semantic"): ResumeData => {
const data = structuredClone(defaultResumeData);
const applied = { languageVersion: 1, text: "@version 1;\n" };
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
data.metadata.stylesheet = { mode, source: applied, applied };
data.sections.skills.items = Array.from({ length: 2_000 }, (_, index) => ({
id: `skill-${index}`,
hidden: false,
icon: "",
iconColor: "",
name: `Skill ${index}`,
proficiency: "Advanced",
level: 5,
keywords: ["TypeScript"],
}));
return data;
};
const renderFinalProps = async (element: unknown) => {
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
const instance = renderer.pdf(element as Parameters<typeof renderer.pdf>[0]);
await vi.waitFor(() => expect(instance.container.document).not.toBeNull(), { timeout: 15_000 });
const document = instance.container.document as HostNode;
const page = findFirst(document, ({ type }) => type === "PAGE");
const fixed = findFirst(document, ({ props }) => props?.fixed === true);
return {
page: { size: page?.props?.size, style: page?.style },
fixed: { type: fixed?.type, fixed: fixed?.props?.fixed, style: fixed?.style },
};
};
describe("browser/server semantic runtime identity", () => {
it("delivers identical final primitive props through ResumeDocument", async () => {
const data = buildFixture();
await createResumePdfBlob({ data, template: "onyx" });
await createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" });
const browserProps = await renderFinalProps(captured.browser);
const serverProps = await renderFinalProps(captured.server);
expect(browserProps).toEqual(serverProps);
expect(browserProps.page.size).toBe("LETTER");
expect(browserProps.fixed).toMatchObject({ type: "VIEW", fixed: true });
}, 30_000);
it("rejects a valid stylesheet when later content exceeds the Semantic CSS node budget", async () => {
const data = buildNodeBudgetFixture("semantic");
const results = await Promise.allSettled([
createResumePdfBlob({ data, template: "onyx" }),
createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" }),
]);
expect(results).toEqual([
expect.objectContaining({
status: "rejected",
reason: expect.objectContaining({
cause: expect.arrayContaining([expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })]),
}),
}),
expect.objectContaining({
status: "rejected",
reason: expect.objectContaining({
cause: expect.arrayContaining([expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })]),
}),
}),
]);
}, 15_000);
it("keeps legacy PDF rendering unaffected by the semantic node budget", async () => {
const data = buildNodeBudgetFixture("legacy");
const blob = await createResumePdfBlob({ data, template: "onyx" });
const file = await createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" });
expect(blob.type).toBe("application/pdf");
expect(file.type).toBe("application/pdf");
}, 15_000);
});
@@ -0,0 +1,262 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import { describe, expect, it } from "vitest";
import { pdf } from "@react-pdf/renderer";
import { createElement } from "react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { ResumeDocument } from "../document";
import { resolveResumeRuntime } from "./resolve";
type HostNode = {
type: string;
value?: string;
children?: HostNode[];
};
const textValues = (node: HostNode): string[] => [
...(node.type === "TEXT_INSTANCE" && node.value ? [node.value] : []),
...(node.children ?? []).flatMap((child) => textValues(child)),
];
const findSemanticNode = (
node: SemanticNode,
predicate: (candidate: SemanticNode) => boolean,
): SemanticNode | undefined => {
if (predicate(node)) return node;
for (const child of node.children) {
const match = findSemanticNode(child, predicate);
if (match) return match;
}
};
const renderTextValues = async (data: ResumeData, template: Template): Promise<string[]> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[0];
const instance = pdf(element);
await expect.poll(() => instance.container.document).not.toBeNull();
return textValues(instance.container.document as HostNode);
};
const semanticFixture = (rule: string): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.hidden = true;
data.basics = {
name: "Ada Lovelace",
headline: "",
email: "",
phone: "",
location: "",
website: { url: "", label: "" },
customFields: [],
};
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
return data;
};
const expectBefore = (values: readonly string[], first: string, second: string) => {
const firstIndex = values.indexOf(first);
const secondIndex = values.indexOf(second);
expect(firstIndex, `${first} is present`).toBeGreaterThanOrEqual(0);
expect(secondIndex, `${second} is present`).toBeGreaterThanOrEqual(0);
expect(firstIndex, `${first} before ${second}`).toBeLessThan(secondIndex);
};
describe("semantic sibling ordering reaches final PDF output", () => {
it("projects contact-list hide and order onto existing contact siblings", async () => {
const data = semanticFixture(`
contact-item[name="location"] { display: none; }
contact-item[name="phone"] { order: -1; }
`);
data.basics.email = "ada@example.com";
data.basics.phone = "+44 123";
data.basics.location = "London";
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
const values = await renderTextValues(data, "onyx");
expectBefore(values, "+44 123", "ada@example.com");
expect(values).not.toContain("London");
});
it("projects Chikorita contacts only within their two existing row hosts", async () => {
const data = semanticFixture(`
template-part[name="contact-row-secondary"] { order: -1; }
contact-item[name="location"] { display: none; }
contact-item[name="phone"] { order: -1; }
contact-item[name="custom"] { order: -1; }
`);
data.basics.email = "ada@example.com";
data.basics.phone = "+44 123";
data.basics.location = "London";
data.basics.website = { url: "https://example.com", label: "example.com" };
data.basics.customFields = [{ id: "custom-1", icon: "", text: "portfolio.example", link: "" }];
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
const values = await renderTextValues(data, "chikorita");
expectBefore(values, "portfolio.example", "example.com");
expectBefore(values, "example.com", "+44 123");
expectBefore(values, "+44 123", "ada@example.com");
expect(values).not.toContain("London");
});
it("projects nested-role hide and order across the complete direct item sequence", async () => {
const data = semanticFixture(`
item[id="role-2"] { display: none; }
item[id="role-3"] { order: -1; }
`);
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "",
location: "",
period: "",
website: { url: "https://example.com/company", label: "Company site", inlineLink: false },
description: "",
roles: [
{ id: "role-1", position: "First role", period: "", description: "" },
{ id: "role-2", position: "Hidden role", period: "", description: "" },
{ id: "role-3", position: "Last role", period: "", description: "" },
],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }];
const values = await renderTextValues(data, "onyx");
expectBefore(values, "Last role", "Analytical Engines");
expectBefore(values, "Company site", "First role");
expect(values).not.toContain("Hidden role");
});
it("projects hide and order across Meowth's existing inline-header part siblings", async () => {
const data = semanticFixture(`
template-part[name="inline-item-header-leading"] { display: none; }
template-part[name="inline-item-header-trailing"] { order: -1; }
`);
data.sections.experience.items = [
{
id: "experience-1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["experience"], sidebar: [] }];
const values = await renderTextValues(data, "meowth");
expectBefore(values, "1842", "Analytical Engines");
expect(values).not.toContain("Engineer");
expect(values).not.toContain("London");
});
it("projects Meowth's education grade row at the item boundary and its fields within that row", async () => {
const data = semanticFixture(`
template-part[name="education-grade-row"] { order: -1; }
field[name="location"] { order: -1; }
`);
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
area: "Mathematics",
degree: "BSc",
grade: "First",
location: "London",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["education"], sidebar: [] }];
const values = await renderTextValues(data, "meowth");
expectBefore(values, "London", "First");
expectBefore(values, "First", "Mathematics");
expectBefore(values, "First", "University of London");
});
it("reorders Meowth inline-header parts without dropping untouched combined fields", async () => {
const data = semanticFixture(`
template-part[name="inline-item-header-trailing"] { order: -1; }
`);
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
area: "Mathematics",
degree: "BSc",
grade: "",
location: "",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
data.metadata.layout.pages = [{ fullWidth: true, main: ["education"], sidebar: [] }];
const values = await renderTextValues(data, "meowth");
expectBefore(values, "1835", "Mathematics");
expectBefore(values, "Mathematics", "BSc");
expectBefore(values, "BSc", "University of London");
});
it("projects rich-text descendant hide and order before renderer mapping", async () => {
const data = semanticFixture(`
rich-text > list { display: none; }
paragraph > underline { order: -1; }
`);
data.summary.content = "<ul><li>Hidden run</li></ul><p><strong>First run</strong><u>Last run</u></p>";
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
const values = await renderTextValues(data, "onyx");
expectBefore(values, "Last run", "First run");
expect(values).not.toContain("Hidden run");
});
it.each([
["en-US", "list-marker { display: none; }", "List content", "•"],
["ar-SA", "list-marker { order: -1; }", "•", "List content"],
] as const)(
"projects %s rich-list marker/content keys onto the existing row children",
async (locale, rule, first, second) => {
const data = semanticFixture(rule);
data.metadata.page.locale = locale;
data.summary.content = "<ul><li>List content</li></ul>";
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
const runtime = resolveResumeRuntime({ data, template: "onyx", mode: "semantic" });
const renderedItem = findSemanticNode(runtime.renderTree, (node) => node.kind === "list-item");
expect(runtime.diagnostics.filter(({ severity }) => severity === "error")).toEqual([]);
expect(renderedItem?.children.map(({ kind }) => kind)).toEqual(
rule.includes("display") ? ["list-item-content"] : ["list-marker", "list-item-content"],
);
const values = await renderTextValues(data, "onyx");
if (rule.includes("display")) {
expect(values).toContain(first);
expect(values).not.toContain(second);
} else {
const firstIndex = values.indexOf(first);
const secondIndex = values.findIndex((value) => value.includes(second));
expect(firstIndex).toBeGreaterThanOrEqual(0);
expect(secondIndex).toBeGreaterThanOrEqual(0);
expect(firstIndex).toBeLessThan(secondIndex);
}
},
);
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,384 @@
import type { SemanticNodeKind } from "@reactive-resume/resume/stylesheet/types";
import type { CustomSectionType } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { PrimitiveBinding, SemanticBindingRegistry } from "./binding-inventory";
import { templateSchema } from "@reactive-resume/schema/templates";
import { azurillSemanticManifest } from "../templates/azurill/semantic";
import { bronzorSemanticManifest } from "../templates/bronzor/semantic";
import { chikoritaSemanticManifest } from "../templates/chikorita/semantic";
import { ditgarSemanticManifest } from "../templates/ditgar/semantic";
import { dittoSemanticManifest } from "../templates/ditto/semantic";
import { gengarSemanticManifest } from "../templates/gengar/semantic";
import { glalieSemanticManifest } from "../templates/glalie/semantic";
import { kakunaSemanticManifest } from "../templates/kakuna/semantic";
import { laprasSemanticManifest } from "../templates/lapras/semantic";
import { leafishSemanticManifest } from "../templates/leafish/semantic";
import { meowthSemanticManifest } from "../templates/meowth/semantic";
import { onyxSemanticManifest } from "../templates/onyx/semantic";
import { pikachuSemanticManifest } from "../templates/pikachu/semantic";
import { rhyhornSemanticManifest } from "../templates/rhyhorn/semantic";
import { scizorSemanticManifest } from "../templates/scizor/semantic";
import { SHARED_BINDING_REGISTRY, STANDARD_FIELD_REGISTRY } from "./binding-inventory";
export type TemplateSemanticPlacement = "main" | "sidebar";
type TemplateSemanticRegionName = "header" | "main" | "sidebar" | "featured";
export type TemplateSemanticRegion = {
name: TemplateSemanticRegionName;
placement: TemplateSemanticPlacement;
origins: readonly TemplateSemanticPlacement[];
flow?: "sequential" | "interleaved";
};
type TemplateSemanticSpecialSummary = {
region: "header" | "featured";
placement: TemplateSemanticPlacement;
source: "always" | "main-with-header";
};
type TemplateSemanticPartOwner =
| { kind: "header"; key: "header" }
| { kind: "region"; key: TemplateSemanticRegionName }
| {
kind: "section";
key: "section";
origin?: TemplateSemanticPlacement;
placement?: TemplateSemanticPlacement;
}
| {
kind: "section-items";
key: "section-items";
placement?: TemplateSemanticPlacement;
columns?: 1;
}
| {
kind: "item";
key: "item";
placement?: TemplateSemanticPlacement;
columns?: 1;
}
| {
kind: "item-header";
key: "item-header";
sectionTypes?: readonly CustomSectionType[];
}
| { kind: "contact-list"; key: "contact-list" }
| { kind: "contact-item"; key: "contact-item"; position?: "last" };
type TemplateSemanticPartBinding =
| {
type: "primitive";
primitive:
| PrimitiveBinding["primitive"]
| {
ownerRole: string;
present: PrimitiveBinding["primitive"];
absent: PrimitiveBinding["primitive"];
};
source: "existing";
}
| {
type: "alias";
canonicalKind: Exclude<SemanticNodeKind, "template-part">;
token: string;
};
export type TemplateSemanticChildSelector = {
kind: Exclude<SemanticNodeKind, "resume" | "page">;
name?: string;
sectionTypes?: readonly CustomSectionType[];
};
type TemplateSemanticPartRoute = {
parent: "owner" | string;
at: "start" | "end" | { before: TemplateSemanticChildSelector } | { after: TemplateSemanticChildSelector };
take?: "all" | readonly TemplateSemanticChildSelector[];
takeFrom?: "item-header";
};
export type TemplateSemanticPrimitivePart = {
name: string;
key: string;
owner: TemplateSemanticPartOwner;
binding: Extract<TemplateSemanticPartBinding, { type: "primitive" }>;
route: TemplateSemanticPartRoute;
};
type TemplateSemanticAliasPart = {
name: string;
key: string;
owner: TemplateSemanticPartOwner;
binding: Extract<TemplateSemanticPartBinding, { type: "alias" }>;
route?: never;
};
export type TemplateSemanticPart = TemplateSemanticPrimitivePart | TemplateSemanticAliasPart;
type TemplateSemanticCanonicalBinding = {
kind: Exclude<SemanticNodeKind, "template-part">;
binding: PrimitiveBinding;
};
export type TemplateSemanticManifest = {
template: Template;
regions: readonly TemplateSemanticRegion[];
header: {
region: "header";
placement: TemplateSemanticPlacement;
};
specialSummary: TemplateSemanticSpecialSummary | null;
parts: readonly TemplateSemanticPart[];
canonicalBindings?: readonly TemplateSemanticCanonicalBinding[];
};
const OWNER_KEYS = {
header: "header",
region: undefined,
section: "section",
"section-items": "section-items",
item: "item",
"item-header": "item-header",
"contact-list": "contact-list",
"contact-item": "contact-item",
} as const satisfies Readonly<Record<TemplateSemanticPartOwner["kind"], string | undefined>>;
const PLACEMENTS = new Set<TemplateSemanticPlacement>(["main", "sidebar"]);
const REGION_NAMES = new Set<TemplateSemanticRegionName>(["header", "main", "sidebar", "featured"]);
const PRIMITIVES = new Set<PrimitiveBinding["primitive"]>(["Document", "Page", "View", "Text", "Link", "Image", "Svg"]);
const SECTION_TYPES = new Set<CustomSectionType>(
Object.keys(STANDARD_FIELD_REGISTRY).filter(
(sectionType) => sectionType !== "experience-role",
) as CustomSectionType[],
);
const MAX_PART_DEPTH = 1;
const assert: (condition: unknown, message: string) => asserts condition = (condition, message) => {
if (!condition) throw new Error(message);
};
const isPrimitivePart = (part: TemplateSemanticPart): part is TemplateSemanticPrimitivePart =>
part.binding.type === "primitive";
const deepFreeze = <T>(value: T): Readonly<T> => {
if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value;
for (const child of Object.values(value)) deepFreeze(child);
return Object.freeze(value);
};
function validateTemplateSemanticManifestShape(manifest: TemplateSemanticManifest): void {
const regionNames = new Set<string>();
const partNames = new Set<string>();
const partKeys = new Set<string>();
assert(templateSchema.options.includes(manifest.template), `Unknown template: ${manifest.template}`);
for (const region of manifest.regions) {
assert(REGION_NAMES.has(region.name), `${manifest.template}: unknown region ${region.name}`);
assert(!regionNames.has(region.name), `${manifest.template}: duplicate region ${region.name}`);
assert(PLACEMENTS.has(region.placement), `${manifest.template}: unknown region placement ${region.placement}`);
assert(
region.origins.every((origin) => PLACEMENTS.has(origin)),
`${manifest.template}: unknown origin in region ${region.name}`,
);
assert(
region.flow !== "interleaved" || region.origins.length === 2,
`${manifest.template}: interleaved regions require two origins`,
);
regionNames.add(region.name);
}
assert(regionNames.has(manifest.header.region), `${manifest.template}: header region is not registered`);
assert(PLACEMENTS.has(manifest.header.placement), `${manifest.template}: unknown header placement`);
const headerRegion = manifest.regions.find((region) => region.name === manifest.header.region);
assert(
headerRegion?.placement === manifest.header.placement,
`${manifest.template}: header placement disagrees with region`,
);
if (manifest.specialSummary) {
const summaryRegion = manifest.regions.find((region) => region.name === manifest.specialSummary?.region);
assert(summaryRegion, `${manifest.template}: special summary region is not registered`);
assert(
summaryRegion.placement === manifest.specialSummary.placement,
`${manifest.template}: special summary placement disagrees with region`,
);
}
for (const part of manifest.parts) {
assert(!partNames.has(part.name), `${manifest.template}: duplicate part name ${part.name}`);
assert(!partKeys.has(part.key), `${manifest.template}: duplicate part key ${part.key}`);
assert(part.name.length > 0 && part.key.length > 0, `${manifest.template}: part names and keys must not be empty`);
const expectedOwnerKey = OWNER_KEYS[part.owner.kind];
if (part.owner.kind === "region") {
assert(regionNames.has(part.owner.key), `${manifest.template}: part ${part.name} owns an unknown region`);
} else {
assert(part.owner.key === expectedOwnerKey, `${manifest.template}: part ${part.name} has an invalid owner key`);
}
if ("placement" in part.owner && part.owner.placement !== undefined) {
assert(PLACEMENTS.has(part.owner.placement), `${manifest.template}: part ${part.name} has an unknown placement`);
}
if (isPrimitivePart(part)) {
assert(part.binding.source === "existing", `${manifest.template}: part ${part.name} claims a synthetic wrapper`);
const primitives =
typeof part.binding.primitive === "string"
? [part.binding.primitive]
: [part.binding.primitive.present, part.binding.primitive.absent];
assert(
primitives.every((primitive) => PRIMITIVES.has(primitive)),
`${manifest.template}: unknown primitive`,
);
assert(part.route.parent.length > 0, `${manifest.template}: part ${part.name} has no routing parent`);
if (part.route.takeFrom) {
assert(part.owner.kind === "item", `${manifest.template}: lifted part ${part.name} must be owned by an item`);
assert(
part.route.parent === "owner",
`${manifest.template}: lifted part ${part.name} must route directly under its owner`,
);
assert(Array.isArray(part.route.take), `${manifest.template}: lifted part ${part.name} requires selectors`);
}
const selectors: readonly TemplateSemanticChildSelector[] = [
...(Array.isArray(part.route.take) ? part.route.take : []),
...(typeof part.route.at === "object"
? ["before" in part.route.at ? part.route.at.before : part.route.at.after]
: []),
];
for (const selector of selectors) {
if (!selector.sectionTypes) continue;
assert(selector.sectionTypes.length > 0, `${manifest.template}: selector section types must not be empty`);
assert(
selector.sectionTypes.every((sectionType) => SECTION_TYPES.has(sectionType)),
`${manifest.template}: selector has an unknown section type`,
);
}
} else {
assert(
part.binding.canonicalKind === part.owner.kind,
`${manifest.template}: part ${part.name} aliases a non-owner primitive`,
);
assert(part.binding.token.length > 0, `${manifest.template}: part ${part.name} has an empty alias token`);
}
partNames.add(part.name);
partKeys.add(part.key);
}
for (const part of manifest.parts) {
if (!isPrimitivePart(part) || part.route.parent === "owner") continue;
const parentPart = manifest.parts.find((candidate) => candidate.name === part.route.parent);
assert(parentPart, `${manifest.template}: part ${part.name} owns an unknown parent part`);
assert(
parentPart.binding.type === "primitive",
`${manifest.template}: nested part ${part.name} requires an existing primitive parent`,
);
assert(
JSON.stringify(parentPart.owner) === JSON.stringify(part.owner),
`${manifest.template}: nested part ${part.name} disagrees with its semantic owner`,
);
}
for (const part of manifest.parts) {
if (!isPrimitivePart(part)) continue;
const seen = new Set<string>([part.name]);
let parent = part.route.parent;
let depth = 0;
while (parent !== "owner") {
assert(!seen.has(parent), `${manifest.template}: cyclic part routing`);
seen.add(parent);
const parentPart = manifest.parts.find((candidate) => candidate.name === parent);
assert(parentPart && isPrimitivePart(parentPart), `${manifest.template}: unknown part routing parent`);
depth += 1;
assert(depth <= MAX_PART_DEPTH, `${manifest.template}: unsupported part routing depth`);
parent = parentPart.route.parent;
}
}
for (const override of manifest.canonicalBindings ?? []) {
assert(override.binding.source === "existing", `${manifest.template}: canonical binding must be existing`);
assert(PRIMITIVES.has(override.binding.primitive), `${manifest.template}: canonical binding has unknown primitive`);
}
}
const TEMPLATE_SEMANTIC_MANIFESTS = {
azurill: azurillSemanticManifest,
bronzor: bronzorSemanticManifest,
chikorita: chikoritaSemanticManifest,
ditgar: ditgarSemanticManifest,
ditto: dittoSemanticManifest,
gengar: gengarSemanticManifest,
glalie: glalieSemanticManifest,
kakuna: kakunaSemanticManifest,
lapras: laprasSemanticManifest,
leafish: leafishSemanticManifest,
meowth: meowthSemanticManifest,
onyx: onyxSemanticManifest,
pikachu: pikachuSemanticManifest,
rhyhorn: rhyhornSemanticManifest,
scizor: scizorSemanticManifest,
} as const satisfies Readonly<Record<Template, TemplateSemanticManifest>>;
for (const manifest of Object.values(TEMPLATE_SEMANTIC_MANIFESTS)) validateTemplateSemanticManifestShape(manifest);
deepFreeze(TEMPLATE_SEMANTIC_MANIFESTS);
export function validateTemplateSemanticManifest(manifest: TemplateSemanticManifest): void {
validateTemplateSemanticManifestShape(manifest);
const expected = TEMPLATE_SEMANTIC_MANIFESTS[manifest.template];
assert(
JSON.stringify(manifest) === JSON.stringify(expected),
`${manifest.template}: manifest differs from its frozen renderer contract`,
);
}
export function getTemplateSemanticManifest(template: Template): TemplateSemanticManifest {
return TEMPLATE_SEMANTIC_MANIFESTS[template];
}
export function getTemplateSemanticRegistryFingerprintInput(): Readonly<Record<Template, TemplateSemanticManifest>> {
return TEMPLATE_SEMANTIC_MANIFESTS;
}
export function getTemplateSemanticBindingRegistry(template: Template): SemanticBindingRegistry {
const manifest = getTemplateSemanticManifest(template);
const canonicalBindings = Object.fromEntries(
(manifest.canonicalBindings ?? []).map(({ kind, binding }) => [kind, binding]),
) as SemanticBindingRegistry;
return {
...SHARED_BINDING_REGISTRY,
...canonicalBindings,
link: (node, context) => {
if (context.parent?.kind === "template-part") {
const part = manifest.parts.find((candidate) => candidate.name === context.parent?.attributes.name);
if (
part?.binding.type === "primitive" &&
(part.binding.primitive === "Link" ||
(typeof part.binding.primitive === "object" && part.binding.primitive.present === "Link"))
) {
return {
type: "alias",
canonicalKind: "template-part",
canonicalNodeKey: context.parent.key,
token: "structured-link",
};
}
}
const shared = SHARED_BINDING_REGISTRY.link;
return typeof shared === "function" ? shared(node, context) : shared;
},
"template-part": (node, { parent }) => {
const part = manifest.parts.find((candidate) => candidate.name === node.attributes.name);
if (part?.binding.type !== "primitive") return undefined;
if (typeof part.binding.primitive === "string") return part.binding as PrimitiveBinding;
return {
type: "primitive",
primitive: parent?.roles.includes(part.binding.primitive.ownerRole)
? part.binding.primitive.present
: part.binding.primitive.absent,
source: "existing",
};
},
};
}
@@ -0,0 +1,28 @@
import { describe, expect, it, vi } from "vitest";
import { rasterizePdf } from "./rasterize-pdf";
describe("rasterizePdf cleanup", () => {
it("destroys the loading task when loading rejects", async () => {
const destroy = vi.fn().mockResolvedValue(undefined);
const loadDocument = vi.fn(() => ({ promise: Promise.reject(new Error("load failed")), destroy }));
await expect(rasterizePdf(new Uint8Array(), { loadDocument })).rejects.toThrow("load failed");
expect(destroy).toHaveBeenCalledOnce();
});
it("cleans up the page and destroys the loading task when rendering rejects", async () => {
const cleanup = vi.fn();
const destroy = vi.fn().mockResolvedValue(undefined);
const page = {
getViewport: vi.fn(() => ({ width: 10, height: 10 })),
render: vi.fn(() => ({ promise: Promise.reject(new Error("render failed")) })),
cleanup,
};
const document = { numPages: 1, getPage: vi.fn().mockResolvedValue(page) };
const loadDocument = vi.fn(() => ({ promise: Promise.resolve(document), destroy }));
await expect(rasterizePdf(new Uint8Array(), { loadDocument })).rejects.toThrow("render failed");
expect(cleanup).toHaveBeenCalledOnce();
expect(destroy).toHaveBeenCalledOnce();
});
});
@@ -0,0 +1,70 @@
import { createCanvas } from "@napi-rs/canvas";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
export type RasterizedPdfPage = {
width: number;
height: number;
data: Uint8Array;
};
type RasterPage = {
getViewport(input: { scale: number }): { width: number; height: number };
render(input: unknown): { promise: Promise<unknown> };
cleanup(): void;
};
type RasterDocument = {
numPages: number;
getPage(pageNumber: number): Promise<RasterPage>;
};
type RasterLoadingTask = {
promise: Promise<RasterDocument>;
destroy(): Promise<void>;
};
export type RasterizePdfDependencies = {
loadDocument?: (bytes: Uint8Array) => RasterLoadingTask;
};
const loadPdfDocument = (bytes: Uint8Array): RasterLoadingTask =>
getDocument({ data: bytes }) as unknown as RasterLoadingTask;
export async function rasterizePdf(
bytes: Uint8Array,
dependencies: RasterizePdfDependencies = {},
): Promise<readonly RasterizedPdfPage[]> {
const loadingTask = (dependencies.loadDocument ?? loadPdfDocument)(bytes);
const pages: RasterizedPdfPage[] = [];
try {
const document = await loadingTask.promise;
for (let pageNumber = 1; pageNumber <= document.numPages; pageNumber += 1) {
const page = await document.getPage(pageNumber);
try {
const viewport = page.getViewport({ scale: 1.5 });
const width = Math.ceil(viewport.width);
const height = Math.ceil(viewport.height);
const canvas = createCanvas(width, height);
const context = canvas.getContext("2d");
await page.render({
canvas: canvas as unknown as HTMLCanvasElement,
canvasContext: context as unknown as CanvasRenderingContext2D,
viewport,
}).promise;
pages.push({
width,
height,
data: Uint8Array.from(context.getImageData(0, 0, width, height).data),
});
} finally {
page.cleanup();
}
}
} finally {
await loadingTask.destroy();
}
return pages;
}
+712
View File
@@ -0,0 +1,712 @@
import type { SemanticNode } from "@reactive-resume/resume/stylesheet/types";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { semanticNodeKeys } from "./node-keys";
import { buildSemanticTree } from "./tree";
const findNode = (node: SemanticNode, predicate: (candidate: SemanticNode) => boolean): SemanticNode | undefined => {
if (predicate(node)) return node;
for (const child of node.children) {
const match = findNode(child, predicate);
if (match) return match;
}
};
const flattenTree = (node: SemanticNode): SemanticNode[] => [node, ...node.children.flatMap(flattenTree)];
const findNodes = (node: SemanticNode, predicate: (candidate: SemanticNode) => boolean): SemanticNode[] =>
flattenTree(node).filter(predicate);
const findParent = (node: SemanticNode, childKey: string): SemanticNode | undefined => {
if (node.children.some((child) => child.key === childKey)) return node;
for (const child of node.children) {
const parent = findParent(child, childKey);
if (parent) return parent;
}
};
const required = <T>(value: T | undefined, label: string): T => {
if (value === undefined) throw new Error(`Missing ${label}`);
return value;
};
const richTextFixture = [
"<h1>One</h1><h2>Two</h2><h3>Three</h3><h4>Four</h4><h5>Five</h5><h6>Six</h6>",
"<blockquote>Quote</blockquote>",
'<p><a href="https://example.com"><strong>Bold</strong></a> <em>Em</em> <u>Under</u> <s>Strike</s> <code>Code</code> <span style="color: #f00">Color</span> <mark data-color="#ff0">Mark</mark><br></p>',
"<ul><li>First</li></ul><hr>",
].join("");
const buildFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
data.basics = {
name: "Ada Lovelace",
headline: "Engineer",
email: "ada@example.com",
phone: "",
location: "Berlin",
website: { url: "https://example.com", label: "example.com" },
customFields: [],
};
data.sections.experience.items = [
{
id: "experience/item",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "https://example.com/company", label: "Company", inlineLink: true },
description: "<p>Ignored when roles exist</p>",
roles: [
{
id: "role/item",
position: "Programmer",
period: "1843",
description: "<p>Built <strong>algorithms</strong>.</p><ul><li>First</li></ul>",
},
],
},
];
data.metadata.layout.pages = [{ fullWidth: false, main: ["experience"], sidebar: [] }];
return data;
};
const buildCompleteFixture = (): ResumeData => {
const data = structuredClone(defaultResumeData);
data.picture.url = "/uploads/ada.png";
data.basics = {
name: "Ada Lovelace",
headline: "Engineer",
email: "ada@example.com",
phone: "+49 123",
location: "Berlin",
website: { url: "https://example.com", label: "example.com" },
customFields: [
{ id: "custom/link", icon: "link", text: "Portfolio", link: "https://portfolio.example.com" },
{ id: "custom/empty", icon: "star", text: "", link: "" },
],
};
data.summary.content = richTextFixture;
data.sections.profiles.items = [
{
id: "profile/1",
hidden: false,
icon: "github-logo",
iconColor: "",
network: "GitHub",
username: "ada",
website: { url: "https://github.com/ada", label: "GitHub", inlineLink: false },
},
];
data.sections.experience.items = [
{
id: "experience/1",
hidden: false,
company: "Analytical Engines",
position: "Engineer",
location: "London",
period: "1842",
website: { url: "https://example.com/company", label: "Company", inlineLink: true },
description: "<p>Outer description</p>",
roles: [
{ id: "role/1", position: "Programmer", period: "1843", description: "<p>Role description</p>" },
{ id: "role/hidden", position: " ", period: "1844", description: "<p>Filtered role</p>" },
],
},
{
id: "experience/2",
hidden: false,
company: "Difference Engine",
position: "Writer",
location: "London",
period: "1841",
website: { url: "https://example.com/role", label: "Role", inlineLink: false },
description: "<p>Visible description</p>",
roles: [],
},
{
id: "hidden/item",
hidden: true,
company: "Hidden",
position: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
roles: [],
},
];
data.sections.education.items = [
{
id: "education/1",
hidden: false,
school: "University",
area: "Mathematics",
degree: "BSc",
grade: "A",
location: "London",
period: "1835",
website: { url: "https://example.com/education", label: "School", inlineLink: false },
description: "<p>Education</p>",
},
];
data.sections.projects.items = [
{
id: "project/1",
hidden: false,
name: "Engine",
period: "1843",
website: { url: "https://example.com/project", label: "Project", inlineLink: true },
description: "<p>Project</p>",
},
{
id: "project/invalid",
hidden: false,
name: " ",
period: "never",
website: { url: "", label: "", inlineLink: false },
description: "<p>Filtered project</p>",
},
];
data.sections.skills.items = [
{
id: "skill/1",
hidden: false,
icon: "code",
iconColor: "",
name: "TypeScript",
proficiency: "Expert",
level: 3,
keywords: ["Types"],
},
];
data.sections.languages.items = [
{ id: "language/1", hidden: false, language: "English", fluency: "Native", level: 4 },
];
data.sections.interests.items = [
{
id: "interest/1",
hidden: false,
icon: "book",
iconColor: "",
name: "Reading",
keywords: ["Science"],
},
];
data.sections.awards.items = [
{
id: "award/1",
hidden: false,
title: "Prize",
date: "1843",
awarder: "Society",
website: { url: "https://example.com/award", label: "Award", inlineLink: false },
description: "<p>Award</p>",
},
];
data.sections.certifications.items = [
{
id: "certification/1",
hidden: false,
title: "Certificate",
date: "1843",
issuer: "Society",
website: { url: "https://example.com/cert", label: "Certificate", inlineLink: false },
description: "<p>Certificate</p>",
},
];
data.sections.publications.items = [
{
id: "publication/1",
hidden: false,
title: "Notes",
date: "1843",
publisher: "Journal",
website: { url: "https://example.com/publication", label: "Publication", inlineLink: false },
description: "<p>Publication</p>",
},
];
data.sections.volunteer.items = [
{
id: "volunteer/1",
hidden: false,
organization: "Society",
location: "London",
period: "1843",
website: { url: "https://example.com/volunteer", label: "Volunteer", inlineLink: false },
description: "<p>Volunteer</p>",
},
];
data.sections.references.items = [
{
id: "reference/1",
hidden: false,
name: "Charles",
position: "Inventor",
phone: "+44 123",
website: { url: "https://example.com/reference", label: "Reference", inlineLink: false },
description: "<p>Reference</p>",
},
];
const builtInItems = {
profiles: required(data.sections.profiles.items[0], "profiles fixture item"),
experience: required(data.sections.experience.items[0], "experience fixture item"),
education: required(data.sections.education.items[0], "education fixture item"),
projects: required(data.sections.projects.items[0], "projects fixture item"),
skills: required(data.sections.skills.items[0], "skills fixture item"),
languages: required(data.sections.languages.items[0], "languages fixture item"),
interests: required(data.sections.interests.items[0], "interests fixture item"),
awards: required(data.sections.awards.items[0], "awards fixture item"),
certifications: required(data.sections.certifications.items[0], "certifications fixture item"),
publications: required(data.sections.publications.items[0], "publications fixture item"),
volunteer: required(data.sections.volunteer.items[0], "volunteer fixture item"),
references: required(data.sections.references.items[0], "references fixture item"),
};
const customTypes = [
"summary",
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
"cover-letter",
] as const;
data.customSections = customTypes.map((type) => ({
id: `custom/${type}~%`,
type,
title: `Custom ${type}`,
icon: "star",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [
type === "summary"
? { id: `custom-item/${type}`, hidden: false, content: "<p>Custom summary</p>" }
: type === "cover-letter"
? {
id: `custom-item/${type}`,
hidden: false,
recipient: "<p>Recipient</p>",
content: "<p>Letter</p>",
}
: {
...structuredClone(builtInItems[type]),
id: `custom-item/${type}`,
},
],
})) as ResumeData["customSections"];
data.customSections.push({
id: "custom/hidden",
type: "profiles",
title: "Hidden",
icon: "star",
columns: 1,
hidden: true,
keepTogether: false,
startOnNewPage: false,
items: [
{
...structuredClone(required(data.sections.profiles.items[0], "profiles fixture item")),
id: "custom-hidden/item",
},
],
});
const builtInSectionIds = [
"summary",
"profiles",
"experience",
"education",
"projects",
"skills",
"languages",
"interests",
"awards",
"certifications",
"publications",
"volunteer",
"references",
];
const customSectionIds = data.customSections.map((section) => section.id);
data.metadata.layout.pages = [
{
fullWidth: false,
main: [...builtInSectionIds, ...customSectionIds],
sidebar: ["skills"],
},
];
return data;
};
describe("buildSemanticTree", () => {
it("builds stable authored-page, section, nested-role, field, and rich-text ancestry", () => {
const data = buildFixture();
const before = structuredClone(data);
const input = {
data,
template: "onyx" as const,
page: required(data.metadata.layout.pages[0], "authored page"),
pageNumber: 1,
showHeader: true,
};
const first = buildSemanticTree(input);
const second = buildSemanticTree(input);
const nodes = flattenTree(first);
const section = findNode(first, (node) => node.key === "page-1/region-main/section-experience");
const role = findNode(first, (node) => node.id === "role/item");
const rolePosition = findNode(
required(role, "nested role"),
(node) => node.kind === "field" && node.attributes.name === "position",
);
const company = findNode(first, (node) => node.kind === "field" && node.attributes.name === "company");
const listItem = findNode(first, (node) => node.kind === "list-item");
expect(second).toEqual(first);
expect(data).toEqual(before);
expect(section?.attributes).toEqual({ type: "experience", placement: "main", origin: "main" });
expect(company?.roles).toContain("primary-text");
expect(role?.kind).toBe("item");
expect(role?.roles).toEqual(expect.arrayContaining(["experience-role", "nested-role"]));
expect(rolePosition?.roles).toEqual(["primary-text"]);
expect(findNode(required(role, "nested role"), (node) => node.kind === "strong")).toBeDefined();
expect(listItem?.children.map((node) => node.kind)).toEqual(["list-marker", "list-item-content"]);
expect(new Set(nodes.map((node) => node.key)).size).toBe(nodes.length);
});
it("covers every built-in and custom field, contact, link, icon, level decoration, and rich-text kind", () => {
const data = buildCompleteFixture();
const tree = buildSemanticTree({
data,
template: "onyx",
page: required(data.metadata.layout.pages[0], "authored page"),
pageNumber: 1,
showHeader: true,
});
const expectedFields = {
summary: ["content"],
profiles: ["network", "username"],
experience: ["company", "position", "location", "period", "description"],
education: ["school", "area", "degree", "grade", "location", "period", "description"],
projects: ["name", "period", "description"],
skills: ["name", "proficiency", "keywords"],
languages: ["language", "fluency"],
interests: ["name", "keywords"],
awards: ["title", "date", "awarder", "description"],
certifications: ["title", "date", "issuer", "description"],
publications: ["title", "date", "publisher", "description"],
volunteer: ["organization", "location", "period", "description"],
references: ["name", "position", "phone", "description"],
};
for (const [type, fields] of Object.entries(expectedFields)) {
const section = required(
findNode(tree, (node) => node.kind === "section" && node.id === type),
`${type} section`,
);
const names = new Set(findNodes(section, (node) => node.kind === "field").map((node) => node.attributes.name));
for (const field of fields) expect(names.has(field), `${type}.${field}`).toBe(true);
}
for (const customSection of data.customSections.filter((section) => !section.hidden)) {
const section = required(
findNode(tree, (node) => node.kind === "section" && node.id === customSection.id),
customSection.id,
);
const expected =
customSection.type === "cover-letter"
? ["recipient", "content"]
: customSection.type === "summary"
? ["content"]
: expectedFields[customSection.type];
const names = new Set(findNodes(section, (node) => node.kind === "field").map((node) => node.attributes.name));
for (const field of expected) expect(names.has(field), `${customSection.id}.${field}`).toBe(true);
}
expect(findNode(tree, (node) => node.id === "custom/hidden")).toBeUndefined();
expect(findNode(tree, (node) => node.id === "hidden/item")).toBeUndefined();
expect(findNode(tree, (node) => node.id === "project/invalid")).toBeUndefined();
expect(findNode(tree, (node) => node.id === "role/hidden")).toBeUndefined();
expect(findNode(tree, (node) => node.kind === "picture")).toBeDefined();
expect(findNode(tree, (node) => node.id === "custom/cover-letter~%")?.children[0]?.kind).toBe("section-items");
expect(findNodes(tree, (node) => node.kind === "contact-item").map((node) => node.attributes.name)).toEqual(
expect.arrayContaining(["email", "phone", "location", "website", "custom"]),
);
expect(findNodes(tree, (node) => node.kind === "link").map((node) => node.key)).toEqual(
expect.arrayContaining([expect.stringContaining("link-inline-website"), expect.stringContaining("link-website")]),
);
expect(findNodes(tree, (node) => node.kind === "icon").length).toBeGreaterThan(5);
const skill = required(
findNode(tree, (node) => node.kind === "item" && node.id === "skill/1"),
"skill item",
);
expect(findNodes(skill, (node) => node.kind === "icon" && node.roles.includes("active"))).toHaveLength(3);
expect(findNodes(skill, (node) => node.kind === "icon" && node.roles.includes("inactive"))).toHaveLength(2);
expect(
findNodes(
skill,
(node) => node.kind === "icon" && (node.roles.includes("active") || node.roles.includes("inactive")),
).every((node) => node.attributes.type === "circle"),
).toBe(true);
for (const [itemId, fields] of [
["language/1", ["language", "fluency"]],
["reference/1", ["name", "position", "phone"]],
] as const) {
const item = required(
findNode(tree, (node) => node.kind === "item" && node.id === itemId),
`${itemId} item`,
);
expect(findNode(item, (node) => node.kind === "item-header")).toBeUndefined();
expect(item.children.filter(({ kind }) => kind === "field").map((node) => node.attributes.name)).toEqual(
expect.arrayContaining([...fields]),
);
}
const richKinds = new Set(
findNodes(tree, (node) =>
[
"rich-heading",
"blockquote",
"paragraph",
"list",
"list-item",
"list-marker",
"list-item-content",
"link",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
"horizontal-rule",
].includes(node.kind),
).map((node) => node.kind),
);
expect(richKinds).toEqual(
new Set([
"rich-heading",
"blockquote",
"paragraph",
"list",
"list-item",
"list-marker",
"list-item-content",
"link",
"strong",
"emphasis",
"underline",
"strike",
"code",
"text-span",
"mark",
"hard-break",
"horizontal-rule",
]),
);
expect(findNodes(tree, (node) => node.kind === "rich-heading").map((node) => node.attributes.level)).toEqual([
"1",
"2",
"3",
"4",
"5",
"6",
]);
const strong = required(
findNode(tree, (node) => node.kind === "strong"),
"strong rich-text node",
);
const strongParent = required(findParent(tree, strong.key), "strong parent");
expect(strongParent.kind).toBe("link");
const normalizedLink = findParent(tree, strongParent.key);
expect(normalizedLink?.kind).toBe("paragraph");
const nodes = flattenTree(tree);
expect(new Set(nodes.map((node) => node.key)).size).toBe(nodes.length);
expect(findNode(tree, (node) => node.id === "custom/experience~%")?.key).toContain(
"section-custom%2Fexperience%7E%25",
);
});
it("omits conditional base contacts, preserves rendered custom fields, and scopes repeated sections by authored page", () => {
const data = buildCompleteFixture();
data.basics.email = "";
data.basics.phone = "";
data.basics.location = "";
data.basics.website = { url: "", label: "" };
const page = { fullWidth: true, main: ["experience"], sidebar: [] };
const first = buildSemanticTree({ data, template: "onyx", page, pageNumber: 1, showHeader: true });
const second = buildSemanticTree({ data, template: "onyx", page, pageNumber: 2, showHeader: true });
expect(findNodes(first, (node) => node.kind === "contact-item").map((node) => node.attributes.name)).toEqual([
"custom",
"custom",
]);
const emptyCustomContact = findNode(first, (node) => node.id === "custom/empty");
expect(emptyCustomContact).toBeDefined();
expect(emptyCustomContact && findNode(emptyCustomContact, (node) => node.kind === "field")).toMatchObject({
key: "page-1/region-header/header/contact-list/contact-custom~custom%2Fempty/field-custom",
attributes: { name: "custom" },
});
expect(findNode(first, (node) => node.kind === "region" && node.attributes.region === "sidebar")).toBeUndefined();
expect(findNode(first, (node) => node.kind === "section" && node.id === "experience")?.key).toBe(
"page-1/region-main/section-experience",
);
expect(findNode(second, (node) => node.kind === "section" && node.id === "experience")?.key).toBe(
"page-2/region-main/section-experience",
);
});
it("encodes every caller-controlled key segment without path or delimiter collisions", () => {
const parent = "page-1/region-main";
expect(semanticNodeKeys.section(parent, "a/b")).not.toBe(semanticNodeKeys.section(parent, "a%2Fb"));
expect(semanticNodeKeys.contactItem(parent, "a~b", "c")).not.toBe(semanticNodeKeys.contactItem(parent, "a", "b~c"));
expect(semanticNodeKeys.field(parent, "name/role")).toBe("page-1/region-main/field-name%2Frole");
expect(semanticNodeKeys.richTextNode(parent, "paragraph", 2)).toBe("page-1/region-main/paragraph-2");
});
it.each([false, true])("emits exactly one profile link when inlineLink is %s", (inlineLink) => {
const data = structuredClone(defaultResumeData);
data.sections.profiles.items = [
{
id: "profile/1",
hidden: false,
icon: "github-logo",
iconColor: "",
network: "GitHub",
username: "ada",
website: { url: "https://github.com/ada", label: "GitHub", inlineLink },
},
];
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: ["profiles"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const profile = required(
findNode(tree, (node) => node.kind === "item" && node.id === "profile/1"),
"profile item",
);
expect(findNodes(profile, (node) => node.kind === "link").map((node) => node.key)).toEqual([
"page-1/region-main/section-profiles/section-items/item-profile%2F1/link-profile",
]);
});
it("models Chikorita's two physical contact rows and routes contacts to their real row parent", () => {
const data = structuredClone(defaultResumeData);
data.basics.email = "ada@example.com";
data.basics.phone = "+44 123";
data.basics.location = "London";
data.basics.website = { url: "https://example.com", label: "example.com" };
data.basics.customFields = [{ id: "custom-1", icon: "", text: "Portfolio", link: "" }];
const tree = buildSemanticTree({
data,
template: "chikorita",
page: { fullWidth: true, main: [], sidebar: [] },
pageNumber: 1,
showHeader: true,
});
const contactList = required(
findNode(tree, (node) => node.kind === "contact-list"),
"contact list",
);
const rows = contactList.children.filter(({ kind }) => kind === "template-part");
expect(rows.map(({ attributes }) => attributes.name)).toEqual(["contact-row-primary", "contact-row-secondary"]);
expect(rows.map((row) => row.children.map(({ attributes }) => attributes.name))).toEqual([
["email", "phone", "location"],
["website", "custom"],
]);
});
it("models Meowth's education inline header and grade row as sibling hosts", () => {
const data = structuredClone(defaultResumeData);
data.sections.education.items = [
{
id: "education-1",
hidden: false,
school: "University of London",
area: "Mathematics",
degree: "BSc",
grade: "First",
location: "London",
period: "1835",
website: { url: "", label: "", inlineLink: false },
description: "",
},
];
const tree = buildSemanticTree({
data,
template: "meowth",
page: { fullWidth: true, main: ["education"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const item = required(
findNode(tree, (node) => node.id === "education-1"),
"education item",
);
const header = required(
item.children.find(({ kind }) => kind === "item-header"),
"education header",
);
const gradeRow = required(
item.children.find(({ attributes }) => attributes.name === "education-grade-row"),
"education grade row",
);
expect(header.children.map(({ attributes }) => attributes.name)).toEqual([
"inline-item-header-leading",
"inline-item-header-middle",
"inline-item-header-trailing",
]);
expect(gradeRow.kind).toBe("template-part");
expect(gradeRow.children.map(({ attributes }) => attributes.name)).toEqual(["education-grade-location"]);
expect(gradeRow.children[0]?.kind).toBe("combined-text");
expect(gradeRow.children[0]?.children.map(({ attributes }) => attributes.name)).toEqual(["grade", "location"]);
});
it.each([
["en-US", ["list-marker", "list-item-content"]],
["ar-SA", ["list-item-content", "list-marker"]],
] as const)("models %s rich-list row children in their authored physical order", (locale, kinds) => {
const data = structuredClone(defaultResumeData);
data.metadata.page.locale = locale;
data.summary.content = "<ul><li>Item</li></ul>";
const tree = buildSemanticTree({
data,
template: "onyx",
page: { fullWidth: true, main: ["summary"], sidebar: [] },
pageNumber: 1,
showHeader: false,
});
const item = required(
findNode(tree, (node) => node.kind === "list-item"),
"rich list item",
);
expect(item.children.map(({ kind }) => kind)).toEqual([...kinds]);
expect(item.children.every(({ key }) => key.startsWith(`${item.key}/`))).toBe(true);
});
});
File diff suppressed because it is too large Load Diff