mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
refactor(stylesheet): move Semantic CSS to the browser (#3329)
This commit is contained in:
@@ -2,7 +2,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { createPublicStyleProjection } from "./semantic/public-projection";
|
||||
|
||||
const rendererMock = vi.hoisted(() => ({
|
||||
pdf: vi.fn(() => ({
|
||||
@@ -120,70 +119,12 @@ describe("createResumePdfBlob", () => {
|
||||
await expect(promise).rejects.toThrow("renderer failed");
|
||||
});
|
||||
|
||||
it("renders a source-free public projection through the semantic runtime", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const publicData = structuredClone(semanticData);
|
||||
delete publicData.metadata.stylesheet;
|
||||
it("renders with base styles when the source is fatal", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
await createResumePdfBlob({ data: publicData, publicStyleProjection: projection });
|
||||
|
||||
expect(rendererMock.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
data: publicData,
|
||||
semanticRuntime: expect.objectContaining({
|
||||
presentation: expect.objectContaining({
|
||||
"page-1/region-header/header/name": { style: { color: "#123456" } },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns semantic diagnostics without rendering an invalid applied source", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfBlobResult } = await import("./browser");
|
||||
|
||||
const result = await createResumePdfBlobResult({ data });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unchecked rendering instead of producing an unstyled PDF for semantic errors", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
await expect(createResumePdfBlob({ data })).rejects.toMatchObject({
|
||||
cause: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an optional prior semantic inspection on the result path", async () => {
|
||||
const { createResumePdfBlobResult } = await import("./browser");
|
||||
const inspection = {
|
||||
presentation: {},
|
||||
sourceTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
renderTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
diagnostics: [],
|
||||
} as const;
|
||||
|
||||
const result = await createResumePdfBlobResult({ data: sampleResumeData, inspection });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, diagnostics: [] });
|
||||
await expect(createResumePdfBlob({ data })).resolves.toHaveProperty("type", "application/pdf");
|
||||
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,79 +2,31 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ResumeRenderOptions } from "./context";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResolvedResumeRuntime, ResumePdfRenderResult } from "./semantic";
|
||||
import type { PublicStyleProjection } from "./semantic/public-projection";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { pdf } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "./document";
|
||||
import { hasSemanticErrors, inspectResumePdf } from "./semantic";
|
||||
import { resolvePublicStyleProjectionRuntime } from "./semantic/public-projection";
|
||||
|
||||
export type {
|
||||
BrowserPdfPreflightResult,
|
||||
PdfPreflightFailure,
|
||||
PdfPreflightPageLimits,
|
||||
PdfPreflightResult,
|
||||
RenderPreflightPdfResult,
|
||||
StylesheetPreflightInput,
|
||||
} from "./semantic/preflight-core";
|
||||
export { renderPreflightPdf } from "./semantic/preflight-core";
|
||||
|
||||
export type CreateResumePdfBlobOptions = {
|
||||
data: ResumeData;
|
||||
template?: Template | undefined;
|
||||
renderOptions?: ResumeRenderOptions | undefined;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
publicStyleProjection?: PublicStyleProjection | undefined;
|
||||
};
|
||||
|
||||
export type CreateResumePdfBlobResultOptions = CreateResumePdfBlobOptions & {
|
||||
inspection?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const renderResumePdfBlob = async ({
|
||||
data,
|
||||
export const createResumePdfBlob = async ({
|
||||
data: input,
|
||||
template,
|
||||
renderOptions,
|
||||
resolveSectionTitle,
|
||||
publicStyleProjection,
|
||||
}: CreateResumePdfBlobOptions) => {
|
||||
const semanticRuntime = publicStyleProjection
|
||||
? await resolvePublicStyleProjectionRuntime(data, publicStyleProjection)
|
||||
: undefined;
|
||||
}: CreateResumePdfBlobOptions): Promise<Blob> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
...(renderOptions ? { renderOptions } : {}),
|
||||
resolveSectionTitle,
|
||||
...(semanticRuntime ? { semanticRuntime } : {}),
|
||||
}) as Parameters<typeof pdf>[0];
|
||||
|
||||
return pdf(document).toBlob();
|
||||
};
|
||||
|
||||
export const createResumePdfBlobResult = async ({
|
||||
inspection,
|
||||
...options
|
||||
}: CreateResumePdfBlobResultOptions): Promise<ResumePdfRenderResult<Blob>> => {
|
||||
const normalizedOptions = { ...options, data: parseResumeData(options.data) };
|
||||
const resolvedInspection = inspection ?? inspectResumePdf(normalizedOptions);
|
||||
if (hasSemanticErrors(resolvedInspection)) {
|
||||
return { ok: false, diagnostics: resolvedInspection.diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: await renderResumePdfBlob(normalizedOptions),
|
||||
diagnostics: resolvedInspection.diagnostics,
|
||||
};
|
||||
};
|
||||
|
||||
export const createResumePdfBlob = async (options: CreateResumePdfBlobOptions): Promise<Blob> => {
|
||||
const result = await createResumePdfBlobResult(options);
|
||||
if (!result.ok) {
|
||||
throw new Error("The semantic stylesheet could not be rendered.", { cause: result.diagnostics });
|
||||
}
|
||||
return result.value;
|
||||
return await pdf(document).toBlob();
|
||||
};
|
||||
|
||||
@@ -54,7 +54,6 @@ export const buildAllTemplatesFixture = (template: Template) => {
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: comprehensiveStylesheet,
|
||||
applied: comprehensiveStylesheet,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("Semantic CSS all-template presentation", () => {
|
||||
const runtime = resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied: comprehensiveStylesheet,
|
||||
source: comprehensiveStylesheet,
|
||||
mode: "semantic",
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("Semantic CSS all-template presentation", () => {
|
||||
const { sourceTree } = resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied: comprehensiveStylesheet,
|
||||
source: comprehensiveStylesheet,
|
||||
mode: "semantic",
|
||||
});
|
||||
const templateParts = new Set<string>();
|
||||
|
||||
@@ -54,7 +54,7 @@ const buildFixture = (template: Template, rule = ""): ResumeData => {
|
||||
: [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial" | "revert"
|
||||
keyword ? `section[type="experience"] field[name="company"] { font-weight: ${keyword}; }` : ""
|
||||
}`;
|
||||
const stylesheet = { languageVersion: 1, text };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: 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();
|
||||
|
||||
@@ -97,7 +97,7 @@ const buildFixture = (
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const fixture = (mode: "legacy" | "semantic", section: "experience" | "education
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet };
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
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,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResumeDocument } from "../document";
|
||||
import { semanticNodeKeys } from "./node-keys";
|
||||
import { resolveResumePresentation, resolveStylesheetMode } from "./resolve";
|
||||
|
||||
const applied = (text: string) => ({ languageVersion: 1, text });
|
||||
const source = (text: string) => ({ languageVersion: 1, text });
|
||||
|
||||
type HostNode = {
|
||||
type: string;
|
||||
@@ -90,7 +90,7 @@ const resolveIssueFixture = (text: string) => {
|
||||
return resolveResumePresentation({
|
||||
data,
|
||||
template: "onyx",
|
||||
applied: applied(text),
|
||||
source: source(text),
|
||||
mode: "semantic",
|
||||
});
|
||||
};
|
||||
@@ -138,7 +138,7 @@ describe("semantic issue fixtures", () => {
|
||||
resolveResumePresentation({
|
||||
data,
|
||||
template: "onyx",
|
||||
applied: applied(`@version 1;${text}`),
|
||||
source: source(`@version 1;${text}`),
|
||||
mode: "semantic",
|
||||
});
|
||||
|
||||
@@ -157,11 +157,11 @@ describe("semantic issue fixtures", () => {
|
||||
header { background-color: #1e293b; }
|
||||
name { color: white; }
|
||||
`);
|
||||
const invalid = compileStylesheet(applied("@version 1; header { background-image: linear-gradient(red, blue); }"));
|
||||
const invalid = compileStylesheet(source("@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();
|
||||
expect(invalid.program).not.toBeNull();
|
||||
});
|
||||
|
||||
it("unbolds only skill names and leaves experience titles unchanged (#2223)", () => {
|
||||
@@ -180,8 +180,7 @@ describe("semantic issue fixtures", () => {
|
||||
const semanticData = buildIssueFixture();
|
||||
semanticData.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: applied("@version 1;"),
|
||||
applied: applied("@version 1;"),
|
||||
source: source("@version 1;"),
|
||||
};
|
||||
const legacyData = buildIssueFixture();
|
||||
|
||||
@@ -191,7 +190,7 @@ describe("semantic issue fixtures", () => {
|
||||
resolveResumePresentation({
|
||||
data: legacyData,
|
||||
template: "onyx",
|
||||
applied: applied("@version 1; name { color: red; }"),
|
||||
source: source("@version 1; name { color: red; }"),
|
||||
mode: "legacy",
|
||||
}),
|
||||
).toEqual({});
|
||||
@@ -199,7 +198,7 @@ describe("semantic issue fixtures", () => {
|
||||
|
||||
it("applies issue-regression styles to the final existing PDF primitives", async () => {
|
||||
const data = buildIssueFixture();
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@version 1;
|
||||
header { background-color: #1e293b; }
|
||||
name { color: white; }
|
||||
@@ -208,7 +207,7 @@ describe("semantic issue fixtures", () => {
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: 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());
|
||||
@@ -226,11 +225,11 @@ describe("semantic issue fixtures", () => {
|
||||
|
||||
it("unbolds only the final skill-name primitive and preserves the experience title weight (#2223)", async () => {
|
||||
const data = buildIssueFixture();
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@version 1;
|
||||
section[type="skills"] field[name="name"] { font-weight: 400; }
|
||||
`);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: 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());
|
||||
@@ -253,12 +252,12 @@ describe("semantic issue fixtures", () => {
|
||||
keywords: [],
|
||||
}));
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: 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());
|
||||
|
||||
@@ -286,7 +286,6 @@ export async function compareLegacySemanticPresentation(
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: input.convertedSource,
|
||||
applied: input.convertedSource,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -162,7 +162,7 @@ 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 };
|
||||
semantic.metadata.stylesheet = { mode: "semantic", source: conversion.source };
|
||||
return semantic;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const buildFixture = (
|
||||
|
||||
if (mode !== "missing") {
|
||||
const stylesheet = { languageVersion: 1, text };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet };
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -53,7 +53,6 @@ const buildFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
data.metadata.stylesheet = {
|
||||
mode,
|
||||
source: semanticSource(),
|
||||
applied: semanticSource(),
|
||||
};
|
||||
}
|
||||
return data;
|
||||
|
||||
@@ -57,7 +57,7 @@ const buildFixture = (value: string): ResumeData => {
|
||||
languageVersion: 1,
|
||||
text: `@version 1; section[type="summary"] { break-before: ${value}; break-inside: ${value}; }`,
|
||||
};
|
||||
data.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const parsePdf = (data: Uint8Array): Promise<ParsedPdf> => getDocument({ data })
|
||||
|
||||
const overflowingFixture = (pageSize: "A4" | "LETTER"): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -75,7 +75,7 @@ const overflowingFixture = (pageSize: "A4" | "LETTER"): ResumeData => {
|
||||
(_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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -96,14 +96,14 @@ const readPhysicalPages = async (document: ParsedPdf) => {
|
||||
describe("semantic pagination bindings", () => {
|
||||
it("passes resolved authored-page size to the existing Page primitive", async () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
|
||||
const page = findFirst(await renderHostTree(data), "PAGE");
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
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: "1842–1843",
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
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({
|
||||
// Render deadline (after the worker is warm). A rich resume on a throttled/shared
|
||||
// vCPU renders in ~5-18s, so 5s spuriously failed real resumes; the worker is now
|
||||
// warmed+reused so this ceiling only bounds a genuinely stuck render.
|
||||
timeoutMs: 30_000,
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
maxOldGenerationMb: 256,
|
||||
maxConcurrentWorkers: 1,
|
||||
maxQueuedRequests: 32,
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,369 +0,0 @@
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectResumePdf } from "@reactive-resume/pdf/semantic";
|
||||
import { resolveResumeRuntime } 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", () => {
|
||||
it("falls back to base presentation and preserves fatal source diagnostics", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
|
||||
const inspection = inspectResumePdf({ data });
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.diagnostics).toContainEqual(expect.objectContaining({ severity: "error" }));
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "UNSUPPORTED_VERSION", severity: "error" }),
|
||||
);
|
||||
expect(inspection.presentation).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to base presentation when a selector list exceeds the resource limit", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const selectors = new Array(65).fill("section").join(",");
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: `@version 1;\n${selectors} { color: red; }` },
|
||||
};
|
||||
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }),
|
||||
);
|
||||
expect(inspection.presentation).toEqual({});
|
||||
expect(inspection.renderTree).toEqual(inspection.sourceTree);
|
||||
});
|
||||
|
||||
it("keeps valid PDF presentation when a neighboring value is recoverable", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: {
|
||||
languageVersion: 1,
|
||||
text: "@version 1; name { color: #123456; opacity: var(--missing); }",
|
||||
},
|
||||
};
|
||||
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.presentation["page-1/region-header/header/name"]?.style?.color).toBe("#123456");
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { compileStylesheet, isFatalStylesheetDiagnostic, 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";
|
||||
@@ -17,7 +17,7 @@ import { buildSemanticTree } from "./tree";
|
||||
export type ResolveResumePresentationInput = {
|
||||
data: ResumeData;
|
||||
template: Template;
|
||||
applied?: StylesheetSource;
|
||||
source?: StylesheetSource;
|
||||
mode: StylesheetMode;
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export function resolveStylesheetMode(data: ResumeData): StylesheetMode {
|
||||
export function resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied,
|
||||
source,
|
||||
mode,
|
||||
}: ResolveResumePresentationInput): ResolvedResumeRuntime {
|
||||
const sourceTree = mergeAuthoredPageTrees(data, template);
|
||||
@@ -93,12 +93,12 @@ export function resolveResumeRuntime({
|
||||
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: [] };
|
||||
}
|
||||
|
||||
const source = applied ??
|
||||
data.metadata.stylesheet?.applied ?? {
|
||||
const stylesheetSource = source ??
|
||||
data.metadata.stylesheet?.source ?? {
|
||||
languageVersion: 1,
|
||||
text: EMPTY_SEMANTIC_CSS_SOURCE,
|
||||
};
|
||||
const compiled = compileStylesheet(source);
|
||||
const compiled = compileStylesheet(stylesheetSource);
|
||||
if (!compiled.program) {
|
||||
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: compiled.diagnostics };
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function resolveResumeRuntime({
|
||||
pages: authoredPageDimensions(data),
|
||||
aliases,
|
||||
});
|
||||
if (resolved.diagnostics.some(({ severity }) => severity === "error")) {
|
||||
if (resolved.diagnostics.some(isFatalStylesheetDiagnostic)) {
|
||||
return {
|
||||
presentation: EMPTY_PRESENTATION,
|
||||
sourceTree,
|
||||
|
||||
@@ -47,7 +47,7 @@ const findSemanticNode = (
|
||||
|
||||
const buildFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -60,7 +60,7 @@ const buildFixture = (): ResumeData => {
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const findFirst = (node: HostNode, predicate: (candidate: HostNode) => boolean):
|
||||
|
||||
const buildFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -50,15 +50,15 @@ const buildFixture = (): ResumeData => {
|
||||
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 };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
const buildNodeBudgetFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\n" };
|
||||
const source = { languageVersion: 1, text: "@version 1;\n" };
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode, source: applied, applied };
|
||||
data.metadata.stylesheet = { mode, source };
|
||||
data.sections.skills.items = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `skill-${index}`,
|
||||
hidden: false,
|
||||
@@ -72,6 +72,13 @@ const buildNodeBudgetFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const buildFatalSourceFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
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]);
|
||||
@@ -100,28 +107,15 @@ describe("browser/server semantic runtime identity", () => {
|
||||
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");
|
||||
it("renders browser and server PDFs with base styles when the stylesheet is fatal", async () => {
|
||||
const data = buildFatalSourceFixture();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
createResumePdfBlob({ data, template: "onyx" }),
|
||||
createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" }),
|
||||
]);
|
||||
const blob = await createResumePdfBlob({ data, template: "onyx" });
|
||||
const file = await 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" })]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
expect(file.type).toBe("application/pdf");
|
||||
expect(await renderFinalProps(captured.browser)).toEqual(await renderFinalProps(captured.server));
|
||||
}, 15_000);
|
||||
|
||||
it("keeps legacy PDF rendering unaffected by the semantic node budget", async () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ const semanticFixture = (rule: string): ResumeData => {
|
||||
customFields: [],
|
||||
};
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createBindingInventory } from "./binding-inventory";
|
||||
import {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "./template-manifest";
|
||||
import { buildSemanticTree } from "./tree";
|
||||
@@ -423,8 +422,6 @@ const EXPECTED_LAYOUT = {
|
||||
} as const satisfies Readonly<Record<Template, Omit<TemplateSemanticManifest, "template" | "parts">>>;
|
||||
|
||||
const flattenTree = (node: SemanticNode): SemanticNode[] => [node, ...node.children.flatMap(flattenTree)];
|
||||
const flattenValues = (value: unknown): unknown[] =>
|
||||
typeof value === "object" && value !== null ? [value, ...Object.values(value).flatMap(flattenValues)] : [value];
|
||||
const findNodes = (node: SemanticNode, predicate: (candidate: SemanticNode) => boolean): SemanticNode[] =>
|
||||
flattenTree(node).filter(predicate);
|
||||
const findPart = (node: SemanticNode, name: string): SemanticNode | undefined =>
|
||||
@@ -488,7 +485,8 @@ describe("template semantic manifests", () => {
|
||||
});
|
||||
|
||||
it("registers child kinds for every primitive template part", () => {
|
||||
for (const manifest of Object.values(getTemplateSemanticRegistryFingerprintInput())) {
|
||||
for (const template of templateSchema.options) {
|
||||
const manifest = getTemplateSemanticManifest(template);
|
||||
for (const part of manifest.parts) {
|
||||
if (part.binding.type === "alias") continue;
|
||||
expect(TEMPLATE_PART_CHILD_KINDS_V1, `${manifest.template}:${part.name}`).toHaveProperty(part.name);
|
||||
@@ -1315,22 +1313,6 @@ describe("template semantic manifests", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes stable, deterministic, deeply frozen fingerprint input without functions", () => {
|
||||
const first = getTemplateSemanticRegistryFingerprintInput();
|
||||
const second = getTemplateSemanticRegistryFingerprintInput();
|
||||
const serialized = JSON.stringify(first);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(JSON.stringify(second)).toBe(serialized);
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
expect(Object.isFrozen(first.azurill.parts)).toBe(true);
|
||||
expect(flattenValues(first).every((value) => typeof value !== "function")).toBe(true);
|
||||
expect(() => {
|
||||
(first.azurill.parts as unknown as object[]).pop();
|
||||
}).toThrow();
|
||||
expect(JSON.stringify(getTemplateSemanticRegistryFingerprintInput())).toBe(serialized);
|
||||
});
|
||||
|
||||
it.each(templateSchema.options)(
|
||||
"%s binds every manifest node to existing chrome without synthetic wrappers",
|
||||
(template) => {
|
||||
|
||||
@@ -334,10 +334,6 @@ export function getTemplateSemanticManifest(template: Template): TemplateSemanti
|
||||
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(
|
||||
|
||||
@@ -998,6 +998,5 @@ export { semanticNodeKeys } from "./node-keys";
|
||||
export {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "./template-manifest";
|
||||
|
||||
@@ -108,31 +108,16 @@ describe("createResumePdfFile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns semantic diagnostics without rendering an invalid applied source", async () => {
|
||||
it("renders with base styles when the source is fatal", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfFileResult } = await import("./server");
|
||||
|
||||
const result = await createResumePdfFileResult({ data, filename: "resume.pdf" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.renderToBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unchecked rendering instead of producing an unstyled PDF for semantic errors", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
const { createResumePdfFile } = await import("./server");
|
||||
|
||||
await expect(createResumePdfFile({ data, filename: "resume.pdf" })).rejects.toMatchObject({
|
||||
cause: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.renderToBuffer).not.toHaveBeenCalled();
|
||||
await expect(createResumePdfFile({ data, filename: "resume.pdf" })).resolves.toHaveProperty(
|
||||
"type",
|
||||
"application/pdf",
|
||||
);
|
||||
expect(rendererMock.renderToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data at the server boundary before React PDF dispatch", async () => {
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResolvedResumeRuntime, ResumePdfRenderResult } from "./semantic";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { renderToBuffer } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "./document";
|
||||
import { hasSemanticErrors, inspectResumePdf } from "./semantic";
|
||||
|
||||
export type {
|
||||
PdfPreflightFailure,
|
||||
PdfPreflightPageLimits,
|
||||
PdfPreflightResult,
|
||||
RenderPreflightPdfResult,
|
||||
StylesheetPreflightInput,
|
||||
StylesheetPreflightRunner,
|
||||
} from "./semantic/preflight-core";
|
||||
export { renderPreflightPdf } from "./semantic/preflight-core";
|
||||
|
||||
export type CreateResumePdfFileOptions = {
|
||||
data: ResumeData;
|
||||
@@ -25,11 +13,13 @@ export type CreateResumePdfFileOptions = {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
export type CreateResumePdfFileResultOptions = CreateResumePdfFileOptions & {
|
||||
inspection?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const renderResumePdfFile = async ({ data, filename, template, resolveSectionTitle }: CreateResumePdfFileOptions) => {
|
||||
export const createResumePdfFile = async ({
|
||||
data: input,
|
||||
filename,
|
||||
template,
|
||||
resolveSectionTitle,
|
||||
}: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
@@ -41,28 +31,3 @@ const renderResumePdfFile = async ({ data, filename, template, resolveSectionTit
|
||||
|
||||
return new File([bytes], filename, { type: "application/pdf" });
|
||||
};
|
||||
|
||||
export const createResumePdfFileResult = async ({
|
||||
inspection,
|
||||
...options
|
||||
}: CreateResumePdfFileResultOptions): Promise<ResumePdfRenderResult<File>> => {
|
||||
const normalizedOptions = { ...options, data: parseResumeData(options.data) };
|
||||
const resolvedInspection = inspection ?? inspectResumePdf(normalizedOptions);
|
||||
if (hasSemanticErrors(resolvedInspection)) {
|
||||
return { ok: false, diagnostics: resolvedInspection.diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: await renderResumePdfFile(normalizedOptions),
|
||||
diagnostics: resolvedInspection.diagnostics,
|
||||
};
|
||||
};
|
||||
|
||||
export const createResumePdfFile = async (options: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const result = await createResumePdfFileResult(options);
|
||||
if (!result.ok) {
|
||||
throw new Error("The semantic stylesheet could not be rendered.", { cause: result.diagnostics });
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("templatePages", () => {
|
||||
|
||||
it("exports the semantic manifest registry through the template index", () => {
|
||||
expect(registry).toContain("getTemplateSemanticManifest");
|
||||
expect(registry).toContain("getTemplateSemanticRegistryFingerprintInput");
|
||||
expect(registry).toContain("TemplateSemanticManifest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,5 @@ export type { TemplateSemanticManifest } from "../semantic/template-manifest";
|
||||
export {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "../semantic/template-manifest";
|
||||
|
||||
Reference in New Issue
Block a user