mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 23:02:17 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -7,6 +7,7 @@
|
||||
"./context": "./src/context.ts",
|
||||
"./features/flags": "./src/features/flags/index.ts",
|
||||
"./features/resume/export": "./src/features/resume/export.ts",
|
||||
"./features/resume/public-pdf": "./src/features/resume/public-pdf.ts",
|
||||
"./features/storage": "./src/features/storage/index.ts",
|
||||
"./routers": "./src/routers/index.ts"
|
||||
},
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StylesheetPreflightRunner } from "@reactive-resume/pdf/server";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { User } from "better-auth";
|
||||
import { ORPCError, os } from "@orpc/server";
|
||||
@@ -10,6 +11,8 @@ interface ORPCContext {
|
||||
locale: Locale;
|
||||
reqHeaders: Headers;
|
||||
resHeaders?: Headers;
|
||||
trustedClient?: string;
|
||||
stylesheetPreflightRunner?: StylesheetPreflightRunner;
|
||||
}
|
||||
|
||||
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
||||
|
||||
@@ -4,6 +4,110 @@ import { redactResumeForViewer } from "../features/resume/access-policy";
|
||||
import { resumeDto } from "./resume";
|
||||
|
||||
describe("resume DTO output validation", () => {
|
||||
it("normalizes ordinary PUT data without losing compatible custom-section overlap", () => {
|
||||
const parsed = resumeDto.update.input.parse({
|
||||
id: "resume-id",
|
||||
data: {
|
||||
...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>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.data?.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe custom sections before update or import persistence", () => {
|
||||
const data = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(resumeDto.update.input.safeParse({ id: "resume-id", data }).success).toBe(false);
|
||||
expect(resumeDto.import.input.safeParse({ data }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("defers imported stylesheet validation to the stable unavailable-feature error", () => {
|
||||
expect(
|
||||
resumeDto.import.input.safeParse({
|
||||
data: {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
stylesheet: { invalid: true },
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not let otherwise-invalid imports bypass validation without a stylesheet field", () => {
|
||||
expect(resumeDto.import.input.safeParse({ data: { metadata: {} } }).success).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[resumeDto.getById.output, {}],
|
||||
[resumeDto.getBySlug.output, { stylesheetMode: "legacy" }],
|
||||
[resumeDto.update.output, {}],
|
||||
[resumeDto.patch.output, {}],
|
||||
] as const)("keeps server concurrency columns out of ordinary resume outputs", (output, extra) => {
|
||||
const parsed = output.parse({
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
data: defaultResumeData,
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
hasPassword: false,
|
||||
stylesheetRevision: 7,
|
||||
renderDataVersion: 9,
|
||||
...extra,
|
||||
});
|
||||
|
||||
expect(parsed).not.toHaveProperty("stylesheetRevision");
|
||||
expect(parsed).not.toHaveProperty("renderDataVersion");
|
||||
});
|
||||
|
||||
it("accepts public resume responses after owner-only fields are redacted", () => {
|
||||
const dbResume = {
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
@@ -26,10 +130,71 @@ describe("resume DTO output validation", () => {
|
||||
const publicResume = {
|
||||
...redactResumeForViewer(dbResume, false),
|
||||
hasPassword: dbResume.hasPassword,
|
||||
stylesheetMode: "legacy",
|
||||
};
|
||||
|
||||
expect(publicResume.name).toBe("Resume");
|
||||
expect(publicResume.data.metadata.notes).toBe("");
|
||||
expect(resumeDto.getBySlug.output.safeParse(publicResume).success).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes only the safe public stylesheet mode discriminator", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||
const parsed = resumeDto.getBySlug.output.parse({
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
data: redactResumeForViewer(
|
||||
{
|
||||
name: "Owner title",
|
||||
data: {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
stylesheet: { mode: "semantic", source, applied: source },
|
||||
},
|
||||
},
|
||||
},
|
||||
false,
|
||||
).data,
|
||||
isPublic: true,
|
||||
isLocked: false,
|
||||
hasPassword: false,
|
||||
stylesheetMode: "semantic",
|
||||
});
|
||||
|
||||
expect(parsed.stylesheetMode).toBe("semantic");
|
||||
expect(JSON.stringify(parsed)).not.toContain("@version");
|
||||
expect(JSON.stringify(parsed)).not.toContain("stylesheetRevision");
|
||||
});
|
||||
|
||||
it("returns current canonical stylesheet state only on version restore", () => {
|
||||
const resume = {
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
data: defaultResumeData,
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
hasPassword: false,
|
||||
};
|
||||
const stylesheet = {
|
||||
mode: "semantic" as const,
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
|
||||
expect(
|
||||
resumeDto.restoreVersion.output.parse({
|
||||
resume,
|
||||
stylesheetState: { stylesheet, revision: 8, renderDataVersion: 13 },
|
||||
}),
|
||||
).toEqual({
|
||||
resume,
|
||||
stylesheetState: { stylesheet, revision: 8, renderDataVersion: 13 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,8 +1,18 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { createSelectSchema } from "drizzle-zod";
|
||||
import z from "zod";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { semanticStylesheetSchema, stylesheetSourceSchema } from "@reactive-resume/schema/resume/stylesheet";
|
||||
|
||||
const importedResumeDataSchema = z.custom<ResumeData>((value) => {
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const data = value as Record<string, unknown>;
|
||||
if (typeof data.metadata !== "object" || data.metadata === null) return false;
|
||||
const { stylesheet: _stylesheet, ...metadata } = data.metadata as Record<string, unknown>;
|
||||
return resumeDataSchema.safeParse({ ...data, metadata }).success;
|
||||
});
|
||||
|
||||
const resumeSchema = createSelectSchema(schema.resume, {
|
||||
id: z.string().describe("The ID of the resume."),
|
||||
@@ -16,6 +26,60 @@ const resumeSchema = createSelectSchema(schema.resume, {
|
||||
userId: z.string().describe("The ID of the user who owns the resume."),
|
||||
createdAt: z.date().describe("The date and time the resume was created."),
|
||||
updatedAt: z.date().describe("The date and time the resume was last updated."),
|
||||
}).omit({ stylesheetRevision: true, renderDataVersion: true });
|
||||
|
||||
const stylesheetMutationCommon = {
|
||||
id: z.string().describe("The ID of the resume."),
|
||||
expectedRevision: z.number().int().nonnegative(),
|
||||
expectedRenderDataVersion: z.number().int().nonnegative(),
|
||||
editGeneration: z.number().int().nonnegative(),
|
||||
};
|
||||
const stylesheetDiagnosticSchema = z.strictObject({
|
||||
code: z.string(),
|
||||
severity: z.enum(["error", "warning"]),
|
||||
message: z.string(),
|
||||
range: z.strictObject({
|
||||
start: z.strictObject({
|
||||
line: z.number().int().positive(),
|
||||
column: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative(),
|
||||
}),
|
||||
end: z.strictObject({
|
||||
line: z.number().int().positive(),
|
||||
column: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative(),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
const stylesheetStateSchema = z.strictObject({
|
||||
stylesheet: semanticStylesheetSchema,
|
||||
revision: z.number().int().nonnegative(),
|
||||
renderDataVersion: z.number().int().nonnegative(),
|
||||
});
|
||||
const publicPdfPageSizeSchema = z.union([
|
||||
z.enum(["A4", "LETTER"]),
|
||||
z.strictObject({ width: z.number().finite(), height: z.number().finite().optional() }),
|
||||
]);
|
||||
const publicPdfNodePresentationSchema = z.strictObject({
|
||||
style: z.record(z.string(), z.union([z.string(), z.number().finite(), z.null()])).optional(),
|
||||
size: publicPdfPageSizeSchema.optional(),
|
||||
break: z.boolean().optional(),
|
||||
wrap: z.boolean().optional(),
|
||||
fixed: z.boolean().optional(),
|
||||
minPresenceAhead: z.number().finite().optional(),
|
||||
orphans: z.number().finite().optional(),
|
||||
widows: z.number().finite().optional(),
|
||||
hidden: z.boolean().optional(),
|
||||
order: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
const publicStyleProjectionSchema = z.strictObject({
|
||||
formatVersion: z.literal(1),
|
||||
languageVersion: z.number().int().positive(),
|
||||
semanticTreeVersion: z.literal(1),
|
||||
registryFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
adapterFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
renderDataHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||
nodes: z.record(z.string(), publicPdfNodePresentationSchema),
|
||||
});
|
||||
|
||||
export const resumeDto = {
|
||||
@@ -43,7 +107,12 @@ export const resumeDto = {
|
||||
// the redacted public response passes output validation.
|
||||
output: resumeSchema
|
||||
.omit({ name: true, password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ name: z.string() }),
|
||||
.extend({ name: z.string(), stylesheetMode: z.enum(["legacy", "semantic"]) }),
|
||||
},
|
||||
|
||||
getStyleProjection: {
|
||||
input: z.strictObject({ username: z.string(), slug: z.string() }),
|
||||
output: publicStyleProjectionSchema,
|
||||
},
|
||||
|
||||
create: {
|
||||
@@ -54,7 +123,7 @@ export const resumeDto = {
|
||||
},
|
||||
|
||||
import: {
|
||||
input: resumeSchema.pick({ data: true }),
|
||||
input: z.object({ data: importedResumeDataSchema }),
|
||||
output: z.string().describe("The ID of the imported resume."),
|
||||
},
|
||||
|
||||
@@ -122,6 +191,58 @@ export const resumeDto = {
|
||||
resumeId: z.string().describe("The ID of the resume to restore."),
|
||||
versionId: z.string().describe("The ID of the version snapshot to restore."),
|
||||
}),
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
output: z.strictObject({
|
||||
resume: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
stylesheetState: stylesheetStateSchema,
|
||||
}),
|
||||
},
|
||||
|
||||
stylesheet: {
|
||||
errors: {
|
||||
validation: z.strictObject({
|
||||
diagnostics: z.array(stylesheetDiagnosticSchema),
|
||||
}),
|
||||
parity: z.strictObject({
|
||||
mismatches: z.array(z.string()),
|
||||
}),
|
||||
revisionConflict: z.strictObject({
|
||||
state: stylesheetStateSchema,
|
||||
}),
|
||||
},
|
||||
getState: {
|
||||
input: z.strictObject({ id: z.string().describe("The ID of the resume.") }),
|
||||
output: stylesheetStateSchema,
|
||||
},
|
||||
mutate: {
|
||||
input: z.discriminatedUnion("transition", [
|
||||
z.strictObject({
|
||||
...stylesheetMutationCommon,
|
||||
transition: z.literal("edit_source"),
|
||||
source: stylesheetSourceSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...stylesheetMutationCommon,
|
||||
transition: z.literal("activate"),
|
||||
source: stylesheetSourceSchema,
|
||||
}),
|
||||
z.strictObject({
|
||||
...stylesheetMutationCommon,
|
||||
transition: z.literal("deactivate"),
|
||||
}),
|
||||
z.strictObject({
|
||||
...stylesheetMutationCommon,
|
||||
transition: z.literal("restore_history"),
|
||||
restore: z.strictObject({
|
||||
mode: z.enum(["legacy", "semantic"]),
|
||||
source: stylesheetSourceSchema,
|
||||
applied: stylesheetSourceSchema,
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
output: stylesheetStateSchema.extend({
|
||||
editGeneration: z.number().int().nonnegative(),
|
||||
diagnostics: z.array(stylesheetDiagnosticSchema),
|
||||
}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -7,6 +7,8 @@ export type FeatureFlags = {
|
||||
disableEmailAuth: boolean;
|
||||
showSponsors: boolean;
|
||||
smtpEnabled: boolean;
|
||||
semanticCssAuthoring: boolean;
|
||||
semanticCssDefault: boolean;
|
||||
};
|
||||
|
||||
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency).
|
||||
@@ -30,6 +32,8 @@ export const flagsRouter = {
|
||||
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
|
||||
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
|
||||
smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."),
|
||||
semanticCssAuthoring: z.boolean().describe("Whether Semantic CSS authoring is enabled on this instance."),
|
||||
semanticCssDefault: z.boolean().describe("Whether new resumes start in Semantic CSS mode."),
|
||||
}),
|
||||
)
|
||||
.handler(
|
||||
@@ -38,6 +42,8 @@ export const flagsRouter = {
|
||||
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
|
||||
showSponsors: env.FLAG_SHOW_SPONSORS,
|
||||
smtpEnabled: isSmtpEnabled(),
|
||||
semanticCssAuthoring: env.FLAG_SEMANTIC_CSS_AUTHORING,
|
||||
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
@@ -71,6 +71,25 @@ describe("redactResumeForViewer", () => {
|
||||
expect(result.data.metadata.notes).toBe("");
|
||||
});
|
||||
|
||||
it("strips editable and applied stylesheet source for non-owner", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||
const resume = {
|
||||
name: "Title",
|
||||
data: {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
stylesheet: { mode: "semantic" as const, source, applied: source },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
|
||||
expect(result.data.metadata.stylesheet).toBeUndefined();
|
||||
expect(JSON.stringify(result)).not.toContain("@version");
|
||||
});
|
||||
|
||||
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
|
||||
const resume = {
|
||||
name: "Dashboard title",
|
||||
|
||||
@@ -53,6 +53,7 @@ export function redactResumeForViewer<T extends { name: string; data: ResumeData
|
||||
viewerIsOwner: boolean,
|
||||
): T {
|
||||
if (viewerIsOwner) return resume;
|
||||
const { stylesheet: _stylesheet, ...metadata } = resume.data.metadata;
|
||||
|
||||
return {
|
||||
...resume,
|
||||
@@ -60,7 +61,7 @@ export function redactResumeForViewer<T extends { name: string; data: ResumeData
|
||||
data: {
|
||||
...resume.data,
|
||||
metadata: {
|
||||
...resume.data.metadata,
|
||||
...metadata,
|
||||
notes: "",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
create: vi.fn(),
|
||||
getById: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: { FLAG_SEMANTIC_CSS_DEFAULT: false } }));
|
||||
|
||||
vi.mock("../../context", async () => {
|
||||
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||
return {
|
||||
protectedProcedure: os.$context<{
|
||||
locale: "en-US";
|
||||
reqHeaders: Headers;
|
||||
user: { id: string };
|
||||
}>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
resumeService: {
|
||||
create: mocks.create,
|
||||
getById: mocks.getById,
|
||||
},
|
||||
}));
|
||||
|
||||
const { crudRouter } = await import("./crud");
|
||||
|
||||
const rendererUnsafeData = (): ResumeData =>
|
||||
({
|
||||
...structuredClone(defaultResumeData),
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||
},
|
||||
],
|
||||
}) as unknown as ResumeData;
|
||||
|
||||
describe("resume duplicate route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.create.mockResolvedValue("copy-id");
|
||||
});
|
||||
|
||||
it("rejects invalid stored source data before calling the shared create service", async () => {
|
||||
mocks.getById.mockResolvedValue({
|
||||
id: "resume-id",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
data: rendererUnsafeData(),
|
||||
});
|
||||
const client = createRouterClient(crudRouter, {
|
||||
context: { locale: "en-US", reqHeaders: new Headers(), user: { id: "user-id" } } as never,
|
||||
});
|
||||
|
||||
const error = await client
|
||||
.duplicate({ id: "resume-id", name: "Copy", slug: "copy", tags: [] })
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", status: 500 });
|
||||
expect(error).toHaveProperty("cause.issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||
expect(mocks.create).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,13 @@
|
||||
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
import { resumeService } from "./service";
|
||||
import { prepareImportedResumeData } from "./stylesheet-preflight";
|
||||
import { createResumeData } from "./stylesheet-preservation";
|
||||
|
||||
export const crudRouter = {
|
||||
list: protectedProcedure
|
||||
@@ -69,7 +73,12 @@ export const crudRouter = {
|
||||
tags: input.tags,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
|
||||
data: createResumeData({
|
||||
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||
withSampleData: input.withSampleData,
|
||||
name: input.name,
|
||||
locale: context.locale,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -92,16 +101,32 @@ export const crudRouter = {
|
||||
message: "A resume with this slug already exists.",
|
||||
status: 400,
|
||||
},
|
||||
SEMANTIC_STYLESHEET_UNAVAILABLE: {
|
||||
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||
status: 503,
|
||||
},
|
||||
STYLESHEET_VALIDATION_FAILED: {
|
||||
message: "The imported stylesheet failed validation.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
const id = generateId();
|
||||
const data = await prepareImportedResumeData({
|
||||
data: resumeDataSchema.parse(input.data),
|
||||
resumeId: id,
|
||||
revision: 0,
|
||||
...(context.stylesheetPreflightRunner ? { runner: context.stylesheetPreflightRunner } : {}),
|
||||
});
|
||||
const name = generateRandomName();
|
||||
const slug = slugify(name);
|
||||
|
||||
const id = await resumeService.create({
|
||||
await resumeService.create({
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
tags: [],
|
||||
data: input.data,
|
||||
data,
|
||||
locale: context.locale,
|
||||
userId: context.user.id,
|
||||
});
|
||||
@@ -110,7 +135,7 @@ export const crudRouter = {
|
||||
await resumeService.versions.snapshot({
|
||||
resumeId: id,
|
||||
userId: context.user.id,
|
||||
data: input.data,
|
||||
data,
|
||||
label: "Imported",
|
||||
});
|
||||
|
||||
@@ -220,6 +245,7 @@ export const crudRouter = {
|
||||
.output(resumeDto.duplicate.output)
|
||||
.handler(async ({ context, input }) => {
|
||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||
const data = parseStoredResumeData(original.data);
|
||||
|
||||
return resumeService.create({
|
||||
userId: context.user.id,
|
||||
@@ -227,7 +253,7 @@ export const crudRouter = {
|
||||
slug: input.slug ?? original.slug,
|
||||
tags: input.tags ?? original.tags,
|
||||
locale: context.locale,
|
||||
data: original.data,
|
||||
data,
|
||||
});
|
||||
}),
|
||||
|
||||
|
||||
@@ -144,6 +144,31 @@ describe("subscribeResumeUpdated", () => {
|
||||
await iterator.next();
|
||||
});
|
||||
|
||||
it("accepts stylesheet invalidations and ignores unknown mutation names", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
const controller = new AbortController();
|
||||
const iterator = subscribeResumeUpdated({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const resultP = iterator.next();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "forged" }));
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "stylesheet" }));
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.value?.mutation).toBe("stylesheet");
|
||||
|
||||
controller.abort();
|
||||
await iterator.next();
|
||||
});
|
||||
|
||||
it("terminates immediately if signal is already aborted", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
|
||||
const RESUME_UPDATED_CHANNEL = "resume_updated";
|
||||
const resumeMutationNames = new Set([
|
||||
"sync",
|
||||
"create",
|
||||
"update",
|
||||
"patch",
|
||||
"lock",
|
||||
"password",
|
||||
"delete",
|
||||
"stylesheet",
|
||||
] as const);
|
||||
|
||||
type PgNotification = {
|
||||
channel?: string | undefined;
|
||||
@@ -12,7 +22,7 @@ export type ResumeUpdatedEvent = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
updatedAt: string;
|
||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
|
||||
};
|
||||
|
||||
type SubscribeResumeUpdatedInput = {
|
||||
@@ -30,7 +40,8 @@ function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent {
|
||||
typeof event.resumeId === "string" &&
|
||||
typeof event.userId === "string" &&
|
||||
typeof event.updatedAt === "string" &&
|
||||
typeof event.mutation === "string"
|
||||
typeof event.mutation === "string" &&
|
||||
resumeMutationNames.has(event.mutation as ResumeUpdatedEvent["mutation"])
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getById: vi.fn(),
|
||||
renderPdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./service", () => ({
|
||||
resumeService: {
|
||||
getById: mocks.getById,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/pdf/server", () => ({
|
||||
createResumePdfFile: mocks.renderPdf,
|
||||
}));
|
||||
|
||||
const { createResumePdfDownload } = await import("./export");
|
||||
|
||||
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("createResumePdfDownload", () => {
|
||||
beforeEach(() => {
|
||||
mocks.getById.mockReset();
|
||||
mocks.renderPdf.mockReset();
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe stored data before export projection or PDF dispatch", async () => {
|
||||
mocks.getById.mockResolvedValue({
|
||||
id: "resume-1",
|
||||
name: "Resume",
|
||||
data: createRendererUnsafeResumeData(),
|
||||
});
|
||||
|
||||
await expect(createResumePdfDownload({ id: "resume-1", userId: "user-1" })).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
|
||||
expect(mocks.renderPdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still renders valid stored data", async () => {
|
||||
const body = new File(["%PDF"], "resume.pdf", { type: "application/pdf" });
|
||||
mocks.getById.mockResolvedValue({
|
||||
id: "resume-1",
|
||||
name: "Resume",
|
||||
data: createLegacyRendererSafeResumeData(),
|
||||
});
|
||||
mocks.renderPdf.mockResolvedValue(body);
|
||||
|
||||
await expect(createResumePdfDownload({ id: "resume-1", userId: "user-1" })).resolves.toMatchObject({ body });
|
||||
expect(mocks.renderPdf.mock.calls[0]?.[0].data.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resu
|
||||
import { generateFilename } from "@reactive-resume/utils/file";
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
export {
|
||||
@@ -21,8 +22,9 @@ type CreateResumePdfDownloadInput = {
|
||||
|
||||
export async function createResumePdfDownload(input: CreateResumePdfDownloadInput) {
|
||||
const resume = await resumeService.getById({ id: input.id, userId: input.userId });
|
||||
const data = parseStoredResumeData(resume.data);
|
||||
const target = input.target ?? "resume";
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) {
|
||||
if (target === "cover-letter" && !resumeHasCoverLetter(data)) {
|
||||
throw new ORPCError("NOT_FOUND", { message: "No cover letter found for this resume" });
|
||||
}
|
||||
|
||||
@@ -34,7 +36,7 @@ export async function createResumePdfDownload(input: CreateResumePdfDownloadInpu
|
||||
// exported, instead of at server boot. Slashes cold-start file I/O on
|
||||
// constrained/slow-disk hosts. See fork perf/lazy-load-pdf.
|
||||
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
|
||||
const body = await createResumePdfFile({ data: getResumeExportData(resume.data, target), filename });
|
||||
const body = await createResumePdfFile({ data: getResumeExportData(data, target), filename });
|
||||
|
||||
return {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createPublicResumePdf } from "./public-pdf";
|
||||
import { createPublicRenderRateLimiter } from "./public-render-rate-limit";
|
||||
import { getStyleProjection } from "./public-style-projection";
|
||||
|
||||
const requestHeaders = new Headers({ "x-forwarded-for": "203.0.113.7" });
|
||||
const input = {
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders,
|
||||
trustedClient: "203.0.113.9",
|
||||
mismatchReason: "render-data-hash" as const,
|
||||
};
|
||||
|
||||
const buildResume = (overrides: Partial<{ isPublic: boolean; passwordHash: string | null }> = {}) => ({
|
||||
id: "resume-1",
|
||||
userId: "owner-1",
|
||||
name: "Private dashboard title",
|
||||
slug: "resume",
|
||||
data: structuredClone(defaultResumeData),
|
||||
isPublic: overrides.isPublic ?? true,
|
||||
passwordHash: overrides.passwordHash ?? null,
|
||||
});
|
||||
|
||||
const buildRendererUnsafeResume = () => {
|
||||
const resume = buildResume();
|
||||
resume.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 resume;
|
||||
};
|
||||
|
||||
describe("createPublicResumePdf", () => {
|
||||
it("rejects unbounded mismatch metadata before access or rendering", async () => {
|
||||
const findResume = vi.fn();
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(
|
||||
{
|
||||
...input,
|
||||
mismatchReason: "private source" as typeof input.mismatchReason,
|
||||
clientRegistryFingerprint: "not-a-fingerprint",
|
||||
},
|
||||
{
|
||||
findResume,
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn(),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
renderPdf: vi.fn(),
|
||||
getFingerprints: vi.fn(),
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||
expect(findResume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("authorizes private and password-protected resumes before budget or render", async () => {
|
||||
const consume = vi.fn();
|
||||
const renderPdf = vi.fn();
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(input, {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume({ isPublic: false })),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume },
|
||||
renderPdf,
|
||||
getFingerprints: vi.fn(),
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
expect(renderPdf).not.toHaveBeenCalled();
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(input, {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume({ passwordHash: "hash" })),
|
||||
hasPasswordAccess: vi.fn().mockReturnValue(false),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume },
|
||||
renderPdf,
|
||||
getFingerprints: vi.fn(),
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NEED_PASSWORD" });
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
expect(renderPdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe stored data before budget, projection metadata, or rendering", async () => {
|
||||
const consume = vi.fn();
|
||||
const renderPdf = vi.fn();
|
||||
const getFingerprints = vi.fn();
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(input, {
|
||||
findResume: vi.fn().mockResolvedValue(buildRendererUnsafeResume()),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume },
|
||||
renderPdf,
|
||||
getFingerprints,
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" });
|
||||
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
expect(getFingerprints).not.toHaveBeenCalled();
|
||||
expect(renderPdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shares the exact limiter with style projection", async () => {
|
||||
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||
const findResume = vi.fn().mockResolvedValue(buildResume());
|
||||
const hasPasswordAccess = vi.fn();
|
||||
const projectionInput = {
|
||||
...input,
|
||||
requestHeaders: new Headers({ "x-forwarded-for": "198.51.100.1" }),
|
||||
};
|
||||
const pdfInput = {
|
||||
...input,
|
||||
requestHeaders: new Headers({ "x-forwarded-for": "198.51.100.2" }),
|
||||
};
|
||||
|
||||
await getStyleProjection(projectionInput, {
|
||||
findResume,
|
||||
hasPasswordAccess,
|
||||
rateLimiter: limiter,
|
||||
createProjection: vi.fn().mockResolvedValue({
|
||||
formatVersion: 1,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: 1,
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
renderDataHash: "2".repeat(64),
|
||||
nodes: {},
|
||||
}),
|
||||
cache: new Map(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(pdfInput, {
|
||||
findResume,
|
||||
hasPasswordAccess,
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: limiter,
|
||||
renderPdf: vi.fn(),
|
||||
getFingerprints: vi.fn(),
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" });
|
||||
});
|
||||
|
||||
it("does not accept a caller-supplied owner identity for a private resume", async () => {
|
||||
const forgedInput = { ...input, currentUserId: "owner-1" };
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(forgedInput, {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume({ isPublic: false })),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
renderPdf: vi.fn().mockResolvedValue(new File(["%PDF"], "resume.pdf")),
|
||||
getFingerprints: vi.fn().mockResolvedValue({
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
}),
|
||||
now: () => 0,
|
||||
observe: vi.fn(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
});
|
||||
|
||||
it("propagates semantic diagnostics instead of returning an unstyled fallback PDF", async () => {
|
||||
const observe = vi.fn();
|
||||
const semanticError = new Error("The semantic stylesheet could not be rendered.", {
|
||||
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||
});
|
||||
const renderPdf = vi.fn().mockRejectedValue(semanticError);
|
||||
|
||||
await expect(
|
||||
createPublicResumePdf(input, {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume()),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
renderPdf,
|
||||
getFingerprints: vi.fn().mockResolvedValue({
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
}),
|
||||
now: () => 10,
|
||||
observe,
|
||||
}),
|
||||
).rejects.toBe(semanticError);
|
||||
|
||||
expect(renderPdf).toHaveBeenCalledTimes(1);
|
||||
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
|
||||
expect(observe).not.toHaveBeenCalledWith(expect.objectContaining({ success: true }));
|
||||
});
|
||||
|
||||
it("emits source-free, hashed fallback metadata", async () => {
|
||||
const sensitive = "Ada <ada@example.test> /* source */";
|
||||
const observe = vi.fn();
|
||||
let now = 10;
|
||||
const resume = buildResume();
|
||||
resume.id = sensitive;
|
||||
resume.data.basics.name = sensitive;
|
||||
resume.data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: sensitive },
|
||||
applied: { languageVersion: 1, text: sensitive },
|
||||
};
|
||||
|
||||
await createPublicResumePdf(input, {
|
||||
findResume: vi.fn().mockResolvedValue(resume),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
renderPdf: vi.fn().mockResolvedValue(new File(["%PDF"], "resume.pdf", { type: "application/pdf" })),
|
||||
getFingerprints: vi.fn().mockResolvedValue({
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
}),
|
||||
now: () => (now += 5),
|
||||
observe,
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(observe.mock.calls);
|
||||
expect(observe).toHaveBeenCalledWith({
|
||||
name: "semantic_css.render_fallback",
|
||||
resumeIdHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
mismatchReason: "render-data-hash",
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
durationMs: 5,
|
||||
success: true,
|
||||
});
|
||||
expect(serialized).not.toContain(sensitive);
|
||||
expect(serialized).not.toMatch(/source|comment|diagnostic|email/i);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { PublicRenderAccessDependencies } from "./public-style-projection";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { generateFilename } from "@reactive-resume/utils/file";
|
||||
import { publicRenderRateLimiter } from "./public-render-rate-limit";
|
||||
import { defaultPublicRenderAccessDependencies, loadAuthorizedPublicRenderResume } from "./public-style-projection";
|
||||
import { hashSemanticCssResumeId } from "./stylesheet-observability";
|
||||
|
||||
export type PublicResumePdfMismatchReason =
|
||||
| "missing-projection"
|
||||
| "format-version"
|
||||
| "language-version"
|
||||
| "semantic-tree-version"
|
||||
| "registry-fingerprint"
|
||||
| "adapter-fingerprint"
|
||||
| "render-data-hash"
|
||||
| "invalid-projection";
|
||||
|
||||
export const PUBLIC_RESUME_PDF_MISMATCH_REASONS = [
|
||||
"missing-projection",
|
||||
"format-version",
|
||||
"language-version",
|
||||
"semantic-tree-version",
|
||||
"registry-fingerprint",
|
||||
"adapter-fingerprint",
|
||||
"render-data-hash",
|
||||
"invalid-projection",
|
||||
] as const satisfies readonly PublicResumePdfMismatchReason[];
|
||||
|
||||
export type CreatePublicResumePdfInput = {
|
||||
username: string;
|
||||
slug: string;
|
||||
requestHeaders: Headers;
|
||||
trustedClient: string;
|
||||
mismatchReason: PublicResumePdfMismatchReason;
|
||||
clientRegistryFingerprint?: string;
|
||||
clientAdapterFingerprint?: string;
|
||||
};
|
||||
|
||||
export type PublicResumePdfDependencies = PublicRenderAccessDependencies & {
|
||||
resolveCurrentUserId(requestHeaders: Headers): Promise<string | undefined>;
|
||||
rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void };
|
||||
renderPdf(input: { data: ResumeData; filename: string }): Promise<File>;
|
||||
getFingerprints(): Promise<{ registryFingerprint: string; adapterFingerprint: string }>;
|
||||
now(): number;
|
||||
observe(event: Readonly<Record<string, unknown>>): void;
|
||||
};
|
||||
|
||||
const defaultDependencies: PublicResumePdfDependencies = {
|
||||
...defaultPublicRenderAccessDependencies,
|
||||
resolveCurrentUserId: async (requestHeaders) =>
|
||||
(await import("../../context")).resolveUserFromRequestHeaders(requestHeaders).then((user) => user?.id),
|
||||
rateLimiter: publicRenderRateLimiter,
|
||||
renderPdf: async (input) => (await import("@reactive-resume/pdf/server")).createResumePdfFile(input),
|
||||
getFingerprints: async () =>
|
||||
(await import("@reactive-resume/pdf/public-projection")).getPublicStyleProjectionFingerprints(),
|
||||
now: Date.now,
|
||||
observe: console.info,
|
||||
};
|
||||
|
||||
export async function createPublicResumePdf(
|
||||
input: CreatePublicResumePdfInput,
|
||||
dependencies: PublicResumePdfDependencies = defaultDependencies,
|
||||
): Promise<{ body: File; filename: string }> {
|
||||
const fingerprintPattern = /^[a-f0-9]{64}$/;
|
||||
if (
|
||||
!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(input.mismatchReason) ||
|
||||
(input.clientRegistryFingerprint !== undefined && !fingerprintPattern.test(input.clientRegistryFingerprint)) ||
|
||||
(input.clientAdapterFingerprint !== undefined && !fingerprintPattern.test(input.clientAdapterFingerprint))
|
||||
) {
|
||||
throw new ORPCError("BAD_REQUEST", { status: 400, message: "Invalid public PDF fallback metadata." });
|
||||
}
|
||||
const currentUserId = await dependencies.resolveCurrentUserId(input.requestHeaders);
|
||||
const resume = await loadAuthorizedPublicRenderResume(
|
||||
{
|
||||
username: input.username,
|
||||
slug: input.slug,
|
||||
requestHeaders: input.requestHeaders,
|
||||
trustedClient: input.trustedClient,
|
||||
...(currentUserId ? { currentUserId } : {}),
|
||||
},
|
||||
dependencies,
|
||||
);
|
||||
dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id });
|
||||
const startedAt = dependencies.now();
|
||||
const fingerprints = await dependencies.getFingerprints();
|
||||
const event = (success: boolean) => {
|
||||
dependencies.observe({
|
||||
name: "semantic_css.render_fallback",
|
||||
resumeIdHash: hashSemanticCssResumeId(resume.id),
|
||||
mismatchReason: input.mismatchReason,
|
||||
...(input.clientRegistryFingerprint ? { clientRegistryFingerprint: input.clientRegistryFingerprint } : {}),
|
||||
...(input.clientAdapterFingerprint ? { clientAdapterFingerprint: input.clientAdapterFingerprint } : {}),
|
||||
...fingerprints,
|
||||
durationMs: Math.max(0, dependencies.now() - startedAt),
|
||||
success,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const filename = generateFilename(resume.data.basics.name || "Resume", "pdf");
|
||||
const body = await dependencies.renderPdf({ data: resume.data, filename });
|
||||
event(true);
|
||||
return {
|
||||
body,
|
||||
filename,
|
||||
};
|
||||
} catch (error) {
|
||||
event(false);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createPublicRenderRateLimiter } from "./public-render-rate-limit";
|
||||
|
||||
describe("public render rate limit", () => {
|
||||
it("cannot reset a transport client's budget by rotating forwarding headers", () => {
|
||||
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||
const first = {
|
||||
trustedClient: "203.0.113.9",
|
||||
requestHeaders: new Headers({
|
||||
"cf-connecting-ip": "198.51.100.1",
|
||||
"x-forwarded-for": "198.51.100.2",
|
||||
}),
|
||||
resumeId: "resume-1",
|
||||
};
|
||||
const rotated = {
|
||||
...first,
|
||||
requestHeaders: new Headers({
|
||||
"cf-connecting-ip": "198.51.100.3",
|
||||
"x-forwarded-for": "198.51.100.4",
|
||||
}),
|
||||
};
|
||||
|
||||
limiter.consume(first);
|
||||
|
||||
expect(() => limiter.consume(rotated)).toThrowError(
|
||||
expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("shares one IP-and-resume token bucket across projection and PDF consumers", () => {
|
||||
const limiter = createPublicRenderRateLimiter({ capacity: 2, refillWindowMs: 60_000, now: () => 0 });
|
||||
const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" };
|
||||
|
||||
limiter.consume(input);
|
||||
limiter.consume(input);
|
||||
|
||||
expect(() => limiter.consume(input)).toThrowError(
|
||||
expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps budgets separate by client IP and resume", () => {
|
||||
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||
limiter.consume({ trustedClient: "203.0.113.7", resumeId: "resume-1" });
|
||||
|
||||
expect(() => limiter.consume({ trustedClient: "203.0.113.8", resumeId: "resume-1" })).not.toThrow();
|
||||
expect(() => limiter.consume({ trustedClient: "203.0.113.7", resumeId: "resume-2" })).not.toThrow();
|
||||
});
|
||||
|
||||
it("refills the bounded bucket over time", () => {
|
||||
let now = 0;
|
||||
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 1_000, now: () => now });
|
||||
const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" };
|
||||
limiter.consume(input);
|
||||
expect(() => limiter.consume(input)).toThrow();
|
||||
|
||||
now = 1_000;
|
||||
|
||||
expect(() => limiter.consume(input)).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { ORPCError } from "@orpc/server";
|
||||
|
||||
type PublicRenderRateLimitInput = {
|
||||
/** Sanitized transport identity supplied by the server adapter, never by request headers. */
|
||||
trustedClient: string;
|
||||
resumeId: string;
|
||||
};
|
||||
|
||||
export type PublicRenderRateLimiter = {
|
||||
consume(input: PublicRenderRateLimitInput): void;
|
||||
};
|
||||
|
||||
type Bucket = {
|
||||
tokens: number;
|
||||
updatedAt: number;
|
||||
};
|
||||
|
||||
const MAX_BUCKETS = 50_000;
|
||||
|
||||
export function createPublicRenderRateLimiter(
|
||||
options: { capacity?: number; refillWindowMs?: number; now?: () => number } = {},
|
||||
): PublicRenderRateLimiter {
|
||||
const capacity = options.capacity ?? 6;
|
||||
const refillWindowMs = options.refillWindowMs ?? 60_000;
|
||||
const now = options.now ?? Date.now;
|
||||
if (!Number.isInteger(capacity) || capacity <= 0 || !Number.isFinite(refillWindowMs) || refillWindowMs <= 0) {
|
||||
throw new Error("Public render token bucket requires positive finite limits");
|
||||
}
|
||||
const buckets = new Map<string, Bucket>();
|
||||
|
||||
return {
|
||||
consume(input) {
|
||||
const currentTime = now();
|
||||
const trustedClient = input.trustedClient.trim() || "unknown";
|
||||
const key = `${trustedClient}:${input.resumeId}`;
|
||||
const previous = buckets.get(key) ?? { tokens: capacity, updatedAt: currentTime };
|
||||
const elapsed = Math.max(0, currentTime - previous.updatedAt);
|
||||
const tokens = Math.min(capacity, previous.tokens + (elapsed * capacity) / refillWindowMs);
|
||||
|
||||
if (tokens < 1) {
|
||||
throw new ORPCError("RATE_LIMIT_EXCEEDED", {
|
||||
status: 429,
|
||||
message: "Public resume rendering rate limit exceeded.",
|
||||
});
|
||||
}
|
||||
|
||||
buckets.delete(key);
|
||||
buckets.set(key, { tokens: tokens - 1, updatedAt: currentTime });
|
||||
if (buckets.size <= MAX_BUCKETS) return;
|
||||
|
||||
for (const [candidate, bucket] of buckets) {
|
||||
if (currentTime - bucket.updatedAt >= refillWindowMs) buckets.delete(candidate);
|
||||
}
|
||||
if (buckets.size > MAX_BUCKETS) buckets.delete(buckets.keys().next().value as string);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const publicRenderRateLimiter = createPublicRenderRateLimiter();
|
||||
@@ -0,0 +1,72 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getStyleProjection: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../context", async () => {
|
||||
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||
const base = os.$context<{
|
||||
locale: "en-US";
|
||||
reqHeaders: Headers;
|
||||
resHeaders?: Headers;
|
||||
trustedClient?: string;
|
||||
}>();
|
||||
return { publicProcedure: base, protectedProcedure: base };
|
||||
});
|
||||
|
||||
vi.mock("./public-style-projection", () => ({
|
||||
getStyleProjection: mocks.getStyleProjection,
|
||||
}));
|
||||
|
||||
vi.mock("./service", () => ({ resumeService: {} }));
|
||||
|
||||
const { sharingRouter } = await import("./sharing");
|
||||
|
||||
describe("public style projection route", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.getStyleProjection.mockResolvedValue({
|
||||
formatVersion: 1,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: 1,
|
||||
registryFingerprint: "0".repeat(64),
|
||||
adapterFingerprint: "1".repeat(64),
|
||||
renderDataHash: "2".repeat(64),
|
||||
nodes: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("mounts a concrete public GET route beside the ordinary JSON read", () => {
|
||||
expect(sharingRouter.getStyleProjection["~orpc"].route).toMatchObject({
|
||||
method: "GET",
|
||||
path: "/resumes/{username}/{slug}/style-projection",
|
||||
operationId: "getResumeStyleProjection",
|
||||
});
|
||||
expect(sharingRouter.getBySlug["~orpc"].route.path).toBe("/resumes/{username}/{slug}");
|
||||
});
|
||||
|
||||
it("marks the concrete projection response private and uses the server-derived client identity", async () => {
|
||||
const reqHeaders = new Headers({ "x-forwarded-for": "198.51.100.1" });
|
||||
const resHeaders = new Headers();
|
||||
const client = createRouterClient(sharingRouter, {
|
||||
context: {
|
||||
locale: "en-US",
|
||||
reqHeaders,
|
||||
resHeaders,
|
||||
trustedClient: "203.0.113.9",
|
||||
},
|
||||
});
|
||||
|
||||
await client.getStyleProjection({ username: "jane", slug: "resume" });
|
||||
|
||||
expect(resHeaders.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(mocks.getStyleProjection).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: reqHeaders,
|
||||
trustedClient: "203.0.113.9",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { getStyleProjection } from "./public-style-projection";
|
||||
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nname { color: #123456; }\n",
|
||||
};
|
||||
|
||||
const buildResume = () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||
return {
|
||||
id: "resume-1",
|
||||
userId: "owner-1",
|
||||
name: "Private dashboard title",
|
||||
slug: "resume",
|
||||
data,
|
||||
isPublic: true,
|
||||
passwordHash: null,
|
||||
};
|
||||
};
|
||||
|
||||
const input = {
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: new Headers({ "x-forwarded-for": "203.0.113.7" }),
|
||||
trustedClient: "203.0.113.9",
|
||||
};
|
||||
|
||||
describe("getStyleProjection", () => {
|
||||
it("authorizes before consuming budget or looking up a cached projection", async () => {
|
||||
const consume = vi.fn();
|
||||
const createProjection = vi.fn();
|
||||
const cache = new Map<string, PublicStyleProjection>([
|
||||
["cached", { renderDataHash: "cached" } as PublicStyleProjection],
|
||||
]);
|
||||
|
||||
await expect(
|
||||
getStyleProjection(input, {
|
||||
findResume: vi.fn().mockResolvedValue({ ...buildResume(), isPublic: false }),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
rateLimiter: { consume },
|
||||
createProjection,
|
||||
cache,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
expect(createProjection).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves password authorization before consuming the shared render budget", async () => {
|
||||
const consume = vi.fn();
|
||||
|
||||
await expect(
|
||||
getStyleProjection(input, {
|
||||
findResume: vi.fn().mockResolvedValue({ ...buildResume(), passwordHash: "hash" }),
|
||||
hasPasswordAccess: vi.fn().mockReturnValue(false),
|
||||
rateLimiter: { consume },
|
||||
createProjection: vi.fn(),
|
||||
cache: new Map(),
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "NEED_PASSWORD", status: 401 });
|
||||
|
||||
expect(consume).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a source-free resolved projection and consumes one render token", async () => {
|
||||
const consume = vi.fn();
|
||||
const result = await getStyleProjection(input, {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume()),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
rateLimiter: { consume },
|
||||
createProjection: createPublicStyleProjection,
|
||||
cache: new Map(),
|
||||
});
|
||||
|
||||
expect(result).toEqual(expect.objectContaining({ formatVersion: 1, nodes: expect.any(Object) }));
|
||||
expect(JSON.stringify(result)).not.toContain("@version");
|
||||
expect(consume).toHaveBeenCalledOnce();
|
||||
expect(consume).toHaveBeenCalledWith({ trustedClient: "203.0.113.9", resumeId: "resume-1" });
|
||||
});
|
||||
|
||||
it("normalizes stored data before public projection", async () => {
|
||||
const resume = buildResume();
|
||||
resume.data.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 never,
|
||||
];
|
||||
const projection = {
|
||||
formatVersion: 1,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: 1,
|
||||
registryFingerprint: "registry",
|
||||
adapterFingerprint: "adapter",
|
||||
renderDataHash: "render-hash",
|
||||
nodes: {},
|
||||
} satisfies PublicStyleProjection;
|
||||
const createProjection = vi.fn().mockResolvedValue(projection);
|
||||
|
||||
await getStyleProjection(input, {
|
||||
findResume: vi.fn().mockResolvedValue(resume),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
createProjection,
|
||||
cache: new Map(),
|
||||
});
|
||||
|
||||
expect(createProjection.mock.calls[0]?.[0].data.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("caches only authorized projections by renderDataHash", async () => {
|
||||
const projection = {
|
||||
formatVersion: 1,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: 1,
|
||||
registryFingerprint: "registry",
|
||||
adapterFingerprint: "adapter",
|
||||
renderDataHash: "render-hash",
|
||||
nodes: {},
|
||||
} satisfies PublicStyleProjection;
|
||||
const createProjection = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(projection)
|
||||
.mockResolvedValueOnce({ ...projection });
|
||||
const cache = new Map<string, PublicStyleProjection>();
|
||||
const dependencies = {
|
||||
findResume: vi.fn().mockResolvedValue(buildResume()),
|
||||
hasPasswordAccess: vi.fn(),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
createProjection,
|
||||
cache,
|
||||
};
|
||||
|
||||
const first = await getStyleProjection(input, dependencies);
|
||||
const second = await getStyleProjection(input, dependencies);
|
||||
|
||||
expect(cache.get("render-hash")).toBe(first);
|
||||
expect(second).toBe(first);
|
||||
});
|
||||
|
||||
it("uses a strict source-free public projection DTO", async () => {
|
||||
const projection = await createPublicStyleProjection({ data: buildResume().data });
|
||||
|
||||
expect(resumeDto.getStyleProjection.output.safeParse(projection).success).toBe(true);
|
||||
expect(
|
||||
resumeDto.getStyleProjection.output.safeParse({
|
||||
...projection,
|
||||
source,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resumeDto.getStyleProjection.output.safeParse({
|
||||
...projection,
|
||||
nodes: {
|
||||
...projection.nodes,
|
||||
private: { diagnostics: [{ message: "source location" }] },
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/server";
|
||||
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||
import { assertCanView, isOwner } from "./access-policy";
|
||||
import { publicRenderRateLimiter } from "./public-render-rate-limit";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
|
||||
type PublicRenderResume = {
|
||||
id: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
data: ResumeData;
|
||||
isPublic: boolean;
|
||||
passwordHash: string | null;
|
||||
};
|
||||
|
||||
export type GetStyleProjectionInput = {
|
||||
username: string;
|
||||
slug: string;
|
||||
requestHeaders: Headers;
|
||||
trustedClient: string;
|
||||
currentUserId?: string;
|
||||
};
|
||||
|
||||
export type PublicStyleProjectionDependencies = {
|
||||
findResume(input: Pick<GetStyleProjectionInput, "username" | "slug">): Promise<PublicRenderResume | null>;
|
||||
hasPasswordAccess(requestHeaders: Headers, resumeId: string, passwordHash: string | null): boolean | Promise<boolean>;
|
||||
rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void };
|
||||
createProjection(input: { data: ResumeData }): Promise<PublicStyleProjection>;
|
||||
cache: Map<string, PublicStyleProjection>;
|
||||
};
|
||||
|
||||
export type AuthorizedPublicRenderResume = PublicRenderResume & {
|
||||
viewerIsOwner: boolean;
|
||||
hasPassword: boolean;
|
||||
};
|
||||
|
||||
export type PublicRenderAccessDependencies = Pick<
|
||||
PublicStyleProjectionDependencies,
|
||||
"findResume" | "hasPasswordAccess"
|
||||
>;
|
||||
|
||||
const findResume = async ({
|
||||
username,
|
||||
slug,
|
||||
}: Pick<GetStyleProjectionInput, "username" | "slug">): Promise<PublicRenderResume | null> => {
|
||||
const [{ db }, schema, { and, eq }] = await Promise.all([
|
||||
import("@reactive-resume/db/client"),
|
||||
import("@reactive-resume/db/schema"),
|
||||
import("drizzle-orm"),
|
||||
]);
|
||||
const [resume] = await db
|
||||
.select({
|
||||
id: schema.resume.id,
|
||||
userId: schema.resume.userId,
|
||||
name: schema.resume.name,
|
||||
slug: schema.resume.slug,
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
passwordHash: schema.resume.password,
|
||||
})
|
||||
.from(schema.resume)
|
||||
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
|
||||
.where(and(eq(schema.resume.slug, slug), eq(schema.user.username, username)));
|
||||
|
||||
return resume ?? null;
|
||||
};
|
||||
|
||||
export const defaultPublicRenderAccessDependencies: PublicRenderAccessDependencies = {
|
||||
findResume,
|
||||
hasPasswordAccess: async (requestHeaders, resumeId, passwordHash) =>
|
||||
(await import("./access")).hasResumeAccess(requestHeaders, resumeId, passwordHash),
|
||||
};
|
||||
|
||||
const projectionCache = new Map<string, PublicStyleProjection>();
|
||||
const MAX_PROJECTION_CACHE_ENTRIES = 128;
|
||||
|
||||
const defaultDependencies: PublicStyleProjectionDependencies = {
|
||||
...defaultPublicRenderAccessDependencies,
|
||||
rateLimiter: publicRenderRateLimiter,
|
||||
createProjection: createPublicStyleProjection,
|
||||
cache: projectionCache,
|
||||
};
|
||||
|
||||
export async function loadAuthorizedPublicRenderResume(
|
||||
input: GetStyleProjectionInput,
|
||||
dependencies: PublicRenderAccessDependencies = defaultPublicRenderAccessDependencies,
|
||||
): Promise<AuthorizedPublicRenderResume> {
|
||||
const resume = await dependencies.findResume(input);
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
|
||||
assertCanView(resume, viewer);
|
||||
if (
|
||||
resume.passwordHash &&
|
||||
!(await dependencies.hasPasswordAccess(input.requestHeaders, resume.id, resume.passwordHash))
|
||||
) {
|
||||
throw new ORPCError("NEED_PASSWORD", {
|
||||
status: 401,
|
||||
data: { username: input.username, slug: input.slug },
|
||||
});
|
||||
}
|
||||
const data = parseStoredResumeData(resume.data);
|
||||
|
||||
return {
|
||||
...resume,
|
||||
data,
|
||||
viewerIsOwner: isOwner(resume, viewer),
|
||||
hasPassword: resume.passwordHash !== null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getStyleProjection(
|
||||
input: GetStyleProjectionInput,
|
||||
dependencies: PublicStyleProjectionDependencies = defaultDependencies,
|
||||
): Promise<PublicStyleProjection> {
|
||||
const resume = await loadAuthorizedPublicRenderResume(input, dependencies);
|
||||
dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id });
|
||||
const candidate = await dependencies.createProjection({ data: resume.data });
|
||||
const cached = dependencies.cache.get(candidate.renderDataHash);
|
||||
if (cached) return cached;
|
||||
|
||||
dependencies.cache.set(candidate.renderDataHash, candidate);
|
||||
if (dependencies.cache.size > MAX_PROJECTION_CACHE_ENTRIES) {
|
||||
dependencies.cache.delete(dependencies.cache.keys().next().value as string);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData {
|
||||
try {
|
||||
return parseResumeData(data);
|
||||
} catch (cause) {
|
||||
throw new ORPCError(code, {
|
||||
status: code === "BAD_REQUEST" ? 400 : 500,
|
||||
message,
|
||||
cause,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export const parseWritableResumeData = (data: unknown) =>
|
||||
parseApiResumeData(data, "BAD_REQUEST", "Resume data does not match the canonical schema.");
|
||||
|
||||
export const parseStoredResumeData = (data: unknown) =>
|
||||
parseApiResumeData(data, "INTERNAL_SERVER_ERROR", "Stored resume data does not match the canonical schema.");
|
||||
@@ -3,6 +3,7 @@ import { crudRouter } from "./crud";
|
||||
import { updatesRouter } from "./event-router";
|
||||
import { sharingRouter } from "./sharing";
|
||||
import { resumeStatisticsRouter } from "./statistics";
|
||||
import { stylesheetRouter } from "./stylesheet";
|
||||
import { tagsRouter } from "./tags";
|
||||
import { versionsRouter } from "./versions";
|
||||
|
||||
@@ -11,10 +12,12 @@ export const resumeRouter = {
|
||||
statistics: resumeStatisticsRouter,
|
||||
analysis: analysisRouter,
|
||||
updates: updatesRouter,
|
||||
stylesheet: stylesheetRouter,
|
||||
|
||||
list: crudRouter.list,
|
||||
getById: crudRouter.getById,
|
||||
getBySlug: sharingRouter.getBySlug,
|
||||
getStyleProjection: sharingRouter.getStyleProjection,
|
||||
create: crudRouter.create,
|
||||
import: crudRouter.import,
|
||||
update: crudRouter.update,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
// Characterization tests for the resume service. The goal is to pin down CURRENT behavior
|
||||
// (CRUD / lock / password / statistics branching) so later changes are deliberate. The DB
|
||||
@@ -30,6 +32,8 @@ vi.mock("@reactive-resume/db/schema", () => ({
|
||||
isPublic: "is_public",
|
||||
isLocked: "is_locked",
|
||||
password: "password",
|
||||
stylesheetRevision: "stylesheet_revision",
|
||||
renderDataVersion: "render_data_version",
|
||||
updatedAt: "updated_at",
|
||||
createdAt: "created_at",
|
||||
},
|
||||
@@ -86,7 +90,7 @@ const { resumeService } = await import("./service");
|
||||
const createUpdateChain = (rows: unknown[]) => {
|
||||
const returning = vi.fn(() => Promise.resolve(rows));
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn(() => ({ where }));
|
||||
const set = vi.fn((_input: unknown) => ({ where }));
|
||||
return { chain: { set }, set, where, returning };
|
||||
};
|
||||
|
||||
@@ -95,6 +99,134 @@ const createSelectChain = (rows: unknown[]) => ({
|
||||
from: () => ({ where: () => Promise.resolve(rows) }),
|
||||
});
|
||||
|
||||
const createLockedSelectChain = (rows: unknown[]) => {
|
||||
const forUpdate = vi.fn(() => Promise.resolve(rows));
|
||||
return {
|
||||
chain: { from: () => ({ where: () => ({ for: forUpdate }) }) },
|
||||
forUpdate,
|
||||
};
|
||||
};
|
||||
|
||||
const createSemanticResumeData = (): ResumeData => {
|
||||
const data: ResumeData = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
const createStylesheetResumeData = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
const data = createSemanticResumeData();
|
||||
if (data.metadata.stylesheet) data.metadata.stylesheet.mode = mode;
|
||||
return data;
|
||||
};
|
||||
|
||||
const createRendererUnsafeResumeData = (): ResumeData =>
|
||||
({
|
||||
...structuredClone(defaultResumeData),
|
||||
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 unknown as ResumeData;
|
||||
|
||||
const createOverlappingRendererSafeResumeData = (): 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>Renderer-irrelevant overlap must survive.</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as unknown as ResumeData;
|
||||
|
||||
const createResumeRow = (data: ResumeData, updatedAt = new Date()) => ({
|
||||
id: "r1",
|
||||
name: "Resume",
|
||||
slug: "resume",
|
||||
tags: [],
|
||||
data,
|
||||
isPublic: false,
|
||||
isLocked: false,
|
||||
updatedAt,
|
||||
hasPassword: false,
|
||||
});
|
||||
|
||||
const createRestoreHarness = (currentData: ResumeData, restoredData: ResumeData) => {
|
||||
const currentRow = { ...createResumeRow(currentData), stylesheetRevision: 3 };
|
||||
const versionLookup = {
|
||||
from: () => ({
|
||||
innerJoin: () => ({ where: () => Promise.resolve([{ data: restoredData }]) }),
|
||||
}),
|
||||
};
|
||||
const versionRetention = {
|
||||
from: () => ({ where: () => ({ orderBy: () => ({ limit: () => [] }) }) }),
|
||||
};
|
||||
dbMock.select
|
||||
.mockReturnValueOnce(createSelectChain([currentRow]))
|
||||
.mockReturnValueOnce(versionLookup)
|
||||
.mockReturnValueOnce(versionRetention)
|
||||
.mockReturnValueOnce(versionRetention);
|
||||
|
||||
const snapshotValues = vi.fn(() => Promise.resolve());
|
||||
dbMock.insert.mockReturnValue({ values: snapshotValues });
|
||||
dbMock.delete.mockReturnValue({ where: () => Promise.resolve() });
|
||||
|
||||
const lockedSelect = createLockedSelectChain([
|
||||
{
|
||||
data: currentData,
|
||||
isLocked: false,
|
||||
stylesheetRevision: 3,
|
||||
renderDataVersion: 7,
|
||||
updatedAt: currentRow.updatedAt,
|
||||
},
|
||||
]);
|
||||
let persistedData: ResumeData | undefined;
|
||||
const returning = vi.fn(() =>
|
||||
Promise.resolve([createResumeRow(persistedData ?? restoredData, new Date("2026-01-02T00:00:00Z"))]),
|
||||
);
|
||||
const where = vi.fn(() => ({ returning }));
|
||||
const set = vi.fn((values: { data: ResumeData }) => {
|
||||
persistedData = values.data;
|
||||
return { where };
|
||||
});
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => lockedSelect.chain, update: () => ({ set }) }),
|
||||
);
|
||||
|
||||
return { set, snapshotValues };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dbMock.select.mockReset();
|
||||
dbMock.insert.mockReset();
|
||||
@@ -116,17 +248,354 @@ it("imports", () => {
|
||||
expect(resumeService).toBeDefined();
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("copies stylesheet content while leaving both concurrency versions at database defaults", async () => {
|
||||
const data = createSemanticResumeData();
|
||||
const values = vi.fn((_input: unknown) => Promise.resolve());
|
||||
dbMock.insert.mockReturnValueOnce({ values });
|
||||
|
||||
await resumeService.create({
|
||||
userId: "u1",
|
||||
name: "Copy",
|
||||
slug: "copy",
|
||||
tags: [],
|
||||
locale: "en-US",
|
||||
data,
|
||||
});
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ stylesheet: data.metadata.stylesheet }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(values.mock.calls[0]?.[0]).not.toHaveProperty("stylesheetRevision");
|
||||
expect(values.mock.calls[0]?.[0]).not.toHaveProperty("renderDataVersion");
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data from direct and duplicate callers before insertion", async () => {
|
||||
const values = vi.fn(() => Promise.resolve());
|
||||
dbMock.insert.mockReturnValueOnce({ values });
|
||||
|
||||
const error = await resumeService
|
||||
.create({
|
||||
userId: "u1",
|
||||
name: "Unsafe copy",
|
||||
slug: "unsafe-copy",
|
||||
tags: [],
|
||||
locale: "en-US",
|
||||
data: createRendererUnsafeResumeData(),
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||
expect(error).toHaveProperty("cause.issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||
expect(values).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists normalized renderer-safe overlapping data", async () => {
|
||||
const values = vi.fn((_input: unknown) => Promise.resolve());
|
||||
dbMock.insert.mockReturnValueOnce({ values });
|
||||
|
||||
await resumeService.create({
|
||||
userId: "u1",
|
||||
name: "Compatible",
|
||||
slug: "compatible",
|
||||
tags: [],
|
||||
locale: "en-US",
|
||||
data: createOverlappingRendererSafeResumeData(),
|
||||
});
|
||||
|
||||
expect(values.mock.calls[0]?.[0]).toHaveProperty(
|
||||
"data.customSections.0.items.0.content",
|
||||
"<p>Renderer-irrelevant overlap must survive.</p>",
|
||||
);
|
||||
expect(values.mock.calls[0]?.[0]).toHaveProperty("data.customSections.0.items.0.roles", []);
|
||||
expect(values.mock.calls[0]?.[0]).toHaveProperty("data.customSections.0.items.0.website", {
|
||||
url: "",
|
||||
label: "",
|
||||
inlineLink: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions.snapshot", () => {
|
||||
it("persists normalized data in version snapshots", async () => {
|
||||
const values = vi.fn((_input: unknown) => Promise.resolve());
|
||||
dbMock.insert.mockReturnValueOnce({ values });
|
||||
dbMock.select.mockReturnValueOnce({
|
||||
from: () => ({ where: () => ({ orderBy: () => ({ limit: () => [] }) }) }),
|
||||
});
|
||||
dbMock.delete.mockReturnValueOnce({ where: () => Promise.resolve() });
|
||||
|
||||
await resumeService.versions.snapshot({
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
data: createOverlappingRendererSafeResumeData(),
|
||||
label: "Manual",
|
||||
});
|
||||
|
||||
expect(values).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
customSections: [
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
content: "<p>Renderer-irrelevant overlap must survive.</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("versions.restore", () => {
|
||||
it.each([
|
||||
{ name: "changed render data", changeRenderData: true, expectedRenderDataVersion: 8 },
|
||||
{ name: "notes-only data", changeRenderData: false, expectedRenderDataVersion: undefined },
|
||||
])(
|
||||
"preserves canonical stylesheet state and snapshots the returned data for $name",
|
||||
async ({ changeRenderData, expectedRenderDataVersion }) => {
|
||||
const currentData = createSemanticResumeData();
|
||||
const restoredData: ResumeData = structuredClone(defaultResumeData);
|
||||
if (changeRenderData) {
|
||||
restoredData.basics.name = "Restored Name";
|
||||
restoredData.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nsection { color: #123456; }\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nsection { color: #123456; }\n" },
|
||||
};
|
||||
} else {
|
||||
restoredData.metadata.notes = "Restored private note";
|
||||
restoredData.metadata.stylesheet = structuredClone(currentData.metadata.stylesheet);
|
||||
}
|
||||
|
||||
const { set, snapshotValues } = createRestoreHarness(currentData, restoredData);
|
||||
const prepareData = vi.fn(async ({ data }: { data: ResumeData }) => data);
|
||||
|
||||
const result = await resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData,
|
||||
});
|
||||
|
||||
expect(set).toHaveBeenCalledTimes(1);
|
||||
expect(prepareData).toHaveBeenCalledWith({ data: restoredData, stylesheetRevision: 3 });
|
||||
expect(result.data.metadata.stylesheet).toEqual(restoredData.metadata.stylesheet);
|
||||
const updateValues = set.mock.calls[0]?.[0];
|
||||
expect(updateValues).toHaveProperty("stylesheetRevision", 4);
|
||||
if (expectedRenderDataVersion === undefined) {
|
||||
expect(updateValues).not.toHaveProperty("renderDataVersion");
|
||||
} else {
|
||||
expect(updateValues).toHaveProperty("renderDataVersion", expectedRenderDataVersion);
|
||||
}
|
||||
expect(snapshotValues).toHaveBeenNthCalledWith(1, {
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
data: currentData,
|
||||
label: "Before restore",
|
||||
});
|
||||
expect(snapshotValues).toHaveBeenNthCalledWith(2, {
|
||||
resumeId: "r1",
|
||||
userId: "u1",
|
||||
data: result.data,
|
||||
label: "Restored version",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ currentMode: "legacy" as const, historicalMode: "semantic" as const },
|
||||
{ currentMode: "semantic" as const, historicalMode: "legacy" as const },
|
||||
])(
|
||||
"does not increment render-data version for a stylesheet-only $currentMode to $historicalMode restore",
|
||||
async ({ currentMode, historicalMode }) => {
|
||||
const currentData = createStylesheetResumeData(currentMode);
|
||||
const restoredData = structuredClone(currentData);
|
||||
if (restoredData.metadata.stylesheet) restoredData.metadata.stylesheet.mode = historicalMode;
|
||||
const { set } = createRestoreHarness(currentData, restoredData);
|
||||
|
||||
await resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData: ({ data }) => Promise.resolve(data),
|
||||
});
|
||||
|
||||
expect(set.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
stylesheet: expect.objectContaining({ mode: historicalMode }),
|
||||
}),
|
||||
}),
|
||||
stylesheetRevision: 4,
|
||||
}),
|
||||
);
|
||||
expect(set.mock.calls[0]?.[0]).not.toHaveProperty("renderDataVersion");
|
||||
},
|
||||
);
|
||||
|
||||
it("increments render-data version for a real restored content change", async () => {
|
||||
const currentData = createStylesheetResumeData("semantic");
|
||||
const restoredData = structuredClone(currentData);
|
||||
restoredData.basics.name = "Historical Name";
|
||||
const { set } = createRestoreHarness(currentData, restoredData);
|
||||
|
||||
await resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData: ({ data }) => Promise.resolve(data),
|
||||
});
|
||||
|
||||
expect(set.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ stylesheetRevision: 4, renderDataVersion: 8 }));
|
||||
});
|
||||
|
||||
it("increments render-data version when active restored legacy rules change", async () => {
|
||||
const currentData = createStylesheetResumeData("legacy");
|
||||
const restoredData = structuredClone(currentData);
|
||||
restoredData.metadata.styleRules = [
|
||||
{
|
||||
id: "restored-rule",
|
||||
label: "Restored",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "#123456" } },
|
||||
},
|
||||
];
|
||||
const { set } = createRestoreHarness(currentData, restoredData);
|
||||
|
||||
await resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData: ({ data }) => Promise.resolve(data),
|
||||
});
|
||||
|
||||
expect(set.mock.calls[0]?.[0]).toEqual(expect.objectContaining({ stylesheetRevision: 4, renderDataVersion: 8 }));
|
||||
});
|
||||
|
||||
it("rejects a currently locked resume before version lookup, preparation, or snapshots", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const currentRow = {
|
||||
...createResumeRow(createStylesheetResumeData("semantic")),
|
||||
isLocked: true,
|
||||
stylesheetRevision: 3,
|
||||
};
|
||||
const currentLookup = {
|
||||
from: () => ({
|
||||
where: () => {
|
||||
callOrder.push("getById");
|
||||
return Promise.resolve([currentRow]);
|
||||
},
|
||||
}),
|
||||
};
|
||||
const versionLookup = {
|
||||
from: () => ({
|
||||
innerJoin: () => ({
|
||||
where: () => {
|
||||
callOrder.push("versionLookup");
|
||||
return Promise.resolve([{ data: createStylesheetResumeData("semantic") }]);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
};
|
||||
dbMock.select.mockImplementation((selection: Record<string, unknown>) =>
|
||||
Object.hasOwn(selection, "isLocked") ? currentLookup : versionLookup,
|
||||
);
|
||||
const prepareData = vi.fn(() => {
|
||||
callOrder.push("prepareData");
|
||||
return Promise.reject(
|
||||
Object.assign(new Error("runner unavailable"), { code: "SEMANTIC_STYLESHEET_UNAVAILABLE" }),
|
||||
);
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "RESUME_LOCKED" });
|
||||
|
||||
expect(callOrder).toEqual(["getById"]);
|
||||
expect(prepareData).not.toHaveBeenCalled();
|
||||
expect(dbMock.insert).not.toHaveBeenCalled();
|
||||
expect(dbMock.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a renderer-unsafe historical snapshot before preparation, snapshots, or update", async () => {
|
||||
const currentData = createSemanticResumeData();
|
||||
const { set, snapshotValues } = createRestoreHarness(currentData, createRendererUnsafeResumeData());
|
||||
const prepareData = vi.fn(async ({ data }: { data: ResumeData }) => data);
|
||||
|
||||
await expect(
|
||||
resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR", status: 500 });
|
||||
|
||||
expect(prepareData).not.toHaveBeenCalled();
|
||||
expect(snapshotValues).not.toHaveBeenCalled();
|
||||
expect(set).not.toHaveBeenCalled();
|
||||
expect(dbMock.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes a valid historical snapshot before preparation and persistence", async () => {
|
||||
const currentData = createSemanticResumeData();
|
||||
const restoredData = createOverlappingRendererSafeResumeData();
|
||||
const { set } = createRestoreHarness(currentData, restoredData);
|
||||
const prepareData = vi.fn(async ({ data }: { data: ResumeData }) => data);
|
||||
|
||||
await resumeService.versions.restore({
|
||||
resumeId: "r1",
|
||||
versionId: "v1",
|
||||
userId: "u1",
|
||||
prepareData,
|
||||
});
|
||||
|
||||
expect(prepareData.mock.calls[0]?.[0].data.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Renderer-irrelevant overlap must survive.</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
expect(set.mock.calls[0]?.[0].data.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Renderer-irrelevant overlap must survive.</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("update", () => {
|
||||
it("throws RESUME_LOCKED when the pre-read reports the resume is locked", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: true }]));
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: true, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain }),
|
||||
);
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", name: "New" })).rejects.toMatchObject({
|
||||
code: "RESUME_LOCKED",
|
||||
});
|
||||
expect(select.forUpdate).toHaveBeenCalledWith("update");
|
||||
});
|
||||
|
||||
it("returns the updated row on success", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
it("updates and returns the ordinary row inside one locked transaction", async () => {
|
||||
const row = {
|
||||
id: "r1",
|
||||
name: "New",
|
||||
@@ -138,17 +607,29 @@ describe("update", () => {
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
hasPassword: false,
|
||||
};
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([row]).chain);
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: row.updatedAt },
|
||||
]);
|
||||
const update = createUpdateChain([row]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
);
|
||||
|
||||
const result = await resumeService.update({ id: "r1", userId: "u1", name: "New" });
|
||||
|
||||
expect(result).toEqual(row);
|
||||
expect(dbMock.transaction).toHaveBeenCalledTimes(1);
|
||||
expect(select.forUpdate).toHaveBeenCalledWith("update");
|
||||
expect(publishResumeUpdatedMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when the UPDATE ... RETURNING matches no row", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
dbMock.update.mockReturnValueOnce(createUpdateChain([]).chain);
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => createUpdateChain([]).chain }),
|
||||
);
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", name: "New" })).rejects.toMatchObject({
|
||||
code: "NOT_FOUND",
|
||||
@@ -156,8 +637,10 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
it("maps a resume_slug_user_id_unique violation to RESUME_SLUG_ALREADY_EXISTS", async () => {
|
||||
dbMock.select.mockReturnValueOnce(createSelectChain([{ isLocked: false }]));
|
||||
dbMock.update.mockReturnValueOnce({
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
const update = {
|
||||
set: () => ({
|
||||
where: () => ({
|
||||
returning: () => {
|
||||
@@ -167,12 +650,332 @@ describe("update", () => {
|
||||
},
|
||||
}),
|
||||
}),
|
||||
});
|
||||
};
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update }),
|
||||
);
|
||||
|
||||
await expect(resumeService.update({ id: "r1", userId: "u1", slug: "taken" })).rejects.toMatchObject({
|
||||
code: "RESUME_SLUG_ALREADY_EXISTS",
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the server stylesheet and increments render-data version once for visual changes", async () => {
|
||||
const serverData = createSemanticResumeData();
|
||||
const clientData: ResumeData = structuredClone(defaultResumeData);
|
||||
clientData.basics.name = "Changed";
|
||||
const row = createResumeRow(clientData);
|
||||
const select = createLockedSelectChain([
|
||||
{ data: serverData, isLocked: false, renderDataVersion: 3, updatedAt: row.updatedAt },
|
||||
]);
|
||||
const update = createUpdateChain([row]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
);
|
||||
|
||||
await resumeService.update({ id: "r1", userId: "u1", data: clientData, skipAutoSnapshot: true });
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ stylesheet: serverData.metadata.stylesheet }),
|
||||
}),
|
||||
renderDataVersion: 4,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not increment render-data version for notes-only changes", async () => {
|
||||
const serverData: ResumeData = structuredClone(defaultResumeData);
|
||||
const clientData: ResumeData = structuredClone(defaultResumeData);
|
||||
clientData.metadata.notes = "private note";
|
||||
const row = createResumeRow(clientData);
|
||||
const select = createLockedSelectChain([
|
||||
{ data: serverData, isLocked: false, renderDataVersion: 3, updatedAt: row.updatedAt },
|
||||
]);
|
||||
const update = createUpdateChain([row]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
);
|
||||
|
||||
await resumeService.update({ id: "r1", userId: "u1", data: clientData, skipAutoSnapshot: true });
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith(expect.not.objectContaining({ renderDataVersion: expect.anything() }));
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data before updating the JSONB column", async () => {
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 3, updatedAt: new Date() },
|
||||
]);
|
||||
const update = createUpdateChain([createResumeRow(defaultResumeData)]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
);
|
||||
|
||||
await expect(
|
||||
resumeService.update({
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
data: createRendererUnsafeResumeData(),
|
||||
skipAutoSnapshot: true,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
expect(publishResumeUpdatedMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("persists normalized renderer-safe overlapping data", async () => {
|
||||
const clientData = createOverlappingRendererSafeResumeData();
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 3, updatedAt: new Date() },
|
||||
]);
|
||||
const update = createUpdateChain([createResumeRow(clientData)]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
);
|
||||
|
||||
await resumeService.update({ id: "r1", userId: "u1", data: clientData, skipAutoSnapshot: true });
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
customSections: [
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
content: "<p>Renderer-irrelevant overlap must survive.</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("patch", () => {
|
||||
const styleRule = {
|
||||
id: "rule",
|
||||
label: "Rule",
|
||||
enabled: true,
|
||||
target: { scope: "global" as const },
|
||||
slots: { heading: { color: "#000000" } },
|
||||
};
|
||||
|
||||
const createPatchTx = (existing: {
|
||||
data: ResumeData;
|
||||
isLocked: boolean;
|
||||
renderDataVersion: number;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
const lockedSelect = createLockedSelectChain([existing]);
|
||||
const row = createResumeRow(existing.data, existing.updatedAt);
|
||||
const update = createUpdateChain([row]);
|
||||
const versionSelect = {
|
||||
from: () => ({ where: () => ({ orderBy: () => ({ limit: () => [] }) }) }),
|
||||
};
|
||||
const tx = {
|
||||
select: vi.fn().mockReturnValueOnce(lockedSelect.chain).mockReturnValueOnce(versionSelect),
|
||||
update: vi.fn(() => update.chain),
|
||||
insert: vi.fn(() => ({ values: vi.fn(() => Promise.resolve()) })),
|
||||
delete: vi.fn(() => ({ where: vi.fn(() => Promise.resolve()) })),
|
||||
};
|
||||
|
||||
return { tx, update };
|
||||
};
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "stylesheet descendant target",
|
||||
op: "replace" as const,
|
||||
path: "/metadata/stylesheet/source/text",
|
||||
value: "@version 1;\nresume { color: red; }\n",
|
||||
},
|
||||
{
|
||||
name: "stylesheet descendant copy source",
|
||||
op: "copy" as const,
|
||||
from: "/metadata/stylesheet/applied~1text",
|
||||
path: "/metadata/notes",
|
||||
},
|
||||
{
|
||||
name: "exact stylesheet move source",
|
||||
op: "move" as const,
|
||||
from: "/metadata/stylesheet",
|
||||
path: "/metadata/notes",
|
||||
},
|
||||
{
|
||||
name: "root target",
|
||||
op: "replace" as const,
|
||||
path: "",
|
||||
value: defaultResumeData,
|
||||
},
|
||||
{
|
||||
name: "metadata ancestor target",
|
||||
op: "replace" as const,
|
||||
path: "/metadata",
|
||||
value: defaultResumeData.metadata,
|
||||
},
|
||||
{
|
||||
name: "root copy source",
|
||||
op: "copy" as const,
|
||||
from: "",
|
||||
path: "/metadata/notes",
|
||||
},
|
||||
{
|
||||
name: "metadata ancestor move source",
|
||||
op: "move" as const,
|
||||
from: "/metadata",
|
||||
path: "/metadata/notes",
|
||||
},
|
||||
])("rejects $name before applying it", async ({ name: _name, ...operation }) => {
|
||||
const data = createSemanticResumeData();
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 2,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [operation],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "INVALID_PATCH_OPERATIONS",
|
||||
message: expect.stringContaining("server-owned stylesheet"),
|
||||
});
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "path",
|
||||
operation: { op: "add" as const, path: "/metadata/~2stylesheet", value: "invalid escape" },
|
||||
},
|
||||
{
|
||||
name: "from",
|
||||
operation: {
|
||||
op: "copy" as const,
|
||||
from: "/metadata/notes~",
|
||||
path: "/metadata/notes",
|
||||
},
|
||||
},
|
||||
])("rejects malformed JSON Pointer escapes in $name before applying", async ({ operation }) => {
|
||||
const data = createSemanticResumeData();
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 2,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
const error = await resumeService
|
||||
.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [operation],
|
||||
})
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "INVALID_PATCH_OPERATIONS",
|
||||
message: expect.stringContaining("valid JSON Pointer"),
|
||||
});
|
||||
expect((error as { data: unknown }).data).toEqual({ index: 0, operation });
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects legacy style-rule changes while semantic mode is active", async () => {
|
||||
const data = createSemanticResumeData();
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 2,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [{ op: "add", path: "/metadata/styleRules/-", value: styleRule }],
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "INVALID_PATCH_OPERATIONS",
|
||||
message: expect.stringContaining("Legacy style rules"),
|
||||
});
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["", "/metadata"])("rejects semantic style-rule changes through ancestor path %j", async (path) => {
|
||||
const data = createSemanticResumeData();
|
||||
const changedMetadata = { ...data.metadata, styleRules: [styleRule] };
|
||||
const value = path === "" ? { ...data, metadata: changedMetadata } : changedMetadata;
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 2,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [{ op: "replace", path, value }],
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "INVALID_PATCH_OPERATIONS" });
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("allows legacy style-rule changes and increments render-data version once", async () => {
|
||||
const data: ResumeData = structuredClone(defaultResumeData);
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 5,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [{ op: "add", path: "/metadata/styleRules/-", value: styleRule }],
|
||||
});
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith(expect.objectContaining({ renderDataVersion: 6 }));
|
||||
});
|
||||
|
||||
it("allows a harmless sibling operation below metadata while preserving the server stylesheet", async () => {
|
||||
const data = createSemanticResumeData();
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 5,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
await resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [{ op: "replace", path: "/metadata/notes", value: "private note" }],
|
||||
});
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
notes: "private note",
|
||||
stylesheet: data.metadata.stylesheet,
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("setLocked", () => {
|
||||
|
||||
@@ -17,6 +17,8 @@ import { getStorageService } from "../storage/service";
|
||||
import { grantResumeAccess, hasResumeAccess } from "./access";
|
||||
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./access-policy";
|
||||
import { publishResumeUpdated } from "./events";
|
||||
import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation";
|
||||
import { hasRenderDataChanged, preserveServerStylesheet } from "./stylesheet-preservation";
|
||||
import { clientKeyFromHeaders, shouldCountView } from "./view-dedup";
|
||||
|
||||
type DbOrTx = typeof db | Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
@@ -29,6 +31,59 @@ function resumeVersionConflict(updatedAt: Date) {
|
||||
});
|
||||
}
|
||||
|
||||
function invalidPatchOperation(message: string, index?: number, operation?: JsonPatchOperation) {
|
||||
if (index !== undefined && operation !== undefined) {
|
||||
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message, data: { index, operation } });
|
||||
}
|
||||
|
||||
return new ORPCError("INVALID_PATCH_OPERATIONS", { status: 400, message });
|
||||
}
|
||||
|
||||
type JsonPointerClass = "root" | "metadata" | "stylesheet" | "other";
|
||||
|
||||
function classifyJsonPointer(pointer: string): JsonPointerClass | undefined {
|
||||
if (pointer === "") return "root";
|
||||
if (!pointer.startsWith("/")) return undefined;
|
||||
|
||||
const segments = pointer
|
||||
.slice(1)
|
||||
.split("/")
|
||||
.map((segment) => {
|
||||
if (/~(?:[^01]|$)/.test(segment)) return undefined;
|
||||
return segment.replace(/~[01]/g, (encoded) => (encoded === "~1" ? "/" : "~"));
|
||||
});
|
||||
if (segments.some((segment) => segment === undefined)) return undefined;
|
||||
if (segments[0] !== "metadata") return "other";
|
||||
if (segments.length === 1) return "metadata";
|
||||
return segments[1] === "stylesheet" ? "stylesheet" : "other";
|
||||
}
|
||||
|
||||
function assertSafePatchPointers(operation: JsonPatchOperation, index: number) {
|
||||
const pathClass = classifyJsonPointer(operation.path);
|
||||
if (!pathClass) {
|
||||
throw invalidPatchOperation("Operation `path` property is not a valid JSON Pointer string.", index, operation);
|
||||
}
|
||||
|
||||
let fromClass: JsonPointerClass | undefined;
|
||||
if ("from" in operation) {
|
||||
fromClass = classifyJsonPointer(operation.from);
|
||||
if (!fromClass) {
|
||||
throw invalidPatchOperation("Operation `from` property is not a valid JSON Pointer string.", index, operation);
|
||||
}
|
||||
}
|
||||
|
||||
const protectedPath =
|
||||
pathClass === "stylesheet" || (operation.op !== "test" && (pathClass === "root" || pathClass === "metadata"));
|
||||
const protectedSource = fromClass === "stylesheet" || fromClass === "root" || fromClass === "metadata";
|
||||
if (protectedPath || protectedSource) {
|
||||
throw invalidPatchOperation(
|
||||
"The server-owned stylesheet cannot be changed through generic resume patches.",
|
||||
index,
|
||||
operation,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Version history: keep a bounded, rolling window of snapshots per resume.
|
||||
const MAX_VERSIONS_PER_RESUME = 30;
|
||||
// Manual-save milestones are debounced server-side: an autosave only checkpoints if the newest
|
||||
@@ -39,10 +94,12 @@ async function writeResumeVersion(
|
||||
client: DbOrTx,
|
||||
input: { resumeId: string; userId: string; data: ResumeData; label: string },
|
||||
) {
|
||||
const data = parseWritableResumeData(input.data);
|
||||
|
||||
await client.insert(schema.resumeVersion).values({
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: input.data,
|
||||
data,
|
||||
label: input.label,
|
||||
});
|
||||
|
||||
@@ -88,7 +145,12 @@ async function applyResumePatchTx(
|
||||
},
|
||||
) {
|
||||
const [existing] = await client
|
||||
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked, updatedAt: schema.resume.updatedAt })
|
||||
.select({
|
||||
data: schema.resume.data,
|
||||
isLocked: schema.resume.isLocked,
|
||||
renderDataVersion: schema.resume.renderDataVersion,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
})
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.for("update");
|
||||
@@ -99,6 +161,8 @@ async function applyResumePatchTx(
|
||||
throw resumeVersionConflict(existing.updatedAt);
|
||||
}
|
||||
|
||||
input.operations.forEach(assertSafePatchPointers);
|
||||
|
||||
let patchedData: ResumeData;
|
||||
|
||||
try {
|
||||
@@ -118,9 +182,21 @@ async function applyResumePatchTx(
|
||||
});
|
||||
}
|
||||
|
||||
patchedData = parseWritableResumeData(preserveServerStylesheet(existing.data, patchedData));
|
||||
if (
|
||||
existing.data.metadata.stylesheet?.mode === "semantic" &&
|
||||
JSON.stringify(existing.data.metadata.styleRules) !== JSON.stringify(patchedData.metadata.styleRules)
|
||||
) {
|
||||
throw invalidPatchOperation("Legacy style rules cannot be changed while Semantic CSS mode is active.");
|
||||
}
|
||||
|
||||
const renderDataChanged = hasRenderDataChanged(existing.data, patchedData);
|
||||
const [resume] = await client
|
||||
.update(schema.resume)
|
||||
.set({ data: patchedData })
|
||||
.set({
|
||||
data: patchedData,
|
||||
...(renderDataChanged ? { renderDataVersion: existing.renderDataVersion + 1 } : {}),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resume.id, input.id),
|
||||
@@ -319,6 +395,7 @@ function toSharedResumeResponse(
|
||||
isLocked: boolean;
|
||||
},
|
||||
hasPassword: boolean,
|
||||
stylesheetMode: "legacy" | "semantic",
|
||||
) {
|
||||
return {
|
||||
id: resume.id,
|
||||
@@ -329,6 +406,7 @@ function toSharedResumeResponse(
|
||||
isPublic: resume.isPublic,
|
||||
isLocked: resume.isLocked,
|
||||
hasPassword,
|
||||
stylesheetMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -377,7 +455,16 @@ export const resumeService = {
|
||||
|
||||
// Non-destructive restore: writes the snapshot's data back through the normal update path, so
|
||||
// prior versions remain and the restore is itself just another (snapshot-able, undoable) change.
|
||||
restore: async (input: { resumeId: string; versionId: string; userId: string }) => {
|
||||
restore: async (input: {
|
||||
resumeId: string;
|
||||
versionId: string;
|
||||
userId: string;
|
||||
prepareData(input: { data: ResumeData; stylesheetRevision: number }): Promise<ResumeData>;
|
||||
}) => {
|
||||
// Check lock state before loading or validating historical data so locked resumes fail without expensive work.
|
||||
const current = await resumeService.getById({ id: input.resumeId, userId: input.userId });
|
||||
if (current.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
|
||||
const [version] = await db
|
||||
.select({ data: schema.resumeVersion.data })
|
||||
.from(schema.resumeVersion)
|
||||
@@ -391,9 +478,13 @@ export const resumeService = {
|
||||
);
|
||||
|
||||
if (!version) throw new ORPCError("NOT_FOUND");
|
||||
const versionData = parseStoredResumeData(version.data);
|
||||
|
||||
// Capture the pre-restore state first so the restore itself is undoable.
|
||||
const current = await resumeService.getById({ id: input.resumeId, userId: input.userId });
|
||||
const restoredData = await input.prepareData({
|
||||
data: versionData,
|
||||
stylesheetRevision: current.stylesheetRevision,
|
||||
});
|
||||
await resumeService.versions.snapshot({
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
@@ -404,14 +495,15 @@ export const resumeService = {
|
||||
const updated = await resumeService.update({
|
||||
id: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: version.data,
|
||||
data: restoredData,
|
||||
restoreStylesheet: true,
|
||||
skipAutoSnapshot: true,
|
||||
});
|
||||
|
||||
await resumeService.versions.snapshot({
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: version.data,
|
||||
data: updated.data,
|
||||
label: "Restored version",
|
||||
});
|
||||
|
||||
@@ -458,6 +550,7 @@ export const resumeService = {
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
stylesheetRevision: schema.resume.stylesheetRevision,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
})
|
||||
@@ -506,10 +599,16 @@ export const resumeService = {
|
||||
}
|
||||
}
|
||||
|
||||
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
|
||||
const stylesheetMode = resume.data.metadata.stylesheet?.mode ?? "legacy";
|
||||
return toSharedResumeResponse(
|
||||
redactResumeForViewer(resume, isOwner(resume, viewer)),
|
||||
resume.hasPassword,
|
||||
stylesheetMode,
|
||||
);
|
||||
},
|
||||
|
||||
create: async (input: {
|
||||
id?: string;
|
||||
userId: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
@@ -517,8 +616,8 @@ export const resumeService = {
|
||||
locale: Locale;
|
||||
data?: ResumeData;
|
||||
}) => {
|
||||
const id = generateId();
|
||||
const data = input.data ?? defaultResumeData;
|
||||
const id = input.id ?? generateId();
|
||||
const data = parseWritableResumeData(structuredClone(input.data ?? defaultResumeData));
|
||||
data.metadata.page.locale = input.locale;
|
||||
|
||||
try {
|
||||
@@ -560,78 +659,107 @@ export const resumeService = {
|
||||
tags?: string[];
|
||||
data?: ResumeData;
|
||||
isPublic?: boolean;
|
||||
restoreStylesheet?: boolean;
|
||||
skipAutoSnapshot?: boolean;
|
||||
}) => {
|
||||
const [resume] = await db
|
||||
.select({ isLocked: schema.resume.isLocked })
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
const resume = await db
|
||||
.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({
|
||||
data: schema.resume.data,
|
||||
isLocked: schema.resume.isLocked,
|
||||
stylesheetRevision: schema.resume.stylesheetRevision,
|
||||
renderDataVersion: schema.resume.renderDataVersion,
|
||||
})
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.for("update");
|
||||
|
||||
if (resume?.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
const inputData = input.data ? parseWritableResumeData(input.data) : undefined;
|
||||
|
||||
const updateData: Partial<typeof schema.resume.$inferSelect> = {
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||
...(input.data !== undefined ? { data: input.data } : {}),
|
||||
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
|
||||
};
|
||||
const data = inputData
|
||||
? input.restoreStylesheet
|
||||
? inputData
|
||||
: preserveServerStylesheet(existing.data, inputData)
|
||||
: undefined;
|
||||
const normalizedData = data ? parseWritableResumeData(data) : undefined;
|
||||
const dataForRenderComparison =
|
||||
normalizedData && input.restoreStylesheet
|
||||
? preserveServerStylesheet(existing.data, normalizedData)
|
||||
: normalizedData;
|
||||
const renderDataChanged = dataForRenderComparison
|
||||
? hasRenderDataChanged(existing.data, dataForRenderComparison)
|
||||
: false;
|
||||
const updateData: Partial<typeof schema.resume.$inferSelect> = {
|
||||
...(input.name !== undefined ? { name: input.name } : {}),
|
||||
...(input.slug !== undefined ? { slug: input.slug } : {}),
|
||||
...(input.tags !== undefined ? { tags: input.tags } : {}),
|
||||
...(normalizedData ? { data: normalizedData } : {}),
|
||||
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
|
||||
...(input.restoreStylesheet ? { stylesheetRevision: existing.stylesheetRevision + 1 } : {}),
|
||||
...(renderDataChanged ? { renderDataVersion: existing.renderDataVersion + 1 } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
const [resume] = await db
|
||||
.update(schema.resume)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resume.id, input.id),
|
||||
eq(schema.resume.isLocked, false),
|
||||
eq(schema.resume.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: schema.resume.id,
|
||||
name: schema.resume.name,
|
||||
slug: schema.resume.slug,
|
||||
tags: schema.resume.tags,
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
});
|
||||
const [updated] = await tx
|
||||
.update(schema.resume)
|
||||
.set(updateData)
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resume.id, input.id),
|
||||
eq(schema.resume.isLocked, false),
|
||||
eq(schema.resume.userId, input.userId),
|
||||
),
|
||||
)
|
||||
.returning({
|
||||
id: schema.resume.id,
|
||||
name: schema.resume.name,
|
||||
slug: schema.resume.slug,
|
||||
tags: schema.resume.tags,
|
||||
data: schema.resume.data,
|
||||
isPublic: schema.resume.isPublic,
|
||||
isLocked: schema.resume.isLocked,
|
||||
stylesheetRevision: schema.resume.stylesheetRevision,
|
||||
renderDataVersion: schema.resume.renderDataVersion,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
|
||||
});
|
||||
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return updated;
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
|
||||
// Debounced manual-save milestone: only snapshots data edits, and only when the previous
|
||||
// snapshot is old enough (see SNAPSHOT_THROTTLE_MS). Covers template switches and typing.
|
||||
if (input.data !== undefined && !input.skipAutoSnapshot) {
|
||||
await maybeSnapshotOnSave({
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
data: resume.data,
|
||||
label: "Manual save",
|
||||
});
|
||||
}
|
||||
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "update",
|
||||
console.error("Failed to update resume:", error);
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
|
||||
});
|
||||
|
||||
return resume;
|
||||
} catch (error) {
|
||||
if (error instanceof ORPCError) throw error;
|
||||
|
||||
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
|
||||
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
|
||||
}
|
||||
|
||||
console.error("Failed to update resume:", error);
|
||||
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
|
||||
// Debounced manual-save milestone: only snapshots data edits, and only when the previous
|
||||
// snapshot is old enough (see SNAPSHOT_THROTTLE_MS). Covers template switches and typing.
|
||||
if (input.data !== undefined && !input.skipAutoSnapshot) {
|
||||
await maybeSnapshotOnSave({
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
data: resume.data,
|
||||
label: "Manual save",
|
||||
});
|
||||
}
|
||||
|
||||
await notifyResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: resume.id,
|
||||
userId: input.userId,
|
||||
updatedAt: resume.updatedAt.toISOString(),
|
||||
mutation: "update",
|
||||
});
|
||||
|
||||
return resume;
|
||||
},
|
||||
|
||||
patch: async (input: { id: string; userId: string; operations: JsonPatchOperation[]; expectedUpdatedAt?: Date }) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import z from "zod";
|
||||
import { protectedProcedure, publicProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../../middleware/rate-limit";
|
||||
import { getStyleProjection } from "./public-style-projection";
|
||||
import { resumeService } from "./service";
|
||||
|
||||
export const sharingRouter = {
|
||||
@@ -26,6 +27,29 @@ export const sharingRouter = {
|
||||
}),
|
||||
),
|
||||
|
||||
getStyleProjection: publicProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{username}/{slug}/style-projection",
|
||||
tags: ["Resume Sharing"],
|
||||
operationId: "getResumeStyleProjection",
|
||||
summary: "Get public resume style projection",
|
||||
description:
|
||||
"Returns the source-free resolved semantic PDF style projection after applying public, private-owner, and password access rules.",
|
||||
successDescription: "The validated public style projection.",
|
||||
})
|
||||
.input(resumeDto.getStyleProjection.input)
|
||||
.output(resumeDto.getStyleProjection.output)
|
||||
.handler(({ input, context }) => {
|
||||
context.resHeaders?.set("Cache-Control", "private, no-store");
|
||||
return getStyleProjection({
|
||||
...input,
|
||||
requestHeaders: context.reqHeaders,
|
||||
trustedClient: context.trustedClient ?? "unknown",
|
||||
...(context.user?.id ? { currentUserId: context.user.id } : {}),
|
||||
});
|
||||
}),
|
||||
|
||||
setPassword: protectedProcedure
|
||||
.route({
|
||||
method: "PUT",
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
|
||||
const source = { languageVersion: 1, text: "@version 1;\n" };
|
||||
const common = {
|
||||
id: "resume-1",
|
||||
expectedRevision: 3,
|
||||
expectedRenderDataVersion: 8,
|
||||
editGeneration: 13,
|
||||
};
|
||||
|
||||
describe("resume stylesheet mutation DTO", () => {
|
||||
it.each([
|
||||
{ ...common, transition: "edit_source", source },
|
||||
{ ...common, transition: "activate", source },
|
||||
{ ...common, transition: "deactivate" },
|
||||
{
|
||||
...common,
|
||||
transition: "restore_history",
|
||||
restore: { mode: "semantic", source, applied: source },
|
||||
},
|
||||
])("accepts the exact $transition payload", (input) => {
|
||||
expect(resumeDto.stylesheet.mutate.input.safeParse(input).success).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "forged applied edit", input: { ...common, transition: "edit_source", source, applied: source } },
|
||||
{ name: "source on deactivate", input: { ...common, transition: "deactivate", source } },
|
||||
{ name: "missing activation source", input: { ...common, transition: "activate" } },
|
||||
{
|
||||
name: "extra restore field",
|
||||
input: {
|
||||
...common,
|
||||
transition: "restore_history",
|
||||
restore: { mode: "semantic", source, applied: source, revision: 99 },
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "missing edit generation",
|
||||
input: {
|
||||
id: common.id,
|
||||
expectedRevision: common.expectedRevision,
|
||||
expectedRenderDataVersion: common.expectedRenderDataVersion,
|
||||
transition: "deactivate",
|
||||
},
|
||||
},
|
||||
])("rejects $name", ({ input }) => {
|
||||
expect(resumeDto.stylesheet.mutate.input.safeParse(input).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { hashSemanticCssResumeId, recordSemanticCssEvent } from "./stylesheet-observability";
|
||||
|
||||
const sensitive = "Ada Lovelace <ada@example.test> /* private comment */";
|
||||
|
||||
describe("semantic CSS observability", () => {
|
||||
it("emits the bounded structured fields with a stable domain-separated resume hash", () => {
|
||||
const logger = vi.fn();
|
||||
|
||||
recordSemanticCssEvent(
|
||||
{
|
||||
name: "semantic_css.preflight",
|
||||
resumeId: "resume-123",
|
||||
durationMs: 17,
|
||||
languageVersion: 1,
|
||||
sourceBytes: 42,
|
||||
template: "onyx",
|
||||
diagnosticCodes: ["MISSING_VERSION_DIRECTIVE"],
|
||||
pageCount: 2,
|
||||
revision: 9,
|
||||
success: true,
|
||||
},
|
||||
logger,
|
||||
);
|
||||
|
||||
expect(logger).toHaveBeenCalledWith({
|
||||
name: "semantic_css.preflight",
|
||||
resumeIdHash: hashSemanticCssResumeId("resume-123"),
|
||||
durationMs: 17,
|
||||
languageVersion: 1,
|
||||
sourceBytes: 42,
|
||||
template: "onyx",
|
||||
diagnosticCodes: ["MISSING_VERSION_DIRECTIVE"],
|
||||
pageCount: 2,
|
||||
revision: 9,
|
||||
success: true,
|
||||
});
|
||||
expect(hashSemanticCssResumeId("resume-123")).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(hashSemanticCssResumeId("resume-123")).not.toBe(hashSemanticCssResumeId("123"));
|
||||
});
|
||||
|
||||
it("never logs source, comments, resume content, personal fields, raw IDs, or unsafe diagnostic text", () => {
|
||||
const logger = vi.fn();
|
||||
const input = {
|
||||
name: "semantic_css.compile",
|
||||
resumeId: sensitive,
|
||||
durationMs: 4,
|
||||
languageVersion: 1,
|
||||
sourceBytes: 101,
|
||||
template: "onyx",
|
||||
diagnosticCodes: ["PARSE_ERROR", sensitive],
|
||||
pageCount: null,
|
||||
revision: 3,
|
||||
success: false,
|
||||
source: sensitive,
|
||||
comments: sensitive,
|
||||
resume: { basics: { name: sensitive } },
|
||||
email: sensitive,
|
||||
} as const;
|
||||
|
||||
recordSemanticCssEvent(input, logger);
|
||||
|
||||
const serialized = JSON.stringify(logger.mock.calls);
|
||||
const event = logger.mock.calls[0]?.[0];
|
||||
expect(serialized).not.toContain(sensitive);
|
||||
expect(event).not.toHaveProperty("source");
|
||||
expect(event).not.toHaveProperty("comments");
|
||||
expect(event).not.toHaveProperty("resume");
|
||||
expect(event).not.toHaveProperty("email");
|
||||
expect(logger).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ diagnosticCodes: ["PARSE_ERROR", "UNKNOWN_DIAGNOSTIC"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
type SemanticCssEventName =
|
||||
| "semantic_css.compile"
|
||||
| "semantic_css.preflight"
|
||||
| "semantic_css.convert_legacy"
|
||||
| "semantic_css.parity_check"
|
||||
| "semantic_css.activate";
|
||||
|
||||
export type SemanticCssEventInput = {
|
||||
name: SemanticCssEventName;
|
||||
resumeId: string;
|
||||
durationMs: number;
|
||||
languageVersion: number;
|
||||
sourceBytes: number;
|
||||
template: Template;
|
||||
diagnosticCodes: readonly string[];
|
||||
pageCount: number | null;
|
||||
revision: number;
|
||||
success: boolean;
|
||||
};
|
||||
|
||||
export const hashSemanticCssResumeId = (resumeId: string): string =>
|
||||
createHash("sha256").update(`reactive-resume:semantic-css:resume:${resumeId}`).digest("hex");
|
||||
|
||||
const safeDiagnosticCode = (code: string): string =>
|
||||
/^[A-Z][A-Z0-9_]{0,63}$/.test(code) ? code : "UNKNOWN_DIAGNOSTIC";
|
||||
|
||||
export function recordSemanticCssEvent(
|
||||
event: SemanticCssEventInput,
|
||||
logger: (event: Readonly<Record<string, unknown>>) => void = console.info,
|
||||
): void {
|
||||
logger({
|
||||
name: event.name,
|
||||
resumeIdHash: hashSemanticCssResumeId(event.resumeId),
|
||||
durationMs: event.durationMs,
|
||||
languageVersion: event.languageVersion,
|
||||
sourceBytes: event.sourceBytes,
|
||||
template: event.template,
|
||||
diagnosticCodes: event.diagnosticCodes.map(safeDiagnosticCode),
|
||||
pageCount: event.pageCount,
|
||||
revision: event.revision,
|
||||
success: event.success,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
import type { StylesheetPreflightRunner } from "@reactive-resume/pdf/server";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import {
|
||||
checkLegacyStylesheetParity,
|
||||
prepareImportedResumeData,
|
||||
validateHistoricalStylesheet,
|
||||
} from "./stylesheet-preflight";
|
||||
|
||||
const semanticPdfMocks = vi.hoisted(() => ({
|
||||
compareLegacySemanticPresentation: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/pdf/semantic", () => semanticPdfMocks);
|
||||
|
||||
const validSource = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection { color: #123456; }\n",
|
||||
};
|
||||
const validApplied = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection-heading { color: #654321; }\n",
|
||||
};
|
||||
const invalidSource = {
|
||||
languageVersion: 99,
|
||||
text: "@version 99;\n/* preserved future source */\n",
|
||||
};
|
||||
|
||||
const resumeData = (source = validSource, applied = validApplied): ResumeData => {
|
||||
const data: ResumeData = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = { mode: "semantic", source, applied };
|
||||
return data;
|
||||
};
|
||||
|
||||
const createRunner = () => {
|
||||
const run = vi.fn<StylesheetPreflightRunner["run"]>(async ({ stylesheet }) => ({
|
||||
ok: true,
|
||||
pageCount: stylesheet.text === EMPTY_SEMANTIC_CSS_SOURCE ? 1 : 2,
|
||||
byteCount: 100,
|
||||
diagnostics: [],
|
||||
}));
|
||||
return { run } satisfies StylesheetPreflightRunner;
|
||||
};
|
||||
|
||||
describe("stylesheet persistence preparation", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
semanticPdfMocks.compareLegacySemanticPresentation.mockReset();
|
||||
});
|
||||
|
||||
it("uses a valid imported source as applied only after PDF preflight", async () => {
|
||||
const runner = createRunner();
|
||||
|
||||
const result = await prepareImportedResumeData({
|
||||
data: resumeData(validSource, validApplied),
|
||||
resumeId: "import-1",
|
||||
revision: 0,
|
||||
runner,
|
||||
});
|
||||
|
||||
expect(result.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: validSource,
|
||||
applied: validSource,
|
||||
});
|
||||
expect(runner.run).toHaveBeenCalledTimes(1);
|
||||
expect(runner.run).toHaveBeenCalledWith({
|
||||
data: expect.any(Object),
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: validSource,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a legacy stylesheet unchanged without PDF preflight", async () => {
|
||||
const data = resumeData();
|
||||
data.metadata.stylesheet = { mode: "legacy", source: validSource, applied: validApplied };
|
||||
const runner = createRunner();
|
||||
|
||||
await expect(prepareImportedResumeData({ data, resumeId: "import-legacy", revision: 0, runner })).resolves.toBe(
|
||||
data,
|
||||
);
|
||||
expect(runner.run).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves invalid imported source and independently validates the imported applied source", async () => {
|
||||
const runner = createRunner();
|
||||
|
||||
const result = await prepareImportedResumeData({
|
||||
data: resumeData(invalidSource, validApplied),
|
||||
resumeId: "import-2",
|
||||
revision: 0,
|
||||
runner,
|
||||
});
|
||||
|
||||
expect(result.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: invalidSource,
|
||||
applied: validApplied,
|
||||
});
|
||||
expect(runner.run).toHaveBeenCalledTimes(1);
|
||||
expect(runner.run.mock.calls[0]?.[0].stylesheet).toEqual(validApplied);
|
||||
});
|
||||
|
||||
it("uses the supported empty applied source when neither imported source is valid", async () => {
|
||||
const runner = createRunner();
|
||||
|
||||
const result = await prepareImportedResumeData({
|
||||
data: resumeData(invalidSource, invalidSource),
|
||||
resumeId: "import-3",
|
||||
revision: 0,
|
||||
runner,
|
||||
});
|
||||
|
||||
expect(result.metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: invalidSource,
|
||||
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
});
|
||||
expect(runner.run).toHaveBeenCalledTimes(1);
|
||||
expect(runner.run.mock.calls[0]?.[0].stylesheet.text).toBe(EMPTY_SEMANTIC_CSS_SOURCE);
|
||||
});
|
||||
|
||||
it("reports a controlled unavailable error when imported stylesheet preflight has no runner", async () => {
|
||||
await expect(
|
||||
prepareImportedResumeData({
|
||||
data: resumeData(),
|
||||
resumeId: "import-4",
|
||||
revision: 0,
|
||||
}),
|
||||
).rejects.toMatchObject({ code: "SEMANTIC_STYLESHEET_UNAVAILABLE", status: 503 });
|
||||
});
|
||||
|
||||
it("validates and preflights historical applied independently while retaining invalid historical source", async () => {
|
||||
const runner = createRunner();
|
||||
const restored = {
|
||||
mode: "semantic" as const,
|
||||
source: invalidSource,
|
||||
applied: validApplied,
|
||||
};
|
||||
|
||||
await expect(
|
||||
validateHistoricalStylesheet({
|
||||
data: resumeData(),
|
||||
resumeId: "restore-1",
|
||||
revision: 7,
|
||||
stylesheet: restored,
|
||||
runner,
|
||||
}),
|
||||
).resolves.toEqual(restored);
|
||||
expect(runner.run).toHaveBeenCalledTimes(1);
|
||||
expect(runner.run.mock.calls[0]?.[0].stylesheet).toEqual(validApplied);
|
||||
});
|
||||
|
||||
it("emits compile and preflight metrics without embedding imported source text", async () => {
|
||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
|
||||
await prepareImportedResumeData({
|
||||
data: resumeData(),
|
||||
resumeId: "import-observed",
|
||||
revision: 0,
|
||||
runner: createRunner(),
|
||||
});
|
||||
|
||||
const serialized = JSON.stringify(log.mock.calls);
|
||||
expect(log.mock.calls.map(([event]) => (event as { name: string }).name)).toEqual([
|
||||
"semantic_css.compile",
|
||||
"semantic_css.preflight",
|
||||
]);
|
||||
expect(serialized).not.toContain(validSource.text);
|
||||
expect(serialized).not.toContain("import-observed");
|
||||
});
|
||||
|
||||
it("records a sanitized parity failure metric when the comparator throws", async () => {
|
||||
const privateText = "private parity failure john.doe@example.com";
|
||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
semanticPdfMocks.compareLegacySemanticPresentation.mockRejectedValueOnce(new Error(privateText));
|
||||
|
||||
await expect(
|
||||
checkLegacyStylesheetParity({
|
||||
data: resumeData(),
|
||||
stylesheet: validSource,
|
||||
resumeId: "parity-observed",
|
||||
revision: 5,
|
||||
}),
|
||||
).rejects.toThrow(privateText);
|
||||
|
||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "semantic_css.parity_check",
|
||||
durationMs: expect.any(Number),
|
||||
diagnosticCodes: [],
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
const serialized = JSON.stringify(log.mock.calls);
|
||||
expect(serialized).not.toContain(privateText);
|
||||
expect(serialized).not.toContain(validSource.text);
|
||||
expect(serialized).not.toContain("parity-observed");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,237 @@
|
||||
import type { StylesheetPreflightRunner } from "@reactive-resume/pdf/server";
|
||||
import type { CompileStylesheetResult } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { StylesheetSnapshot } from "./stylesheet-service";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
||||
|
||||
type PrepareImportedResumeDataInput = {
|
||||
data: ResumeData;
|
||||
resumeId: string;
|
||||
revision: number;
|
||||
runner?: StylesheetPreflightRunner | undefined;
|
||||
};
|
||||
|
||||
type ValidateHistoricalStylesheetInput = {
|
||||
data: ResumeData;
|
||||
resumeId: string;
|
||||
revision: number;
|
||||
stylesheet: SemanticStylesheet;
|
||||
runner?: StylesheetPreflightRunner | undefined;
|
||||
};
|
||||
|
||||
const byteCount = (source: StylesheetSource): number => new TextEncoder().encode(source.text).byteLength;
|
||||
|
||||
const observeCompile = (
|
||||
input: Pick<PrepareImportedResumeDataInput, "data" | "resumeId" | "revision">,
|
||||
source: StylesheetSource,
|
||||
): CompileStylesheetResult => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const result = compileStylesheet(source);
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.compile",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: source.languageVersion,
|
||||
sourceBytes: byteCount(source),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: result.diagnostics.map(({ code }) => code),
|
||||
pageCount: null,
|
||||
revision: input.revision,
|
||||
success: result.program !== null,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.compile",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: source.languageVersion,
|
||||
sourceBytes: byteCount(source),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: input.revision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const unavailableError = () =>
|
||||
new ORPCError("SEMANTIC_STYLESHEET_UNAVAILABLE", {
|
||||
status: 503,
|
||||
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||
});
|
||||
|
||||
const validationError = (message: string) =>
|
||||
new ORPCError("STYLESHEET_VALIDATION_FAILED", {
|
||||
status: 400,
|
||||
message,
|
||||
});
|
||||
|
||||
const runPreflight = async (
|
||||
input: Pick<PrepareImportedResumeDataInput, "data" | "resumeId" | "revision" | "runner">,
|
||||
stylesheet: StylesheetSource,
|
||||
) => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
if (!input.runner) throw unavailableError();
|
||||
const result = await input.runner.run({
|
||||
data: input.data,
|
||||
template: input.data.metadata.template,
|
||||
stylesheet,
|
||||
});
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.preflight",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: stylesheet.languageVersion,
|
||||
sourceBytes: byteCount(stylesheet),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: result.diagnostics.map(({ code }) => code),
|
||||
pageCount: result.ok ? result.pageCount : null,
|
||||
revision: input.revision,
|
||||
success: result.ok,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.preflight",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: stylesheet.languageVersion,
|
||||
sourceBytes: byteCount(stylesheet),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: input.revision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const validApplied = async (input: PrepareImportedResumeDataInput, stylesheet: StylesheetSource): Promise<boolean> => {
|
||||
const compiled = observeCompile(input, stylesheet);
|
||||
return compiled.program !== null && (await runPreflight(input, stylesheet)).ok;
|
||||
};
|
||||
|
||||
export async function prepareImportedResumeData(input: PrepareImportedResumeDataInput): Promise<ResumeData> {
|
||||
const stylesheet = input.data.metadata.stylesheet;
|
||||
if (stylesheet?.mode !== "semantic") return input.data;
|
||||
|
||||
let applied = stylesheet.source;
|
||||
if (!(await validApplied(input, stylesheet.source))) {
|
||||
applied = stylesheet.applied;
|
||||
if (!(await validApplied(input, stylesheet.applied))) {
|
||||
applied = { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE };
|
||||
if (!(await validApplied(input, applied))) {
|
||||
throw validationError("The empty stylesheet failed PDF preflight.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...input.data,
|
||||
metadata: {
|
||||
...input.data.metadata,
|
||||
stylesheet: { ...stylesheet, applied },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function validateHistoricalStylesheet(
|
||||
input: ValidateHistoricalStylesheetInput,
|
||||
): Promise<SemanticStylesheet> {
|
||||
const compiled = observeCompile(input, input.stylesheet.applied);
|
||||
if (!compiled.program) throw validationError("The historical applied stylesheet is invalid.");
|
||||
if (!(await runPreflight(input, input.stylesheet.applied)).ok) {
|
||||
throw validationError("The historical applied stylesheet failed PDF preflight.");
|
||||
}
|
||||
return input.stylesheet;
|
||||
}
|
||||
|
||||
export async function convertLegacyStylesheet(snapshot: StylesheetSnapshot): Promise<StylesheetSource> {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const { convertLegacyStyleRules } = await import("@reactive-resume/pdf/semantic");
|
||||
const source = convertLegacyStyleRules(snapshot.data).source;
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.convert_legacy",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: source.languageVersion,
|
||||
sourceBytes: byteCount(source),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: true,
|
||||
});
|
||||
return source;
|
||||
} catch (error) {
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.convert_legacy",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: 1,
|
||||
sourceBytes: 0,
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function checkLegacyStylesheetParity(input: {
|
||||
data: ResumeData;
|
||||
stylesheet: StylesheetSource;
|
||||
resumeId: string;
|
||||
revision: number;
|
||||
}): Promise<{ mismatches: readonly string[] }> {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const { compareLegacySemanticPresentation } = await import("@reactive-resume/pdf/semantic");
|
||||
const result = await compareLegacySemanticPresentation({
|
||||
data: input.data,
|
||||
convertedSource: input.stylesheet,
|
||||
templates: templateSchema.options,
|
||||
});
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.parity_check",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: input.stylesheet.languageVersion,
|
||||
sourceBytes: byteCount(input.stylesheet),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: input.revision,
|
||||
success: result.mismatches.length === 0,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
recordSemanticCssEvent({
|
||||
name: "semantic_css.parity_check",
|
||||
resumeId: input.resumeId,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: input.stylesheet.languageVersion,
|
||||
sourceBytes: byteCount(input.stylesheet),
|
||||
template: input.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: input.revision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createResumeData, hasRenderDataChanged, preserveServerStylesheet } from "./stylesheet-preservation";
|
||||
|
||||
const cloneData = (): ResumeData => structuredClone(defaultResumeData);
|
||||
|
||||
const semanticStylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" },
|
||||
} as const;
|
||||
|
||||
describe("preserveServerStylesheet", () => {
|
||||
it("preserves the server-owned stylesheet when an old client omits it", () => {
|
||||
const serverData = cloneData();
|
||||
serverData.metadata.stylesheet = semanticStylesheet;
|
||||
const clientData = cloneData();
|
||||
|
||||
const merged = preserveServerStylesheet(serverData, clientData);
|
||||
|
||||
expect(merged.metadata.stylesheet).toEqual(semanticStylesheet);
|
||||
});
|
||||
|
||||
it("preserves the server-owned stylesheet when a client tries to replace it", () => {
|
||||
const serverData = cloneData();
|
||||
serverData.metadata.stylesheet = semanticStylesheet;
|
||||
const clientData = cloneData();
|
||||
clientData.metadata.stylesheet = {
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
};
|
||||
|
||||
expect(preserveServerStylesheet(serverData, clientData).metadata.stylesheet).toEqual(semanticStylesheet);
|
||||
});
|
||||
|
||||
it("does not let a generic update introduce a stylesheet", () => {
|
||||
const serverData = cloneData();
|
||||
const clientData = cloneData();
|
||||
clientData.metadata.stylesheet = semanticStylesheet;
|
||||
|
||||
expect(preserveServerStylesheet(serverData, clientData).metadata.stylesheet).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasRenderDataChanged", () => {
|
||||
it("detects content and base-design changes", () => {
|
||||
const before = cloneData();
|
||||
const changedContent = cloneData();
|
||||
changedContent.basics.name = "Changed";
|
||||
const changedDesign = cloneData();
|
||||
changedDesign.metadata.design.colors.primary = "#000000";
|
||||
|
||||
expect(hasRenderDataChanged(before, changedContent)).toBe(true);
|
||||
expect(hasRenderDataChanged(before, changedDesign)).toBe(true);
|
||||
});
|
||||
|
||||
it("ignores notes and stylesheet source or applied changes", () => {
|
||||
const before = cloneData();
|
||||
before.metadata.stylesheet = semanticStylesheet;
|
||||
const changedNotes = structuredClone(before);
|
||||
changedNotes.metadata.notes = "private note";
|
||||
const changedStylesheet = structuredClone(before);
|
||||
changedStylesheet.metadata.stylesheet = {
|
||||
...semanticStylesheet,
|
||||
source: { languageVersion: 1, text: "@version 1;\nresume { color: blue; }\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nresume { color: blue; }\n" },
|
||||
};
|
||||
|
||||
expect(hasRenderDataChanged(before, changedNotes)).toBe(false);
|
||||
expect(hasRenderDataChanged(before, changedStylesheet)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects active legacy style-rule changes but ignores dormant rules in semantic mode", () => {
|
||||
const legacyBefore = cloneData();
|
||||
const legacyAfter = cloneData();
|
||||
legacyAfter.metadata.styleRules = [
|
||||
{
|
||||
id: "rule",
|
||||
label: "Rule",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "#000000" } },
|
||||
},
|
||||
];
|
||||
const semanticBefore = cloneData();
|
||||
semanticBefore.metadata.stylesheet = semanticStylesheet;
|
||||
const semanticAfter = structuredClone(semanticBefore);
|
||||
semanticAfter.metadata.styleRules = legacyAfter.metadata.styleRules;
|
||||
|
||||
expect(hasRenderDataChanged(legacyBefore, legacyAfter)).toBe(true);
|
||||
expect(hasRenderDataChanged(semanticBefore, semanticAfter)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResumeData", () => {
|
||||
it("seeds empty semantic source only for the default-enabled cohort", () => {
|
||||
expect(createResumeData({ semanticCssDefault: true }).metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
});
|
||||
expect(createResumeData({ semanticCssDefault: false }).metadata.stylesheet).toBeUndefined();
|
||||
});
|
||||
|
||||
it("clones normal and sample defaults instead of mutating shared data", () => {
|
||||
const normal = createResumeData({ semanticCssDefault: false, locale: "de-DE" });
|
||||
const sample = createResumeData({
|
||||
semanticCssDefault: true,
|
||||
withSampleData: true,
|
||||
name: "Sample Person",
|
||||
locale: "de-DE",
|
||||
});
|
||||
|
||||
normal.basics.name = "Mutated";
|
||||
sample.metadata.page.locale = "en-US";
|
||||
|
||||
expect(defaultResumeData.basics.name).toBe("");
|
||||
expect(defaultResumeData.metadata.page.locale).not.toBe("de-DE");
|
||||
expect(sample.basics.name).toBe("Sample Person");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { projectRenderData } from "@reactive-resume/resume/stylesheet/render-data";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
|
||||
type CreateResumeDataOptions = {
|
||||
semanticCssDefault: boolean;
|
||||
withSampleData?: boolean;
|
||||
name?: string;
|
||||
locale?: Locale;
|
||||
};
|
||||
|
||||
export function preserveServerStylesheet(serverData: ResumeData, clientData: ResumeData): ResumeData {
|
||||
const { stylesheet: _clientStylesheet, ...metadata } = clientData.metadata;
|
||||
const stylesheet = serverData.metadata.stylesheet;
|
||||
|
||||
return {
|
||||
...clientData,
|
||||
metadata: stylesheet ? { ...metadata, stylesheet: structuredClone(stylesheet) } : metadata,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasRenderDataChanged(before: ResumeData, after: ResumeData): boolean {
|
||||
return JSON.stringify(projectRenderData(before)) !== JSON.stringify(projectRenderData(after));
|
||||
}
|
||||
|
||||
export function createResumeData(options: CreateResumeDataOptions): ResumeData {
|
||||
const data = structuredClone(options.withSampleData ? createSampleResumeData(options.name) : defaultResumeData);
|
||||
|
||||
if (options.locale) data.metadata.page.locale = options.locale;
|
||||
if (options.semanticCssDefault) {
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
};
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { InferSchemaOutput, Schema } from "@orpc/server";
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import type z from "zod";
|
||||
import type { resumeDto } from "../../dto/resume";
|
||||
import { describe, expect, expectTypeOf, it, vi } from "vitest";
|
||||
|
||||
vi.mock("../../context", async () => {
|
||||
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||
return { protectedProcedure: os.$context() };
|
||||
});
|
||||
|
||||
vi.mock("./stylesheet-service", () => ({ createDatabaseStylesheetService: vi.fn() }));
|
||||
|
||||
const { stylesheetRouter } = await import("./stylesheet");
|
||||
|
||||
type MutateErrorMap = (typeof stylesheetRouter.mutate)["~orpc"]["errorMap"];
|
||||
type ErrorData<TKey extends keyof MutateErrorMap> = MutateErrorMap[TKey] extends {
|
||||
data: infer TSchema extends Schema<unknown, unknown>;
|
||||
}
|
||||
? InferSchemaOutput<TSchema>
|
||||
: never;
|
||||
type StylesheetState = z.infer<typeof resumeDto.stylesheet.getState.output>;
|
||||
|
||||
const source = { languageVersion: 1, text: "@version 1;\n" };
|
||||
const state = {
|
||||
stylesheet: { mode: "semantic" as const, source, applied: source },
|
||||
revision: 4,
|
||||
renderDataVersion: 8,
|
||||
};
|
||||
const diagnostic = {
|
||||
code: "PARSE_ERROR",
|
||||
severity: "error" as const,
|
||||
message: "Invalid stylesheet.",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
const dataSchema = (code: keyof MutateErrorMap) => {
|
||||
const error = stylesheetRouter.mutate["~orpc"].errorMap[code];
|
||||
const schema = error && "data" in error ? error.data : undefined;
|
||||
expect(schema, `${String(code)} must declare an oRPC data schema`).toBeDefined();
|
||||
return schema as z.ZodType;
|
||||
};
|
||||
|
||||
describe("resume stylesheet route error contract", () => {
|
||||
it("exposes strict validation diagnostic data at runtime and in the inferred client error", () => {
|
||||
const schema = dataSchema("STYLESHEET_VALIDATION_FAILED");
|
||||
|
||||
expect(schema.safeParse({ diagnostics: [diagnostic] }).success).toBe(true);
|
||||
expect(schema.safeParse({ diagnostics: [diagnostic], source }).success).toBe(false);
|
||||
expect(schema.safeParse({ diagnostics: [{ ...diagnostic, source }] }).success).toBe(false);
|
||||
expectTypeOf<ErrorData<"STYLESHEET_VALIDATION_FAILED">>().toEqualTypeOf<{
|
||||
diagnostics: SemanticCssDiagnostic[];
|
||||
}>();
|
||||
});
|
||||
|
||||
it("exposes strict parity mismatch data at runtime and in the inferred client error", () => {
|
||||
const schema = dataSchema("STYLESHEET_PARITY_FAILED");
|
||||
|
||||
expect(schema.safeParse({ mismatches: ["onyx: page 1"] }).success).toBe(true);
|
||||
expect(schema.safeParse({ mismatches: ["onyx: page 1"], source }).success).toBe(false);
|
||||
expectTypeOf<ErrorData<"STYLESHEET_PARITY_FAILED">>().toEqualTypeOf<{ mismatches: string[] }>();
|
||||
});
|
||||
|
||||
it("exposes strict canonical conflict state for type-safe client rebasing", () => {
|
||||
const schema = dataSchema("STYLESHEET_REVISION_CONFLICT");
|
||||
|
||||
expect(schema.safeParse({ state }).success).toBe(true);
|
||||
expect(schema.safeParse({ state, expectedRevision: 3 }).success).toBe(false);
|
||||
expect(schema.safeParse({ state: { ...state, source } }).success).toBe(false);
|
||||
expectTypeOf<ErrorData<"STYLESHEET_REVISION_CONFLICT">>().toEqualTypeOf<{
|
||||
state: StylesheetState;
|
||||
}>();
|
||||
});
|
||||
|
||||
it("does not invent data for the message-only unavailable error", () => {
|
||||
expect("data" in stylesheetRouter.mutate["~orpc"].errorMap.SEMANTIC_STYLESHEET_UNAVAILABLE).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
const databaseTest = process.env.DATABASE_URL ? describe : describe.skip;
|
||||
|
||||
databaseTest("stylesheet PostgreSQL compare-and-swap", () => {
|
||||
it("rejects a preflighted candidate when render data changes on a second connection", async () => {
|
||||
const [{ getPool }, { createDatabaseStylesheetService }] = await Promise.all([
|
||||
import("@reactive-resume/db/client"),
|
||||
import("./stylesheet-service"),
|
||||
]);
|
||||
const firstConnection = await getPool().connect();
|
||||
const secondConnection = await getPool().connect();
|
||||
const userId = `stylesheet-user-${randomUUID()}`;
|
||||
const resumeId = `stylesheet-resume-${randomUUID()}`;
|
||||
const initialSource = { languageVersion: 1, text: "@version 1;\n" };
|
||||
const candidateSource = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection { color: #123456; }\n",
|
||||
};
|
||||
const data: ResumeData = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: initialSource,
|
||||
applied: initialSource,
|
||||
};
|
||||
|
||||
let releasePreflight: () => void = () => {};
|
||||
let signalPaused: () => void = () => {};
|
||||
const paused = new Promise<void>((resolve) => {
|
||||
signalPaused = resolve;
|
||||
});
|
||||
const released = new Promise<void>((resolve) => {
|
||||
releasePreflight = resolve;
|
||||
});
|
||||
|
||||
try {
|
||||
await secondConnection.query(
|
||||
`insert into "user" (id, name, email, username, display_username)
|
||||
values ($1, $2, $3, $4, $4)`,
|
||||
[userId, "Stylesheet Test", `${userId}@example.test`, userId],
|
||||
);
|
||||
await secondConnection.query(
|
||||
`insert into resume
|
||||
(id, name, slug, tags, data, user_id, stylesheet_revision, render_data_version)
|
||||
values ($1, 'Resume', 'resume', '{}', $2::jsonb, $3, 0, 0)`,
|
||||
[resumeId, JSON.stringify(data), userId],
|
||||
);
|
||||
|
||||
const service = createDatabaseStylesheetService({
|
||||
database: drizzle({ client: firstConnection }),
|
||||
runner: {
|
||||
run: async () => ({
|
||||
ok: true,
|
||||
pageCount: 1,
|
||||
byteCount: 128,
|
||||
diagnostics: [],
|
||||
}),
|
||||
},
|
||||
afterPreflight: async () => {
|
||||
signalPaused();
|
||||
await released;
|
||||
},
|
||||
publish: async () => undefined,
|
||||
});
|
||||
|
||||
const mutation = service.mutate({
|
||||
id: resumeId,
|
||||
userId,
|
||||
expectedRevision: 0,
|
||||
expectedRenderDataVersion: 0,
|
||||
editGeneration: 1,
|
||||
transition: "edit_source",
|
||||
source: candidateSource,
|
||||
});
|
||||
|
||||
await paused;
|
||||
await secondConnection.query(
|
||||
`update resume
|
||||
set data = jsonb_set(data, '{basics,name}', to_jsonb($1::text), true),
|
||||
render_data_version = render_data_version + 1
|
||||
where id = $2 and user_id = $3`,
|
||||
["Concurrent content", resumeId, userId],
|
||||
);
|
||||
releasePreflight();
|
||||
|
||||
const conflict = await mutation.catch((error: unknown) => error);
|
||||
expect(conflict).toMatchObject({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
status: 409,
|
||||
data: {
|
||||
state: {
|
||||
revision: 0,
|
||||
renderDataVersion: 1,
|
||||
stylesheet: {
|
||||
source: initialSource,
|
||||
applied: initialSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = await secondConnection.query<{
|
||||
data: ResumeData;
|
||||
stylesheet_revision: number;
|
||||
render_data_version: number;
|
||||
}>(
|
||||
`select data, stylesheet_revision, render_data_version
|
||||
from resume where id = $1 and user_id = $2`,
|
||||
[resumeId, userId],
|
||||
);
|
||||
expect(persisted.rows[0]).toMatchObject({
|
||||
data: {
|
||||
basics: { name: "Concurrent content" },
|
||||
metadata: {
|
||||
stylesheet: {
|
||||
source: initialSource,
|
||||
applied: initialSource,
|
||||
},
|
||||
},
|
||||
},
|
||||
stylesheet_revision: 0,
|
||||
render_data_version: 1,
|
||||
});
|
||||
} finally {
|
||||
releasePreflight();
|
||||
await secondConnection.query(`delete from "user" where id = $1`, [userId]).catch(() => undefined);
|
||||
firstConnection.release();
|
||||
secondConnection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,535 @@
|
||||
import type { PdfPreflightResult } from "@reactive-resume/pdf/server";
|
||||
import type { CompileStylesheetResult } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { SemanticCssEventInput } from "./stylesheet-observability";
|
||||
import type { StylesheetSnapshot } from "./stylesheet-service";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createStylesheetService } from "./stylesheet-service";
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: {}, getPool: vi.fn() }));
|
||||
|
||||
const validSource = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection { color: #112233; }\n",
|
||||
} satisfies StylesheetSource;
|
||||
const invalidSource = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection {",
|
||||
} satisfies StylesheetSource;
|
||||
const previousSource = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection { color: #445566; }\n",
|
||||
} satisfies StylesheetSource;
|
||||
const previousApplied = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nsection { color: #778899; }\n",
|
||||
} satisfies StylesheetSource;
|
||||
const previousStylesheet = {
|
||||
mode: "semantic",
|
||||
source: previousSource,
|
||||
applied: previousApplied,
|
||||
} satisfies SemanticStylesheet;
|
||||
const diagnostic = {
|
||||
code: "PARSE_ERROR",
|
||||
severity: "error" as const,
|
||||
message: "Invalid stylesheet.",
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
},
|
||||
};
|
||||
const successfulCompile = {
|
||||
program: { languageVersion: 1, rules: [] },
|
||||
diagnostics: [],
|
||||
} satisfies CompileStylesheetResult;
|
||||
const failedCompile = { program: null, diagnostics: [diagnostic] } satisfies CompileStylesheetResult;
|
||||
const successfulPreflight = {
|
||||
ok: true,
|
||||
pageCount: 1,
|
||||
byteCount: 256,
|
||||
diagnostics: [],
|
||||
} satisfies PdfPreflightResult;
|
||||
|
||||
const commonMutationInput = {
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
expectedRevision: 3,
|
||||
expectedRenderDataVersion: 8,
|
||||
editGeneration: 11,
|
||||
} as const;
|
||||
|
||||
const snapshot = (stylesheet: SemanticStylesheet = previousStylesheet): StylesheetSnapshot => {
|
||||
const data: ResumeData = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = stylesheet;
|
||||
data.metadata.styleRules = [
|
||||
{
|
||||
id: "legacy-rule",
|
||||
label: "Legacy",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { heading: { color: "#123456" } },
|
||||
},
|
||||
];
|
||||
|
||||
return {
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
data,
|
||||
isLocked: false,
|
||||
stylesheetRevision: 3,
|
||||
renderDataVersion: 8,
|
||||
updatedAt: new Date("2026-07-28T12:00:00.000Z"),
|
||||
};
|
||||
};
|
||||
|
||||
type HarnessOptions = {
|
||||
initial?: StylesheetSnapshot;
|
||||
compile?: (source: StylesheetSource) => CompileStylesheetResult;
|
||||
preflight?: ((input: { data: ResumeData; stylesheet: StylesheetSource }) => Promise<PdfPreflightResult>) | undefined;
|
||||
parityMismatches?: readonly string[];
|
||||
parity?: () => Promise<{ mismatches: readonly string[] }>;
|
||||
locked?: StylesheetSnapshot;
|
||||
useDefaultObserver?: boolean;
|
||||
};
|
||||
|
||||
const createHarness = (options: HarnessOptions = {}) => {
|
||||
const callOrder: string[] = [];
|
||||
const events: SemanticCssEventInput[] = [];
|
||||
let persisted = structuredClone(options.initial ?? snapshot());
|
||||
const compile = vi.fn((source: StylesheetSource) => {
|
||||
callOrder.push("compile");
|
||||
return options.compile?.(source) ?? successfulCompile;
|
||||
});
|
||||
const preflight = options.preflight
|
||||
? vi.fn(options.preflight)
|
||||
: vi.fn(() => {
|
||||
callOrder.push("preflight");
|
||||
return Promise.resolve(successfulPreflight);
|
||||
});
|
||||
const parity = vi.fn(() => {
|
||||
callOrder.push("parity");
|
||||
return options.parity?.() ?? Promise.resolve({ mismatches: options.parityMismatches ?? [] });
|
||||
});
|
||||
const publish = vi.fn(() => {
|
||||
callOrder.push("publish");
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
const service = createStylesheetService({
|
||||
readSnapshot: () => {
|
||||
callOrder.push("readSnapshot");
|
||||
return Promise.resolve(structuredClone(persisted));
|
||||
},
|
||||
convertLegacy: () => {
|
||||
callOrder.push("convertLegacy");
|
||||
return { languageVersion: 1, text: "@version 1;\n" };
|
||||
},
|
||||
compile,
|
||||
preflight:
|
||||
options.preflight === undefined
|
||||
? preflight
|
||||
: (input) => {
|
||||
callOrder.push("preflight");
|
||||
return preflight(input);
|
||||
},
|
||||
parity,
|
||||
transaction: async (run) => {
|
||||
callOrder.push("begin");
|
||||
try {
|
||||
const result = await run({
|
||||
lock: () => {
|
||||
callOrder.push("lock");
|
||||
return Promise.resolve(structuredClone(options.locked ?? persisted));
|
||||
},
|
||||
update: ({ data }) => {
|
||||
callOrder.push("update");
|
||||
persisted = {
|
||||
...persisted,
|
||||
data,
|
||||
stylesheetRevision: persisted.stylesheetRevision + 1,
|
||||
};
|
||||
return Promise.resolve(structuredClone(persisted));
|
||||
},
|
||||
});
|
||||
callOrder.push("commit");
|
||||
return result;
|
||||
} catch (error) {
|
||||
callOrder.push("rollback");
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
compare: (locked, input) => {
|
||||
callOrder.push("compare");
|
||||
return (
|
||||
locked.stylesheetRevision === input.expectedRevision &&
|
||||
locked.renderDataVersion === input.expectedRenderDataVersion
|
||||
);
|
||||
},
|
||||
publish,
|
||||
...(options.useDefaultObserver ? {} : { observe: (event: SemanticCssEventInput) => events.push(event) }),
|
||||
});
|
||||
|
||||
return { callOrder, compile, events, parity, persisted: () => persisted, preflight, publish, service };
|
||||
};
|
||||
|
||||
describe("stylesheet service", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("stores invalid editable source while preserving the last-valid applied source and render version", async () => {
|
||||
const harness = createHarness({ compile: () => failedCompile });
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "edit_source",
|
||||
source: invalidSource,
|
||||
});
|
||||
|
||||
expect(result.stylesheet.source).toEqual(invalidSource);
|
||||
expect(result.stylesheet.applied).toEqual(previousApplied);
|
||||
expect(result.revision).toBe(4);
|
||||
expect(result.renderDataVersion).toBe(8);
|
||||
expect(result.editGeneration).toBe(11);
|
||||
expect(result.diagnostics).toEqual([diagnostic]);
|
||||
expect(harness.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("promotes a valid edit only in semantic mode after preflight outside the short transaction", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "edit_source",
|
||||
source: validSource,
|
||||
});
|
||||
|
||||
expect(result.stylesheet.source).toEqual(validSource);
|
||||
expect(result.stylesheet.applied).toEqual(validSource);
|
||||
expect(harness.callOrder).toEqual([
|
||||
"readSnapshot",
|
||||
"compile",
|
||||
"preflight",
|
||||
"begin",
|
||||
"lock",
|
||||
"compare",
|
||||
"update",
|
||||
"commit",
|
||||
"publish",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a valid legacy-mode edit dormant without preflight", async () => {
|
||||
const initial = snapshot({ ...previousStylesheet, mode: "legacy" });
|
||||
const harness = createHarness({ initial });
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "edit_source",
|
||||
source: validSource,
|
||||
});
|
||||
|
||||
expect(result.stylesheet).toEqual({
|
||||
mode: "legacy",
|
||||
source: validSource,
|
||||
applied: previousApplied,
|
||||
});
|
||||
expect(harness.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("preserves editable source but not applied source when semantic render preflight fails", async () => {
|
||||
const harness = createHarness({
|
||||
preflight: async () => ({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||
message: "PDF rendering failed.",
|
||||
diagnostics: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "edit_source",
|
||||
source: validSource,
|
||||
});
|
||||
|
||||
expect(result.stylesheet.source).toEqual(validSource);
|
||||
expect(result.stylesheet.applied).toEqual(previousApplied);
|
||||
expect(result.diagnostics).toEqual([
|
||||
expect.objectContaining({ code: "STYLESHEET_PREFLIGHT_RENDER_FAILED", severity: "error" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires parity and preflight before explicit activation", async () => {
|
||||
const initial = snapshot({ ...previousStylesheet, mode: "legacy" });
|
||||
const harness = createHarness({ initial });
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "activate",
|
||||
source: validSource,
|
||||
});
|
||||
|
||||
expect(result.stylesheet).toEqual({ mode: "semantic", source: validSource, applied: validSource });
|
||||
expect(harness.parity).toHaveBeenCalledOnce();
|
||||
expect(harness.preflight).toHaveBeenCalledOnce();
|
||||
expect(result.revision).toBe(4);
|
||||
expect(result.renderDataVersion).toBe(8);
|
||||
expect(harness.events).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "semantic_css.activate",
|
||||
durationMs: expect.any(Number),
|
||||
pageCount: 1,
|
||||
revision: 4,
|
||||
success: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("records a sanitized compile failure metric when the compiler throws", async () => {
|
||||
const privateText = "private compiler failure john.doe@example.com";
|
||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
const harness = createHarness({
|
||||
compile: () => {
|
||||
throw new Error(privateText);
|
||||
},
|
||||
useDefaultObserver: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.service.mutate({ ...commonMutationInput, transition: "edit_source", source: validSource }),
|
||||
).rejects.toThrow(privateText);
|
||||
|
||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "semantic_css.compile",
|
||||
durationMs: expect.any(Number),
|
||||
diagnosticCodes: [],
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
const serialized = JSON.stringify(log.mock.calls);
|
||||
expect(serialized).not.toContain(privateText);
|
||||
expect(serialized).not.toContain(validSource.text);
|
||||
expect(serialized).not.toContain("resume-1");
|
||||
});
|
||||
|
||||
it("records a sanitized preflight failure metric when the runner throws", async () => {
|
||||
const privateText = "private preflight failure john.doe@example.com";
|
||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
const harness = createHarness({
|
||||
preflight: () => Promise.reject(new Error(privateText)),
|
||||
useDefaultObserver: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.service.mutate({ ...commonMutationInput, transition: "edit_source", source: validSource }),
|
||||
).rejects.toThrow(privateText);
|
||||
|
||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "semantic_css.preflight",
|
||||
durationMs: expect.any(Number),
|
||||
diagnosticCodes: [],
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
const serialized = JSON.stringify(log.mock.calls);
|
||||
expect(serialized).not.toContain(privateText);
|
||||
expect(serialized).not.toContain(validSource.text);
|
||||
expect(serialized).not.toContain("resume-1");
|
||||
});
|
||||
|
||||
it("records a sanitized activation failure metric when parity throws", async () => {
|
||||
const privateText = "private parity failure john.doe@example.com";
|
||||
const log = vi.spyOn(console, "info").mockImplementation(() => undefined);
|
||||
const harness = createHarness({
|
||||
initial: snapshot({ ...previousStylesheet, mode: "legacy" }),
|
||||
parity: () => Promise.reject(new Error(privateText)),
|
||||
useDefaultObserver: true,
|
||||
});
|
||||
|
||||
await expect(
|
||||
harness.service.mutate({ ...commonMutationInput, transition: "activate", source: validSource }),
|
||||
).rejects.toThrow(privateText);
|
||||
|
||||
expect(log.mock.calls.map(([event]) => event)).toContainEqual(
|
||||
expect.objectContaining({
|
||||
name: "semantic_css.activate",
|
||||
durationMs: expect.any(Number),
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: 3,
|
||||
success: false,
|
||||
}),
|
||||
);
|
||||
const serialized = JSON.stringify(log.mock.calls);
|
||||
expect(serialized).not.toContain(privateText);
|
||||
expect(serialized).not.toContain(validSource.text);
|
||||
expect(serialized).not.toContain("resume-1");
|
||||
});
|
||||
|
||||
it("deactivates without compiling or deleting either source", async () => {
|
||||
const harness = createHarness();
|
||||
|
||||
const result = await harness.service.mutate({ ...commonMutationInput, transition: "deactivate" });
|
||||
|
||||
expect(result.stylesheet).toEqual({ ...previousStylesheet, mode: "legacy" });
|
||||
expect(harness.compile).not.toHaveBeenCalled();
|
||||
expect(harness.parity).not.toHaveBeenCalled();
|
||||
expect(harness.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not rewrite invalid stored resume data during deactivation", async () => {
|
||||
const initial = snapshot();
|
||||
initial.data.customSections = [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||
} as never,
|
||||
];
|
||||
const harness = createHarness({ initial });
|
||||
|
||||
const error = await harness.service
|
||||
.mutate({ ...commonMutationInput, transition: "deactivate" })
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", status: 500 });
|
||||
expect(error).toHaveProperty("cause.issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||
expect(harness.callOrder).not.toContain("update");
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes valid stored resume data during deactivation", async () => {
|
||||
const initial = snapshot();
|
||||
initial.data.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 never,
|
||||
];
|
||||
const harness = createHarness({ initial });
|
||||
|
||||
await harness.service.mutate({ ...commonMutationInput, transition: "deactivate" });
|
||||
|
||||
expect(harness.persisted().data.customSections[0]?.items[0]).toMatchObject({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("validates and preflights restored applied source independently from invalid editable source", async () => {
|
||||
const restoredApplied = { ...validSource, text: "@version 1;\nresume { color: #abcdef; }\n" };
|
||||
const compile = vi.fn((source: StylesheetSource) =>
|
||||
source === restoredApplied ? successfulCompile : failedCompile,
|
||||
);
|
||||
const harness = createHarness({ compile });
|
||||
|
||||
const result = await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "restore_history",
|
||||
restore: {
|
||||
mode: "semantic",
|
||||
source: invalidSource,
|
||||
applied: restoredApplied,
|
||||
},
|
||||
});
|
||||
|
||||
expect(compile).toHaveBeenCalledTimes(1);
|
||||
expect(compile).toHaveBeenCalledWith(restoredApplied);
|
||||
expect(harness.preflight).toHaveBeenCalledWith({
|
||||
data: expect.any(Object),
|
||||
stylesheet: restoredApplied,
|
||||
});
|
||||
expect(result.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: invalidSource,
|
||||
applied: restoredApplied,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects locked resumes before compiler or preflight work", async () => {
|
||||
const initial = { ...snapshot(), isLocked: true };
|
||||
const harness = createHarness({ initial });
|
||||
|
||||
await expect(
|
||||
harness.service.mutate({ ...commonMutationInput, transition: "edit_source", source: validSource }),
|
||||
).rejects.toMatchObject({ code: "RESUME_LOCKED" });
|
||||
expect(harness.compile).not.toHaveBeenCalled();
|
||||
expect(harness.preflight).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns the locked canonical snapshot on a two-version conflict without writing", async () => {
|
||||
const locked = {
|
||||
...snapshot(),
|
||||
stylesheetRevision: 4,
|
||||
renderDataVersion: 9,
|
||||
};
|
||||
delete locked.data.metadata.stylesheet;
|
||||
const harness = createHarness({ locked });
|
||||
|
||||
const error = await harness.service
|
||||
.mutate({ ...commonMutationInput, transition: "edit_source", source: validSource })
|
||||
.catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toMatchObject({
|
||||
code: "STYLESHEET_REVISION_CONFLICT",
|
||||
data: {
|
||||
state: expect.objectContaining({
|
||||
revision: 4,
|
||||
renderDataVersion: 9,
|
||||
stylesheet: expect.objectContaining({
|
||||
mode: "legacy",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
}),
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(harness.callOrder).not.toContain("update");
|
||||
expect(harness.publish).not.toHaveBeenCalled();
|
||||
expect(harness.callOrder.indexOf("rollback")).toBeLessThan(harness.callOrder.indexOf("convertLegacy"));
|
||||
});
|
||||
|
||||
it("reloads the exact invalid source, last-valid applied source, and untouched legacy rules", async () => {
|
||||
const harness = createHarness({ compile: () => failedCompile });
|
||||
await harness.service.mutate({
|
||||
...commonMutationInput,
|
||||
transition: "edit_source",
|
||||
source: invalidSource,
|
||||
});
|
||||
|
||||
const reloaded = await harness.service.getState({ id: "resume-1", userId: "user-1" });
|
||||
|
||||
expect(reloaded.stylesheet.source).toEqual(invalidSource);
|
||||
expect(reloaded.stylesheet.applied).toEqual(previousApplied);
|
||||
expect(harness.persisted().data.metadata.styleRules).toEqual(snapshot().data.metadata.styleRules);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import type { PdfPreflightResult, StylesheetPreflightRunner } from "@reactive-resume/pdf/server";
|
||||
import type { CompileStylesheetResult, SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SemanticStylesheet, StylesheetMode, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { SemanticCssEventInput } from "./stylesheet-observability";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { publishResumeUpdated } from "./events";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
import { recordSemanticCssEvent } from "./stylesheet-observability";
|
||||
import { checkLegacyStylesheetParity, convertLegacyStylesheet } from "./stylesheet-preflight";
|
||||
|
||||
export type StylesheetSnapshot = {
|
||||
id: string;
|
||||
userId: string;
|
||||
data: ResumeData;
|
||||
isLocked: boolean;
|
||||
stylesheetRevision: number;
|
||||
renderDataVersion: number;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
type StylesheetMutationCommon = {
|
||||
id: string;
|
||||
userId: string;
|
||||
expectedRevision: number;
|
||||
expectedRenderDataVersion: number;
|
||||
editGeneration: number;
|
||||
};
|
||||
|
||||
export type MutateResumeStylesheetInput = StylesheetMutationCommon &
|
||||
(
|
||||
| { transition: "edit_source"; source: StylesheetSource }
|
||||
| { transition: "activate"; source: StylesheetSource }
|
||||
| { transition: "deactivate" }
|
||||
| {
|
||||
transition: "restore_history";
|
||||
restore: {
|
||||
mode: StylesheetMode;
|
||||
source: StylesheetSource;
|
||||
applied: StylesheetSource;
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
export type StylesheetState = {
|
||||
stylesheet: SemanticStylesheet;
|
||||
revision: number;
|
||||
renderDataVersion: number;
|
||||
};
|
||||
|
||||
export type StylesheetMutationResult = StylesheetState & {
|
||||
editGeneration: number;
|
||||
diagnostics: SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
type StylesheetTransaction = {
|
||||
lock(input: { id: string; userId: string }): Promise<StylesheetSnapshot>;
|
||||
update(input: { snapshot: StylesheetSnapshot; data: ResumeData }): Promise<StylesheetSnapshot>;
|
||||
};
|
||||
|
||||
type StylesheetServiceDependencies = {
|
||||
readSnapshot(input: { id: string; userId: string }): Promise<StylesheetSnapshot>;
|
||||
convertLegacy(snapshot: StylesheetSnapshot): StylesheetSource | Promise<StylesheetSource>;
|
||||
compile(source: StylesheetSource): CompileStylesheetResult;
|
||||
preflight?(input: { data: ResumeData; stylesheet: StylesheetSource }): Promise<PdfPreflightResult>;
|
||||
parity(input: {
|
||||
data: ResumeData;
|
||||
stylesheet: StylesheetSource;
|
||||
resumeId: string;
|
||||
revision: number;
|
||||
}): Promise<{ mismatches: readonly string[] }>;
|
||||
transaction<T>(run: (transaction: StylesheetTransaction) => Promise<T>): Promise<T>;
|
||||
compare(snapshot: StylesheetSnapshot, input: StylesheetMutationCommon): boolean | Promise<boolean>;
|
||||
publish(snapshot: StylesheetSnapshot): Promise<void>;
|
||||
afterPreflight?(): Promise<void>;
|
||||
observe?(event: SemanticCssEventInput): void;
|
||||
};
|
||||
|
||||
class StylesheetRevisionConflict extends Error {
|
||||
constructor(readonly snapshot: StylesheetSnapshot) {
|
||||
super("The resume or stylesheet changed while the candidate was being validated.");
|
||||
}
|
||||
}
|
||||
|
||||
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE });
|
||||
|
||||
export async function stylesheetFromSnapshot(
|
||||
snapshot: StylesheetSnapshot,
|
||||
convertLegacy: (snapshot: StylesheetSnapshot) => StylesheetSource | Promise<StylesheetSource>,
|
||||
): Promise<SemanticStylesheet> {
|
||||
const stylesheet = snapshot.data.metadata.stylesheet;
|
||||
if (stylesheet) return stylesheet;
|
||||
|
||||
return {
|
||||
mode: "legacy",
|
||||
source: await convertLegacy(snapshot),
|
||||
applied: emptySource(),
|
||||
};
|
||||
}
|
||||
|
||||
const stateFromSnapshot = async (
|
||||
snapshot: StylesheetSnapshot,
|
||||
convertLegacy: (snapshot: StylesheetSnapshot) => StylesheetSource | Promise<StylesheetSource>,
|
||||
): Promise<StylesheetState> => ({
|
||||
stylesheet: await stylesheetFromSnapshot(snapshot, convertLegacy),
|
||||
revision: snapshot.stylesheetRevision,
|
||||
renderDataVersion: snapshot.renderDataVersion,
|
||||
});
|
||||
|
||||
const preflightDiagnostic = (result: Extract<PdfPreflightResult, { ok: false }>): SemanticCssDiagnostic => ({
|
||||
code: result.code,
|
||||
severity: "error",
|
||||
message: result.message,
|
||||
range: {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
},
|
||||
});
|
||||
|
||||
const validationError = (message: string, diagnostics: readonly SemanticCssDiagnostic[]) =>
|
||||
new ORPCError("STYLESHEET_VALIDATION_FAILED", {
|
||||
status: 400,
|
||||
message,
|
||||
data: { diagnostics },
|
||||
});
|
||||
|
||||
const unavailableError = () =>
|
||||
new ORPCError("SEMANTIC_STYLESHEET_UNAVAILABLE", {
|
||||
status: 503,
|
||||
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||
});
|
||||
|
||||
export function createStylesheetService(dependencies: StylesheetServiceDependencies): {
|
||||
getState(input: { id: string; userId: string }): Promise<StylesheetState>;
|
||||
mutate(input: MutateResumeStylesheetInput): Promise<StylesheetMutationResult>;
|
||||
} {
|
||||
const observe = dependencies.observe ?? recordSemanticCssEvent;
|
||||
const sourceBytes = (source: StylesheetSource) => new TextEncoder().encode(source.text).byteLength;
|
||||
const compile = (snapshot: StylesheetSnapshot, source: StylesheetSource) => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
const result = dependencies.compile(source);
|
||||
observe({
|
||||
name: "semantic_css.compile",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: source.languageVersion,
|
||||
sourceBytes: sourceBytes(source),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: result.diagnostics.map(({ code }) => code),
|
||||
pageCount: null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: result.program !== null,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
observe({
|
||||
name: "semantic_css.compile",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: source.languageVersion,
|
||||
sourceBytes: sourceBytes(source),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
const runPreflight = async (snapshot: StylesheetSnapshot, stylesheet: StylesheetSource) => {
|
||||
const startedAt = performance.now();
|
||||
try {
|
||||
if (!dependencies.preflight) throw unavailableError();
|
||||
const result = await dependencies.preflight({ data: snapshot.data, stylesheet });
|
||||
observe({
|
||||
name: "semantic_css.preflight",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: stylesheet.languageVersion,
|
||||
sourceBytes: sourceBytes(stylesheet),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: result.diagnostics.map(({ code }) => code),
|
||||
pageCount: result.ok ? result.pageCount : null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: result.ok,
|
||||
});
|
||||
return result;
|
||||
} catch (error) {
|
||||
observe({
|
||||
name: "semantic_css.preflight",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - startedAt,
|
||||
languageVersion: stylesheet.languageVersion,
|
||||
sourceBytes: sourceBytes(stylesheet),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: [],
|
||||
pageCount: null,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
success: false,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getState: async (input) => stateFromSnapshot(await dependencies.readSnapshot(input), dependencies.convertLegacy),
|
||||
|
||||
mutate: async (input) => {
|
||||
const snapshot = await dependencies.readSnapshot(input);
|
||||
let diagnostics: SemanticCssDiagnostic[] = [];
|
||||
let activationPageCount: number | null = null;
|
||||
const activationStartedAt = performance.now();
|
||||
const observeActivation = (success: boolean, revision: number) => {
|
||||
if (input.transition !== "activate") return;
|
||||
observe({
|
||||
name: "semantic_css.activate",
|
||||
resumeId: snapshot.id,
|
||||
durationMs: performance.now() - activationStartedAt,
|
||||
languageVersion: input.source.languageVersion,
|
||||
sourceBytes: sourceBytes(input.source),
|
||||
template: snapshot.data.metadata.template,
|
||||
diagnosticCodes: diagnostics.map(({ code }) => code),
|
||||
pageCount: activationPageCount,
|
||||
revision,
|
||||
success,
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
if (snapshot.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
|
||||
const current = await stylesheetFromSnapshot(snapshot, dependencies.convertLegacy);
|
||||
let next = current;
|
||||
let didPreflight = false;
|
||||
|
||||
if (input.transition === "edit_source") {
|
||||
const compiled = compile(snapshot, input.source);
|
||||
diagnostics = [...compiled.diagnostics];
|
||||
next = { ...current, source: input.source };
|
||||
|
||||
if (compiled.program && current.mode === "semantic") {
|
||||
const preflight = await runPreflight(snapshot, input.source);
|
||||
didPreflight = true;
|
||||
diagnostics = preflight.ok
|
||||
? [...compiled.diagnostics, ...preflight.diagnostics]
|
||||
: [...compiled.diagnostics, ...preflight.diagnostics, preflightDiagnostic(preflight)];
|
||||
if (preflight.ok) next = { ...next, applied: input.source };
|
||||
}
|
||||
}
|
||||
|
||||
if (input.transition === "activate") {
|
||||
const compiled = compile(snapshot, input.source);
|
||||
diagnostics = [...compiled.diagnostics];
|
||||
if (!compiled.program) {
|
||||
throw validationError("The stylesheet cannot be activated because it is invalid.", compiled.diagnostics);
|
||||
}
|
||||
|
||||
const parity = await dependencies.parity({
|
||||
data: snapshot.data,
|
||||
stylesheet: input.source,
|
||||
resumeId: snapshot.id,
|
||||
revision: snapshot.stylesheetRevision,
|
||||
});
|
||||
if (parity.mismatches.length > 0) {
|
||||
throw new ORPCError("STYLESHEET_PARITY_FAILED", {
|
||||
status: 400,
|
||||
message: "The converted stylesheet does not preserve the legacy PDF presentation.",
|
||||
data: { mismatches: parity.mismatches },
|
||||
});
|
||||
}
|
||||
|
||||
const preflight = await runPreflight(snapshot, input.source);
|
||||
didPreflight = true;
|
||||
activationPageCount = preflight.ok ? preflight.pageCount : null;
|
||||
if (!preflight.ok) {
|
||||
diagnostics = [...compiled.diagnostics, ...preflight.diagnostics, preflightDiagnostic(preflight)];
|
||||
throw validationError("The stylesheet failed PDF preflight.", diagnostics);
|
||||
}
|
||||
|
||||
diagnostics = [...compiled.diagnostics, ...preflight.diagnostics];
|
||||
next = { mode: "semantic", source: input.source, applied: input.source };
|
||||
}
|
||||
|
||||
if (input.transition === "deactivate") {
|
||||
next = { ...current, mode: "legacy" };
|
||||
}
|
||||
|
||||
if (input.transition === "restore_history") {
|
||||
const compiled = compile(snapshot, input.restore.applied);
|
||||
if (!compiled.program) {
|
||||
throw validationError("The historical applied stylesheet is invalid.", compiled.diagnostics);
|
||||
}
|
||||
|
||||
const preflight = await runPreflight(snapshot, input.restore.applied);
|
||||
didPreflight = true;
|
||||
if (!preflight.ok) {
|
||||
throw validationError("The historical applied stylesheet failed PDF preflight.", [
|
||||
...compiled.diagnostics,
|
||||
...preflight.diagnostics,
|
||||
preflightDiagnostic(preflight),
|
||||
]);
|
||||
}
|
||||
|
||||
diagnostics = [...compiled.diagnostics, ...preflight.diagnostics];
|
||||
next = input.restore;
|
||||
}
|
||||
|
||||
if (didPreflight) await dependencies.afterPreflight?.();
|
||||
|
||||
const updated = await dependencies.transaction(async (transaction) => {
|
||||
const locked = await transaction.lock(input);
|
||||
if (locked.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
if (!(await dependencies.compare(locked, input))) throw new StylesheetRevisionConflict(locked);
|
||||
const data = parseStoredResumeData({
|
||||
...locked.data,
|
||||
metadata: { ...locked.data.metadata, stylesheet: next },
|
||||
});
|
||||
return transaction.update({ snapshot: locked, data });
|
||||
});
|
||||
|
||||
await dependencies.publish(updated);
|
||||
observeActivation(true, updated.stylesheetRevision);
|
||||
|
||||
return {
|
||||
...(await stateFromSnapshot(updated, dependencies.convertLegacy)),
|
||||
editGeneration: input.editGeneration,
|
||||
diagnostics,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error instanceof StylesheetRevisionConflict) {
|
||||
observeActivation(false, error.snapshot.stylesheetRevision);
|
||||
throw new ORPCError("STYLESHEET_REVISION_CONFLICT", {
|
||||
status: 409,
|
||||
message: error.message,
|
||||
data: {
|
||||
state: await stateFromSnapshot(error.snapshot, dependencies.convertLegacy),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
observeActivation(false, snapshot.stylesheetRevision);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
type StylesheetDatabase = Pick<typeof db, "select" | "transaction">;
|
||||
|
||||
type DatabaseStylesheetServiceOptions = {
|
||||
database?: StylesheetDatabase;
|
||||
runner?: StylesheetPreflightRunner;
|
||||
afterPreflight?: () => Promise<void>;
|
||||
publish?: (snapshot: StylesheetSnapshot) => Promise<void>;
|
||||
};
|
||||
|
||||
const snapshotSelection = {
|
||||
id: schema.resume.id,
|
||||
userId: schema.resume.userId,
|
||||
data: schema.resume.data,
|
||||
isLocked: schema.resume.isLocked,
|
||||
stylesheetRevision: schema.resume.stylesheetRevision,
|
||||
renderDataVersion: schema.resume.renderDataVersion,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
};
|
||||
|
||||
export function createDatabaseStylesheetService(options: DatabaseStylesheetServiceOptions = {}) {
|
||||
const database = options.database ?? db;
|
||||
const runner = options.runner;
|
||||
const readSnapshot = async (input: { id: string; userId: string }) => {
|
||||
const [snapshot] = await database
|
||||
.select(snapshotSelection)
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
|
||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||
return snapshot;
|
||||
};
|
||||
|
||||
return createStylesheetService({
|
||||
readSnapshot,
|
||||
convertLegacy: convertLegacyStylesheet,
|
||||
compile: compileStylesheet,
|
||||
...(runner
|
||||
? {
|
||||
preflight: ({ data, stylesheet }: { data: ResumeData; stylesheet: StylesheetSource }) =>
|
||||
runner.run({
|
||||
data,
|
||||
template: data.metadata.template,
|
||||
stylesheet,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
parity: checkLegacyStylesheetParity,
|
||||
transaction: (run) =>
|
||||
database.transaction((transaction) =>
|
||||
run({
|
||||
lock: async (input) => {
|
||||
const [snapshot] = await transaction
|
||||
.select(snapshotSelection)
|
||||
.from(schema.resume)
|
||||
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)))
|
||||
.for("update");
|
||||
if (!snapshot) throw new ORPCError("NOT_FOUND");
|
||||
return snapshot;
|
||||
},
|
||||
update: async ({ snapshot, data }) => {
|
||||
const [updated] = await transaction
|
||||
.update(schema.resume)
|
||||
.set({
|
||||
data,
|
||||
stylesheetRevision: snapshot.stylesheetRevision + 1,
|
||||
})
|
||||
.where(and(eq(schema.resume.id, snapshot.id), eq(schema.resume.userId, snapshot.userId)))
|
||||
.returning(snapshotSelection);
|
||||
if (!updated) throw new ORPCError("NOT_FOUND");
|
||||
return updated;
|
||||
},
|
||||
}),
|
||||
),
|
||||
compare: (snapshot, input) =>
|
||||
snapshot.stylesheetRevision === input.expectedRevision &&
|
||||
snapshot.renderDataVersion === input.expectedRenderDataVersion,
|
||||
publish:
|
||||
options.publish ??
|
||||
(async (snapshot) => {
|
||||
try {
|
||||
await publishResumeUpdated({
|
||||
type: "resume.updated",
|
||||
resumeId: snapshot.id,
|
||||
userId: snapshot.userId,
|
||||
updatedAt: snapshot.updatedAt.toISOString(),
|
||||
mutation: "stylesheet",
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn("Failed to publish resume.updated event:", error);
|
||||
}
|
||||
}),
|
||||
...(options.afterPreflight ? { afterPreflight: options.afterPreflight } : {}),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { protectedProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { createDatabaseStylesheetService } from "./stylesheet-service";
|
||||
|
||||
const errors = {
|
||||
SEMANTIC_STYLESHEET_UNAVAILABLE: {
|
||||
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||
status: 503,
|
||||
},
|
||||
STYLESHEET_VALIDATION_FAILED: {
|
||||
message: "The stylesheet failed validation.",
|
||||
status: 400,
|
||||
data: resumeDto.stylesheet.errors.validation,
|
||||
},
|
||||
STYLESHEET_PARITY_FAILED: {
|
||||
message: "The converted stylesheet does not preserve legacy PDF presentation.",
|
||||
status: 400,
|
||||
data: resumeDto.stylesheet.errors.parity,
|
||||
},
|
||||
STYLESHEET_REVISION_CONFLICT: {
|
||||
message: "The resume or stylesheet changed while the candidate was being validated.",
|
||||
status: 409,
|
||||
data: resumeDto.stylesheet.errors.revisionConflict,
|
||||
},
|
||||
} as const;
|
||||
|
||||
export const stylesheetRouter = {
|
||||
getState: protectedProcedure
|
||||
.route({
|
||||
method: "GET",
|
||||
path: "/resumes/{id}/stylesheet",
|
||||
tags: ["Resumes"],
|
||||
operationId: "getResumeStylesheetState",
|
||||
summary: "Get resume stylesheet state",
|
||||
description: "Returns the owner-only canonical semantic stylesheet state and concurrency versions.",
|
||||
successDescription: "The canonical stylesheet state.",
|
||||
})
|
||||
.input(resumeDto.stylesheet.getState.input)
|
||||
.output(resumeDto.stylesheet.getState.output)
|
||||
.handler(({ context, input }) =>
|
||||
createDatabaseStylesheetService({
|
||||
...(context.stylesheetPreflightRunner ? { runner: context.stylesheetPreflightRunner } : {}),
|
||||
}).getState({ id: input.id, userId: context.user.id }),
|
||||
),
|
||||
|
||||
mutate: protectedProcedure
|
||||
.route({
|
||||
method: "POST",
|
||||
path: "/resumes/{id}/stylesheet",
|
||||
tags: ["Resumes"],
|
||||
operationId: "mutateResumeStylesheet",
|
||||
summary: "Mutate resume stylesheet state",
|
||||
description: "Applies one revisioned semantic stylesheet transition after validation and PDF preflight.",
|
||||
successDescription: "The committed canonical stylesheet state.",
|
||||
})
|
||||
.input(resumeDto.stylesheet.mutate.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.stylesheet.mutate.output)
|
||||
.errors(errors)
|
||||
.handler(({ context, input }) =>
|
||||
createDatabaseStylesheetService({
|
||||
...(context.stylesheetPreflightRunner ? { runner: context.stylesheetPreflightRunner } : {}),
|
||||
}).mutate({
|
||||
...input,
|
||||
userId: context.user.id,
|
||||
}),
|
||||
),
|
||||
};
|
||||
@@ -2,6 +2,8 @@ import { protectedProcedure } from "../../context";
|
||||
import { resumeDto } from "../../dto/resume";
|
||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||
import { resumeService } from "./service";
|
||||
import { convertLegacyStylesheet, validateHistoricalStylesheet } from "./stylesheet-preflight";
|
||||
import { stylesheetFromSnapshot } from "./stylesheet-service";
|
||||
|
||||
export const versionsRouter = {
|
||||
listVersions: protectedProcedure
|
||||
@@ -35,11 +37,46 @@ export const versionsRouter = {
|
||||
.input(resumeDto.restoreVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.restoreVersion.output)
|
||||
.handler(({ context, input }) =>
|
||||
resumeService.versions.restore({
|
||||
.errors({
|
||||
SEMANTIC_STYLESHEET_UNAVAILABLE: {
|
||||
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||
status: 503,
|
||||
},
|
||||
STYLESHEET_VALIDATION_FAILED: {
|
||||
message: "The historical stylesheet failed validation.",
|
||||
status: 400,
|
||||
},
|
||||
})
|
||||
.handler(async ({ context, input }) => {
|
||||
const resume = await resumeService.versions.restore({
|
||||
resumeId: input.resumeId,
|
||||
versionId: input.versionId,
|
||||
userId: context.user.id,
|
||||
}),
|
||||
),
|
||||
prepareData: async ({ data, stylesheetRevision }) => {
|
||||
const stylesheet = data.metadata.stylesheet;
|
||||
if (!stylesheet) return data;
|
||||
|
||||
const validated = await validateHistoricalStylesheet({
|
||||
data,
|
||||
resumeId: input.resumeId,
|
||||
revision: stylesheetRevision,
|
||||
stylesheet,
|
||||
...(context.stylesheetPreflightRunner ? { runner: context.stylesheetPreflightRunner } : {}),
|
||||
});
|
||||
return {
|
||||
...data,
|
||||
metadata: { ...data.metadata, stylesheet: validated },
|
||||
};
|
||||
},
|
||||
});
|
||||
const stylesheet = await stylesheetFromSnapshot({ ...resume, userId: context.user.id }, convertLegacyStylesheet);
|
||||
return {
|
||||
resume,
|
||||
stylesheetState: {
|
||||
stylesheet,
|
||||
revision: resume.stylesheetRevision,
|
||||
renderDataVersion: resume.renderDataVersion,
|
||||
},
|
||||
};
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -19,6 +19,8 @@ export const resume = pg.pgTable(
|
||||
isPublic: pg.boolean("is_public").notNull().default(false),
|
||||
isLocked: pg.boolean("is_locked").notNull().default(false),
|
||||
password: pg.text("password"),
|
||||
stylesheetRevision: pg.integer("stylesheet_revision").notNull().default(0),
|
||||
renderDataVersion: pg.integer("render_data_version").notNull().default(0),
|
||||
data: pg
|
||||
.jsonb("data")
|
||||
.notNull()
|
||||
|
||||
@@ -1,9 +1,28 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { buildDocx } from "./index";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
describe("buildDocx", () => {
|
||||
it("returns a Blob for the default resume", async () => {
|
||||
const blob = await buildDocx(defaultResumeData);
|
||||
@@ -27,4 +46,42 @@ describe("buildDocx", () => {
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
await expect(promise).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data before DOCX builder dispatch", async () => {
|
||||
const error = await buildDocx(createRendererUnsafeResumeData()).catch((caught: unknown) => caught);
|
||||
|
||||
expect(error).toHaveProperty("issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||
});
|
||||
|
||||
it("normalizes valid legacy data before DOCX building", async () => {
|
||||
const data = {
|
||||
...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;
|
||||
|
||||
await expect(buildDocx(data)).resolves.toBeInstanceOf(Blob);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SectionTitleResolver } from "./builder";
|
||||
import { Packer } from "docx";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
/**
|
||||
@@ -9,6 +10,6 @@ import { buildDocument } from "./builder";
|
||||
*/
|
||||
// biome-ignore lint/suspicious/useAwait: keep synchronous renderer errors on the public Promise rejection path.
|
||||
export async function buildDocx(data: ResumeData, resolveTitle?: SectionTitleResolver): Promise<Blob> {
|
||||
const doc = buildDocument(data, resolveTitle);
|
||||
const doc = buildDocument(parseResumeData(data), resolveTitle);
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -83,6 +83,8 @@ export const env = createEnv({
|
||||
FLAG_SHOW_SPONSORS: z.stringbool().default(false),
|
||||
FLAG_ALLOW_UNSAFE_AI_BASE_URL: z.stringbool().default(false),
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: z.stringbool().default(false),
|
||||
FLAG_SEMANTIC_CSS_AUTHORING: z.stringbool().default(false),
|
||||
FLAG_SEMANTIC_CSS_DEFAULT: z.stringbool().default(false),
|
||||
},
|
||||
runtimeEnv: process.env,
|
||||
emptyStringAsUndefined: true,
|
||||
|
||||
@@ -6,6 +6,12 @@
|
||||
"exports": {
|
||||
"./browser": "./src/browser.tsx",
|
||||
"./document": "./src/document.tsx",
|
||||
"./preflight": "./src/semantic/preflight-core.tsx",
|
||||
"./preflight-reference": "./src/semantic/preflight-reference.ts",
|
||||
"./public-projection": "./src/semantic/public-projection.ts",
|
||||
"./semantic": "./src/semantic/index.ts",
|
||||
"./semantic-manifest": "./src/semantic/template-manifest.ts",
|
||||
"./semantic-tree": "./src/semantic/tree.ts",
|
||||
"./section-title": "./src/section-title.ts",
|
||||
"./server": "./src/server.tsx"
|
||||
},
|
||||
@@ -22,6 +28,7 @@
|
||||
"dependencies": {
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@reactive-resume/fonts": "workspace:*",
|
||||
"@reactive-resume/resume": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"cjk-regex": "^3.4.0",
|
||||
@@ -31,10 +38,13 @@
|
||||
"react-pdf-html": "^2.1.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@napi-rs/canvas": "1.0.2",
|
||||
"@react-pdf/types": "^2.11.1",
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/react": "^19.2.17",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"pdfjs-dist": "6.1.200",
|
||||
"pixelmatch": "^7.2.0",
|
||||
"typescript": "^7.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
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(() => ({
|
||||
@@ -8,7 +10,7 @@ const rendererMock = vi.hoisted(() => ({
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@react-pdf/renderer", async (importOriginal) => ({
|
||||
vi.mock("#react-pdf-renderer", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
|
||||
pdf: rendererMock.pdf,
|
||||
}));
|
||||
@@ -17,6 +19,43 @@ vi.mock("./document", () => ({
|
||||
ResumeDocument: () => null,
|
||||
}));
|
||||
|
||||
const createLegacyRendererSafeResumeData = (): ResumeData =>
|
||||
({
|
||||
...structuredClone(sampleResumeData),
|
||||
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;
|
||||
|
||||
const createRendererUnsafeResumeData = (): ResumeData => {
|
||||
const data = createLegacyRendererSafeResumeData();
|
||||
const section = data.customSections[0];
|
||||
if (!section) throw new Error("Expected a custom section fixture.");
|
||||
section.items = [{ id: "summary-item", hidden: false, content: "<p>Missing company</p>" }] as never;
|
||||
return data;
|
||||
};
|
||||
|
||||
describe("createResumePdfBlob", () => {
|
||||
beforeEach(() => {
|
||||
rendererMock.pdf.mockClear();
|
||||
@@ -25,9 +64,10 @@ describe("createResumePdfBlob", () => {
|
||||
it("renders ResumeDocument with data, template, and section title resolver", async () => {
|
||||
const resolveSectionTitle: SectionTitleResolver = (input) => input.defaultEnglishTitle ?? input.sectionId;
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
const data = createLegacyRendererSafeResumeData();
|
||||
|
||||
const blob = await createResumePdfBlob({
|
||||
data: sampleResumeData,
|
||||
data,
|
||||
template: "azurill",
|
||||
resolveSectionTitle,
|
||||
});
|
||||
@@ -36,15 +76,38 @@ describe("createResumePdfBlob", () => {
|
||||
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
|
||||
expect(rendererMock.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: {
|
||||
data: sampleResumeData,
|
||||
props: expect.objectContaining({
|
||||
template: "azurill",
|
||||
resolveSectionTitle,
|
||||
},
|
||||
data: expect.objectContaining({
|
||||
customSections: [
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data before browser PDF dispatch", async () => {
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
const error = await createResumePdfBlob({ data: createRendererUnsafeResumeData() }).catch(
|
||||
(caught: unknown) => caught,
|
||||
);
|
||||
|
||||
expect(error).toHaveProperty("issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a rejected Promise when the renderer fails synchronously", async () => {
|
||||
rendererMock.pdf.mockImplementationOnce(() => {
|
||||
throw new Error("renderer failed");
|
||||
@@ -56,4 +119,71 @@ describe("createResumePdfBlob", () => {
|
||||
expect(promise).toBeInstanceOf(Promise);
|
||||
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;
|
||||
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: [] });
|
||||
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,30 +2,79 @@ 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";
|
||||
|
||||
type CreateResumePdfBlobOptions = {
|
||||
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;
|
||||
};
|
||||
|
||||
// biome-ignore lint/suspicious/useAwait: keep synchronous renderer errors on the public Promise rejection path.
|
||||
export const createResumePdfBlob = async ({
|
||||
export type CreateResumePdfBlobResultOptions = CreateResumePdfBlobOptions & {
|
||||
inspection?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const renderResumePdfBlob = async ({
|
||||
data,
|
||||
template,
|
||||
renderOptions,
|
||||
resolveSectionTitle,
|
||||
publicStyleProjection,
|
||||
}: CreateResumePdfBlobOptions) => {
|
||||
const semanticRuntime = publicStyleProjection
|
||||
? await resolvePublicStyleProjectionRuntime(data, publicStyleProjection)
|
||||
: undefined;
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -4,10 +4,13 @@ import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import type { ComponentType } from "react";
|
||||
import type { ResumeRenderOptions } from "./context";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResolvedResumeRuntime } from "./semantic";
|
||||
import { useMemo } from "react";
|
||||
import { Document } from "#react-pdf-renderer";
|
||||
import { RenderProvider } from "./context";
|
||||
import { registerFonts, resumeContentContainsCJK, resumeContentScripts } from "./hooks/use-register-fonts";
|
||||
import { SemanticRenderProvider } from "./semantic/context";
|
||||
import { resolveResumeRuntime, resolveStylesheetMode } from "./semantic/resolve";
|
||||
import { getTemplatePage } from "./templates";
|
||||
import { shouldShowResumeHeader } from "./templates/shared/cover-letter";
|
||||
import { getTemplatePageMinHeightStyle, getTemplatePageSize } from "./templates/shared/page-size";
|
||||
@@ -17,6 +20,7 @@ export type TemplatePageProps = {
|
||||
pageSize: ReturnType<typeof getTemplatePageSize>;
|
||||
pageMinHeightStyle: ReturnType<typeof getTemplatePageMinHeightStyle>;
|
||||
showHeader: boolean;
|
||||
pageNumber: number;
|
||||
};
|
||||
|
||||
export type TemplatePage = ComponentType<TemplatePageProps>;
|
||||
@@ -26,12 +30,19 @@ type ResumeDocumentProps = {
|
||||
template: Template;
|
||||
renderOptions?: ResumeRenderOptions | undefined;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
semanticRuntime?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const getLayoutPageKey = (page: LayoutPage, pageIndex: number) =>
|
||||
`${page.fullWidth ? "full" : "split"}:${page.main.join(",")}:${page.sidebar.join(",")}:${pageIndex}`;
|
||||
|
||||
export const ResumeDocument = ({ data, template, renderOptions, resolveSectionTitle }: ResumeDocumentProps) => {
|
||||
export const ResumeDocument = ({
|
||||
data,
|
||||
template,
|
||||
renderOptions,
|
||||
resolveSectionTitle,
|
||||
semanticRuntime,
|
||||
}: ResumeDocumentProps) => {
|
||||
const TemplatePageComponent = getTemplatePage(template);
|
||||
const creationDate = useMemo(() => new Date(), []);
|
||||
const hasCjkContent = useMemo(() => resumeContentContainsCJK(data), [data]);
|
||||
@@ -50,29 +61,43 @@ export const ResumeDocument = ({ data, template, renderOptions, resolveSectionTi
|
||||
const pageSize = getTemplatePageSize(resumeData.metadata.page.format);
|
||||
const pageMinHeightStyle = getTemplatePageMinHeightStyle(resumeData.metadata.page.format);
|
||||
const headerResumeData = renderOptions ? { ...resumeData, renderOptions } : resumeData;
|
||||
const stylesheetMode = resolveStylesheetMode(resumeData);
|
||||
const runtime = useMemo(
|
||||
() => semanticRuntime ?? resolveResumeRuntime({ data: resumeData, template, mode: stylesheetMode }),
|
||||
[resumeData, semanticRuntime, stylesheetMode, template],
|
||||
);
|
||||
const semanticMode = semanticRuntime ? "semantic" : stylesheetMode;
|
||||
|
||||
return (
|
||||
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
|
||||
<Document
|
||||
pageMode="useNone"
|
||||
creationDate={creationDate}
|
||||
producer="Reactive Resume"
|
||||
title={resumeData.basics.name}
|
||||
author={resumeData.basics.name}
|
||||
creator={resumeData.basics.name}
|
||||
subject={resumeData.basics.headline}
|
||||
language={resumeData.metadata.page.locale}
|
||||
>
|
||||
{resumeData.metadata.layout.pages.map((page, index) => (
|
||||
<TemplatePageComponent
|
||||
key={getLayoutPageKey(page, index)}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageMinHeightStyle={pageMinHeightStyle}
|
||||
showHeader={shouldShowResumeHeader(headerResumeData, index)}
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</RenderProvider>
|
||||
<SemanticRenderProvider
|
||||
presentation={runtime.presentation}
|
||||
mode={semanticMode}
|
||||
sourceTree={runtime.sourceTree}
|
||||
renderTree={runtime.renderTree}
|
||||
>
|
||||
<RenderProvider data={resumeData} resolveSectionTitle={resolveSectionTitle} renderOptions={renderOptions}>
|
||||
<Document
|
||||
pageMode="useNone"
|
||||
creationDate={creationDate}
|
||||
producer="Reactive Resume"
|
||||
title={resumeData.basics.name}
|
||||
author={resumeData.basics.name}
|
||||
creator={resumeData.basics.name}
|
||||
subject={resumeData.basics.headline}
|
||||
language={resumeData.metadata.page.locale}
|
||||
>
|
||||
{resumeData.metadata.layout.pages.map((page, index) => (
|
||||
<TemplatePageComponent
|
||||
key={getLayoutPageKey(page, index)}
|
||||
page={page}
|
||||
pageSize={pageSize}
|
||||
pageMinHeightStyle={pageMinHeightStyle}
|
||||
showHeader={shouldShowResumeHeader(headerResumeData, index)}
|
||||
pageNumber={index + 1}
|
||||
/>
|
||||
))}
|
||||
</Document>
|
||||
</RenderProvider>
|
||||
</SemanticRenderProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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" });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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: "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();
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
};
|
||||
}
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user