mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-23 14:52:18 +10:00
refactor(stylesheet): move Semantic CSS to the browser (#3329)
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
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";
|
||||
@@ -12,7 +11,6 @@ interface ORPCContext {
|
||||
reqHeaders: Headers;
|
||||
resHeaders?: Headers;
|
||||
trustedClient?: string;
|
||||
stylesheetPreflightRunner?: StylesheetPreflightRunner;
|
||||
}
|
||||
|
||||
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
||||
|
||||
@@ -65,7 +65,7 @@ describe("resume DTO output validation", () => {
|
||||
expect(resumeDto.import.input.safeParse({ data }).success).toBe(false);
|
||||
});
|
||||
|
||||
it("defers imported stylesheet validation to the stable unavailable-feature error", () => {
|
||||
it("rejects invalid imported stylesheet structure at the schema boundary", () => {
|
||||
expect(
|
||||
resumeDto.import.input.safeParse({
|
||||
data: {
|
||||
@@ -76,38 +76,13 @@ describe("resume DTO output validation", () => {
|
||||
},
|
||||
},
|
||||
}).success,
|
||||
).toBe(true);
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
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",
|
||||
@@ -130,7 +105,6 @@ describe("resume DTO output validation", () => {
|
||||
const publicResume = {
|
||||
...redactResumeForViewer(dbResume, false),
|
||||
hasPassword: dbResume.hasPassword,
|
||||
stylesheetMode: "legacy",
|
||||
};
|
||||
|
||||
expect(publicResume.name).toBe("Resume");
|
||||
@@ -138,7 +112,7 @@ describe("resume DTO output validation", () => {
|
||||
expect(resumeDto.getBySlug.output.safeParse(publicResume).success).toBe(true);
|
||||
});
|
||||
|
||||
it("exposes only the safe public stylesheet mode discriminator", () => {
|
||||
it("exposes canonical stylesheet source in authorized public data", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||
const parsed = resumeDto.getBySlug.output.parse({
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
@@ -152,7 +126,7 @@ describe("resume DTO output validation", () => {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
stylesheet: { mode: "semantic", source, applied: source },
|
||||
stylesheet: { mode: "semantic", source },
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -161,15 +135,12 @@ describe("resume DTO output validation", () => {
|
||||
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");
|
||||
expect(parsed.data.metadata.stylesheet).toEqual({ mode: "semantic", source });
|
||||
});
|
||||
|
||||
it("returns current canonical stylesheet state only on version restore", () => {
|
||||
it("returns the ordinary resume contract on version restore", () => {
|
||||
const resume = {
|
||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||
name: "Resume",
|
||||
@@ -181,20 +152,6 @@ describe("resume DTO output validation", () => {
|
||||
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 },
|
||||
});
|
||||
expect(resumeDto.restoreVersion.output.parse(resume)).toEqual(resume);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
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."),
|
||||
@@ -26,60 +16,6 @@ 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 = {
|
||||
@@ -107,12 +43,7 @@ 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(), stylesheetMode: z.enum(["legacy", "semantic"]) }),
|
||||
},
|
||||
|
||||
getStyleProjection: {
|
||||
input: z.strictObject({ username: z.string(), slug: z.string() }),
|
||||
output: publicStyleProjectionSchema,
|
||||
.extend({ name: z.string() }),
|
||||
},
|
||||
|
||||
create: {
|
||||
@@ -123,7 +54,7 @@ export const resumeDto = {
|
||||
},
|
||||
|
||||
import: {
|
||||
input: z.object({ data: importedResumeDataSchema }),
|
||||
input: z.object({ data: resumeDataSchema }),
|
||||
output: z.string().describe("The ID of the imported resume."),
|
||||
},
|
||||
|
||||
@@ -191,55 +122,6 @@ 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: 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),
|
||||
}),
|
||||
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),
|
||||
}),
|
||||
},
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -71,7 +71,7 @@ describe("redactResumeForViewer", () => {
|
||||
expect(result.data.metadata.notes).toBe("");
|
||||
});
|
||||
|
||||
it("strips editable and applied stylesheet source for non-owner", () => {
|
||||
it("preserves stylesheet source for an authorized non-owner", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||
const resume = {
|
||||
name: "Title",
|
||||
@@ -79,15 +79,14 @@ describe("redactResumeForViewer", () => {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
stylesheet: { mode: "semantic" as const, source, applied: source },
|
||||
stylesheet: { mode: "semantic" as const, source },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = redactResumeForViewer(resume, false);
|
||||
|
||||
expect(result.data.metadata.stylesheet).toBeUndefined();
|
||||
expect(JSON.stringify(result)).not.toContain("@version");
|
||||
expect(result.data.metadata.stylesheet).toEqual({ mode: "semantic", source });
|
||||
});
|
||||
|
||||
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
|
||||
|
||||
@@ -53,15 +53,13 @@ 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,
|
||||
name: "Resume",
|
||||
data: {
|
||||
...resume.data,
|
||||
metadata: {
|
||||
...metadata,
|
||||
...resume.data.metadata,
|
||||
notes: "",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
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 { createResumeData } from "./initial-data";
|
||||
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
|
||||
@@ -99,23 +97,10 @@ 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 data = input.data;
|
||||
const name = generateRandomName();
|
||||
const slug = slugify(name);
|
||||
|
||||
|
||||
@@ -144,7 +144,7 @@ describe("subscribeResumeUpdated", () => {
|
||||
await iterator.next();
|
||||
});
|
||||
|
||||
it("accepts stylesheet invalidations and ignores unknown mutation names", async () => {
|
||||
it("ignores removed stylesheet and unknown mutation names", async () => {
|
||||
const client = makeFakeClient();
|
||||
pool.connect.mockResolvedValueOnce(client);
|
||||
|
||||
@@ -161,9 +161,10 @@ describe("subscribeResumeUpdated", () => {
|
||||
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "forged" }));
|
||||
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "stylesheet" }));
|
||||
client.__notify("resume_updated", JSON.stringify(exampleEvent));
|
||||
|
||||
const result = await resultP;
|
||||
expect(result.value?.mutation).toBe("stylesheet");
|
||||
expect(result.value).toEqual(exampleEvent);
|
||||
|
||||
controller.abort();
|
||||
await iterator.next();
|
||||
|
||||
@@ -1,16 +1,7 @@
|
||||
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);
|
||||
const resumeMutationNames = new Set(["sync", "create", "update", "patch", "lock", "password", "delete"] as const);
|
||||
|
||||
type PgNotification = {
|
||||
channel?: string | undefined;
|
||||
@@ -22,7 +13,7 @@ export type ResumeUpdatedEvent = {
|
||||
resumeId: string;
|
||||
userId: string;
|
||||
updatedAt: string;
|
||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
|
||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
||||
};
|
||||
|
||||
type SubscribeResumeUpdatedInput = {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createResumeData } from "./initial-data";
|
||||
|
||||
describe("createResumeData", () => {
|
||||
it("seeds one canonical empty stylesheet source", () => {
|
||||
expect(createResumeData({}).metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clones normal and sample defaults instead of mutating shared data", () => {
|
||||
const normal = createResumeData({ locale: "de-DE" });
|
||||
const sample = createResumeData({ 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");
|
||||
});
|
||||
});
|
||||
-16
@@ -1,6 +1,5 @@
|
||||
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";
|
||||
@@ -11,20 +10,6 @@ type CreateResumeDataOptions = {
|
||||
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);
|
||||
|
||||
@@ -32,7 +17,6 @@ export function createResumeData(options: CreateResumeDataOptions): ResumeData {
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
applied: { languageVersion: 1, text: EMPTY_SEMANTIC_CSS_SOURCE },
|
||||
};
|
||||
|
||||
return data;
|
||||
@@ -1,8 +1,6 @@
|
||||
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 = {
|
||||
@@ -10,250 +8,86 @@ const input = {
|
||||
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;
|
||||
};
|
||||
const dependencies = (resume = buildResume()) => ({
|
||||
findResume: vi.fn().mockResolvedValue(resume),
|
||||
hasPasswordAccess: vi.fn().mockReturnValue(true),
|
||||
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||
rateLimiter: { consume: vi.fn() },
|
||||
renderPdf: vi.fn().mockResolvedValue(new File(["%PDF"], "resume.pdf", { type: "application/pdf" })),
|
||||
});
|
||||
|
||||
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();
|
||||
const privateDependencies = dependencies(buildResume({ isPublic: false }));
|
||||
await expect(createPublicResumePdf(input, privateDependencies)).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||
expect(privateDependencies.rateLimiter.consume).not.toHaveBeenCalled();
|
||||
expect(privateDependencies.renderPdf).not.toHaveBeenCalled();
|
||||
|
||||
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();
|
||||
const passwordDependencies = dependencies(buildResume({ passwordHash: "hash" }));
|
||||
passwordDependencies.hasPasswordAccess.mockReturnValue(false);
|
||||
await expect(createPublicResumePdf(input, passwordDependencies)).rejects.toMatchObject({ code: "NEED_PASSWORD" });
|
||||
expect(passwordDependencies.rateLimiter.consume).not.toHaveBeenCalled();
|
||||
expect(passwordDependencies.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;
|
||||
it("rejects renderer-unsafe stored data before budget or rendering", async () => {
|
||||
const resume = buildResume();
|
||||
resume.id = sensitive;
|
||||
resume.data.basics.name = sensitive;
|
||||
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,
|
||||
];
|
||||
const unsafeDependencies = dependencies(resume);
|
||||
|
||||
await expect(createPublicResumePdf(input, unsafeDependencies)).rejects.toMatchObject({
|
||||
code: "INTERNAL_SERVER_ERROR",
|
||||
});
|
||||
expect(unsafeDependencies.rateLimiter.consume).not.toHaveBeenCalled();
|
||||
expect(unsafeDependencies.renderPdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("renders on demand with canonical public stylesheet source", async () => {
|
||||
const resume = buildResume();
|
||||
resume.data.basics.name = "Ada Lovelace";
|
||||
resume.data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: sensitive },
|
||||
applied: { languageVersion: 1, text: sensitive },
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
|
||||
};
|
||||
const pdfDependencies = dependencies(resume);
|
||||
|
||||
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 result = await createPublicResumePdf(input, pdfDependencies);
|
||||
|
||||
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(result.filename).toBe("ada-lovelace.pdf");
|
||||
expect(pdfDependencies.rateLimiter.consume).toHaveBeenCalledWith({
|
||||
trustedClient: input.trustedClient,
|
||||
resumeId: resume.id,
|
||||
});
|
||||
expect(serialized).not.toContain(sensitive);
|
||||
expect(serialized).not.toMatch(/source|comment|diagnostic|email/i);
|
||||
expect(pdfDependencies.renderPdf).toHaveBeenCalledWith({ data: resume.data, filename: "ada-lovelace.pdf" });
|
||||
});
|
||||
|
||||
it("preserves ordinary renderer failures", async () => {
|
||||
const rendererError = new Error("renderer failed");
|
||||
const pdfDependencies = dependencies();
|
||||
pdfDependencies.renderPdf.mockRejectedValue(rendererError);
|
||||
|
||||
await expect(createPublicResumePdf(input, pdfDependencies)).rejects.toBe(rendererError);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,112 +1,84 @@
|
||||
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 { assertCanView } from "./access-policy";
|
||||
import { publicRenderRateLimiter } from "./public-render-rate-limit";
|
||||
import { defaultPublicRenderAccessDependencies, loadAuthorizedPublicRenderResume } from "./public-style-projection";
|
||||
import { hashSemanticCssResumeId } from "./stylesheet-observability";
|
||||
import { parseStoredResumeData } from "./resume-data-validation";
|
||||
|
||||
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[];
|
||||
type PublicRenderResume = {
|
||||
id: string;
|
||||
userId: string;
|
||||
data: ResumeData;
|
||||
isPublic: boolean;
|
||||
passwordHash: string | null;
|
||||
};
|
||||
|
||||
export type CreatePublicResumePdfInput = {
|
||||
username: string;
|
||||
slug: string;
|
||||
requestHeaders: Headers;
|
||||
trustedClient: string;
|
||||
mismatchReason: PublicResumePdfMismatchReason;
|
||||
clientRegistryFingerprint?: string;
|
||||
clientAdapterFingerprint?: string;
|
||||
};
|
||||
|
||||
export type PublicResumePdfDependencies = PublicRenderAccessDependencies & {
|
||||
export type PublicResumePdfDependencies = {
|
||||
findResume(input: Pick<CreatePublicResumePdfInput, "username" | "slug">): Promise<PublicRenderResume | null>;
|
||||
hasPasswordAccess(requestHeaders: Headers, resumeId: string, passwordHash: string | null): boolean | Promise<boolean>;
|
||||
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 findResume = async ({ username, slug }: Pick<CreatePublicResumePdfInput, "username" | "slug">) => {
|
||||
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,
|
||||
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;
|
||||
};
|
||||
|
||||
const defaultDependencies: PublicResumePdfDependencies = {
|
||||
...defaultPublicRenderAccessDependencies,
|
||||
findResume,
|
||||
hasPasswordAccess: async (requestHeaders, resumeId, passwordHash) =>
|
||||
(await import("./access")).hasResumeAccess(requestHeaders, resumeId, passwordHash),
|
||||
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,
|
||||
});
|
||||
};
|
||||
const resume = await dependencies.findResume(input);
|
||||
if (!resume) throw new ORPCError("NOT_FOUND");
|
||||
|
||||
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;
|
||||
const currentUserId = await dependencies.resolveCurrentUserId(input.requestHeaders);
|
||||
assertCanView(resume, currentUserId ? { id: currentUserId } : null);
|
||||
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);
|
||||
dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id });
|
||||
const filename = generateFilename(data.basics.name || "Resume", "pdf");
|
||||
return { body: await dependencies.renderPdf({ data, filename }), filename };
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,190 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,130 +0,0 @@
|
||||
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,18 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { parseWritableResumeData } from "./resume-data-validation";
|
||||
|
||||
describe("parseWritableResumeData", () => {
|
||||
it("rejects stylesheet source above the Semantic CSS byte limit", () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "x".repeat(SEMANTIC_CSS_LIMITS_V1.maxSourceBytes + 1) },
|
||||
};
|
||||
|
||||
expect(() => parseWritableResumeData(data)).toThrowError(
|
||||
expect.objectContaining({ code: "BAD_REQUEST", status: 400 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,10 +1,16 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { ORPCError } from "@orpc/client";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet";
|
||||
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);
|
||||
const parsed = parseResumeData(data);
|
||||
const source = parsed.metadata.stylesheet?.source.text;
|
||||
if (source !== undefined && new TextEncoder().encode(source).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes) {
|
||||
throw new Error("The stylesheet source exceeds the Semantic CSS byte limit.");
|
||||
}
|
||||
return parsed;
|
||||
} catch (cause) {
|
||||
throw new ORPCError(code, {
|
||||
status: code === "BAD_REQUEST" ? 400 : 500,
|
||||
|
||||
@@ -3,7 +3,6 @@ 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";
|
||||
|
||||
@@ -12,12 +11,10 @@ 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,
|
||||
|
||||
@@ -32,8 +32,6 @@ 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",
|
||||
},
|
||||
@@ -112,17 +110,10 @@ const createSemanticResumeData = (): ResumeData => {
|
||||
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),
|
||||
@@ -183,7 +174,7 @@ const createResumeRow = (data: ResumeData, updatedAt = new Date()) => ({
|
||||
});
|
||||
|
||||
const createRestoreHarness = (currentData: ResumeData, restoredData: ResumeData) => {
|
||||
const currentRow = { ...createResumeRow(currentData), stylesheetRevision: 3 };
|
||||
const currentRow = createResumeRow(currentData);
|
||||
const versionLookup = {
|
||||
from: () => ({
|
||||
innerJoin: () => ({ where: () => Promise.resolve([{ data: restoredData }]) }),
|
||||
@@ -206,8 +197,6 @@ const createRestoreHarness = (currentData: ResumeData, restoredData: ResumeData)
|
||||
{
|
||||
data: currentData,
|
||||
isLocked: false,
|
||||
stylesheetRevision: 3,
|
||||
renderDataVersion: 7,
|
||||
updatedAt: currentRow.updatedAt,
|
||||
},
|
||||
]);
|
||||
@@ -249,7 +238,7 @@ it("imports", () => {
|
||||
});
|
||||
|
||||
describe("create", () => {
|
||||
it("copies stylesheet content while leaving both concurrency versions at database defaults", async () => {
|
||||
it("copies stylesheet content", async () => {
|
||||
const data = createSemanticResumeData();
|
||||
const values = vi.fn((_input: unknown) => Promise.resolve());
|
||||
dbMock.insert.mockReturnValueOnce({ values });
|
||||
@@ -270,8 +259,6 @@ describe("create", () => {
|
||||
}),
|
||||
}),
|
||||
);
|
||||
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 () => {
|
||||
@@ -357,139 +344,44 @@ describe("versions.snapshot", () => {
|
||||
});
|
||||
|
||||
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 = {
|
||||
it("normalizes a historical applied stylesheet while restoring its canonical source", async () => {
|
||||
const currentData = createSemanticResumeData();
|
||||
const restoredData = createSemanticResumeData();
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" };
|
||||
const legacyRestoredData = {
|
||||
...restoredData,
|
||||
metadata: {
|
||||
...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" } },
|
||||
source,
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: red; }\n" },
|
||||
},
|
||||
},
|
||||
];
|
||||
const { set } = createRestoreHarness(currentData, restoredData);
|
||||
} as unknown as ResumeData;
|
||||
const { set } = createRestoreHarness(currentData, legacyRestoredData);
|
||||
|
||||
await resumeService.versions.restore({
|
||||
const result = 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 }));
|
||||
expect(set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ stylesheet: { mode: "semantic", source } }),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(result.data.metadata.stylesheet).toEqual({ mode: "semantic", source });
|
||||
});
|
||||
|
||||
it("rejects a currently locked resume before version lookup, preparation, or snapshots", async () => {
|
||||
it("rejects a currently locked resume before version lookup or snapshots", async () => {
|
||||
const callOrder: string[] = [];
|
||||
const currentRow = {
|
||||
...createResumeRow(createStylesheetResumeData("semantic")),
|
||||
...createResumeRow(createSemanticResumeData()),
|
||||
isLocked: true,
|
||||
stylesheetRevision: 3,
|
||||
};
|
||||
const currentLookup = {
|
||||
from: () => ({
|
||||
@@ -504,7 +396,7 @@ describe("versions.restore", () => {
|
||||
innerJoin: () => ({
|
||||
where: () => {
|
||||
callOrder.push("versionLookup");
|
||||
return Promise.resolve([{ data: createStylesheetResumeData("semantic") }]);
|
||||
return Promise.resolve([{ data: createSemanticResumeData() }]);
|
||||
},
|
||||
}),
|
||||
}),
|
||||
@@ -512,65 +404,43 @@ describe("versions.restore", () => {
|
||||
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 () => {
|
||||
it("rejects a renderer-unsafe historical snapshot before 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 () => {
|
||||
it("normalizes a valid historical snapshot before 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>",
|
||||
@@ -582,9 +452,7 @@ describe("versions.restore", () => {
|
||||
|
||||
describe("update", () => {
|
||||
it("throws RESUME_LOCKED when the pre-read reports the resume is locked", async () => {
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: true, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: true, updatedAt: new Date() }]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain }),
|
||||
);
|
||||
@@ -607,9 +475,7 @@ describe("update", () => {
|
||||
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||
hasPassword: false,
|
||||
};
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: row.updatedAt },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, updatedAt: row.updatedAt }]);
|
||||
const update = createUpdateChain([row]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
@@ -624,9 +490,7 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
it("throws NOT_FOUND when the UPDATE ... RETURNING matches no row", async () => {
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, updatedAt: new Date() }]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => createUpdateChain([]).chain }),
|
||||
);
|
||||
@@ -637,9 +501,7 @@ describe("update", () => {
|
||||
});
|
||||
|
||||
it("maps a resume_slug_user_id_unique violation to RESUME_SLUG_ALREADY_EXISTS", async () => {
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 0, updatedAt: new Date() },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, updatedAt: new Date() }]);
|
||||
const update = {
|
||||
set: () => ({
|
||||
where: () => ({
|
||||
@@ -660,14 +522,14 @@ describe("update", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves the server stylesheet and increments render-data version once for visual changes", async () => {
|
||||
it("persists the stylesheet supplied through the ordinary update path", async () => {
|
||||
const serverData = createSemanticResumeData();
|
||||
const clientData: ResumeData = structuredClone(defaultResumeData);
|
||||
clientData.basics.name = "Changed";
|
||||
const clientData = createSemanticResumeData();
|
||||
if (clientData.metadata.stylesheet) {
|
||||
clientData.metadata.stylesheet.source.text = "@version 1;\nname { color: blue; }\n";
|
||||
}
|
||||
const row = createResumeRow(clientData);
|
||||
const select = createLockedSelectChain([
|
||||
{ data: serverData, isLocked: false, renderDataVersion: 3, updatedAt: row.updatedAt },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: serverData, isLocked: false, updatedAt: row.updatedAt }]);
|
||||
const update = createUpdateChain([row]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
@@ -678,35 +540,14 @@ describe("update", () => {
|
||||
expect(update.set).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({ stylesheet: serverData.metadata.stylesheet }),
|
||||
metadata: expect.objectContaining({ stylesheet: clientData.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 select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, updatedAt: new Date() }]);
|
||||
const update = createUpdateChain([createResumeRow(defaultResumeData)]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
@@ -727,9 +568,7 @@ describe("update", () => {
|
||||
|
||||
it("persists normalized renderer-safe overlapping data", async () => {
|
||||
const clientData = createOverlappingRendererSafeResumeData();
|
||||
const select = createLockedSelectChain([
|
||||
{ data: defaultResumeData, isLocked: false, renderDataVersion: 3, updatedAt: new Date() },
|
||||
]);
|
||||
const select = createLockedSelectChain([{ data: defaultResumeData, isLocked: false, updatedAt: new Date() }]);
|
||||
const update = createUpdateChain([createResumeRow(clientData)]);
|
||||
dbMock.transaction.mockImplementationOnce(async (callback: (tx: unknown) => Promise<unknown>) =>
|
||||
callback({ select: () => select.chain, update: () => update.chain }),
|
||||
@@ -766,12 +605,7 @@ describe("patch", () => {
|
||||
slots: { heading: { color: "#000000" } },
|
||||
};
|
||||
|
||||
const createPatchTx = (existing: {
|
||||
data: ResumeData;
|
||||
isLocked: boolean;
|
||||
renderDataVersion: number;
|
||||
updatedAt: Date;
|
||||
}) => {
|
||||
const createPatchTx = (existing: { data: ResumeData; isLocked: boolean; updatedAt: Date }) => {
|
||||
const lockedSelect = createLockedSelectChain([existing]);
|
||||
const row = createResumeRow(existing.data, existing.updatedAt);
|
||||
const update = createUpdateChain([row]);
|
||||
@@ -788,69 +622,36 @@ describe("patch", () => {
|
||||
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 }) => {
|
||||
it("persists stylesheet source through the ordinary patch path", 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: [operation],
|
||||
await resumeService.patchInTransaction(tx as never, {
|
||||
id: "r1",
|
||||
userId: "u1",
|
||||
operations: [
|
||||
{
|
||||
op: "replace",
|
||||
path: "/metadata/stylesheet/source/text",
|
||||
value: "@version 1;\nname { color: blue; }\n",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
metadata: expect.objectContaining({
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).rejects.toMatchObject({
|
||||
code: "INVALID_PATCH_OPERATIONS",
|
||||
message: expect.stringContaining("server-owned stylesheet"),
|
||||
});
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
@@ -871,7 +672,6 @@ describe("patch", () => {
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 2,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
@@ -891,55 +691,11 @@ describe("patch", () => {
|
||||
expect(update.set).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects legacy style-rule changes while semantic mode is active", async () => {
|
||||
it("persists legacy style-rule changes through the ordinary patch path", 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(),
|
||||
});
|
||||
|
||||
@@ -948,8 +704,9 @@ describe("patch", () => {
|
||||
userId: "u1",
|
||||
operations: [{ op: "add", path: "/metadata/styleRules/-", value: styleRule }],
|
||||
});
|
||||
|
||||
expect(update.set).toHaveBeenCalledWith(expect.objectContaining({ renderDataVersion: 6 }));
|
||||
expect(update.set).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ metadata: expect.objectContaining({ styleRules: [styleRule] }) }),
|
||||
});
|
||||
});
|
||||
|
||||
it("allows a harmless sibling operation below metadata while preserving the server stylesheet", async () => {
|
||||
@@ -957,7 +714,6 @@ describe("patch", () => {
|
||||
const { tx, update } = createPatchTx({
|
||||
data,
|
||||
isLocked: false,
|
||||
renderDataVersion: 5,
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ 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];
|
||||
@@ -39,11 +38,9 @@ function invalidPatchOperation(message: string, index?: number, operation?: Json
|
||||
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;
|
||||
function isValidJsonPointer(pointer: string): boolean {
|
||||
if (pointer === "") return true;
|
||||
if (!pointer.startsWith("/")) return false;
|
||||
|
||||
const segments = pointer
|
||||
.slice(1)
|
||||
@@ -52,35 +49,16 @@ function classifyJsonPointer(pointer: string): JsonPointerClass | undefined {
|
||||
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";
|
||||
return !segments.some((segment) => segment === undefined);
|
||||
}
|
||||
|
||||
function assertSafePatchPointers(operation: JsonPatchOperation, index: number) {
|
||||
const pathClass = classifyJsonPointer(operation.path);
|
||||
if (!pathClass) {
|
||||
function assertValidPatchPointers(operation: JsonPatchOperation, index: number) {
|
||||
if (!isValidJsonPointer(operation.path)) {
|
||||
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,
|
||||
);
|
||||
if ("from" in operation && !isValidJsonPointer(operation.from)) {
|
||||
throw invalidPatchOperation("Operation `from` property is not a valid JSON Pointer string.", index, operation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,7 +126,6 @@ async function applyResumePatchTx(
|
||||
.select({
|
||||
data: schema.resume.data,
|
||||
isLocked: schema.resume.isLocked,
|
||||
renderDataVersion: schema.resume.renderDataVersion,
|
||||
updatedAt: schema.resume.updatedAt,
|
||||
})
|
||||
.from(schema.resume)
|
||||
@@ -161,7 +138,7 @@ async function applyResumePatchTx(
|
||||
throw resumeVersionConflict(existing.updatedAt);
|
||||
}
|
||||
|
||||
input.operations.forEach(assertSafePatchPointers);
|
||||
input.operations.forEach(assertValidPatchPointers);
|
||||
|
||||
let patchedData: ResumeData;
|
||||
|
||||
@@ -182,21 +159,10 @@ 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);
|
||||
patchedData = parseWritableResumeData(patchedData);
|
||||
const [resume] = await client
|
||||
.update(schema.resume)
|
||||
.set({
|
||||
data: patchedData,
|
||||
...(renderDataChanged ? { renderDataVersion: existing.renderDataVersion + 1 } : {}),
|
||||
})
|
||||
.set({ data: patchedData })
|
||||
.where(
|
||||
and(
|
||||
eq(schema.resume.id, input.id),
|
||||
@@ -395,7 +361,6 @@ function toSharedResumeResponse(
|
||||
isLocked: boolean;
|
||||
},
|
||||
hasPassword: boolean,
|
||||
stylesheetMode: "legacy" | "semantic",
|
||||
) {
|
||||
return {
|
||||
id: resume.id,
|
||||
@@ -406,7 +371,6 @@ function toSharedResumeResponse(
|
||||
isPublic: resume.isPublic,
|
||||
isLocked: resume.isLocked,
|
||||
hasPassword,
|
||||
stylesheetMode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -455,12 +419,7 @@ 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;
|
||||
prepareData(input: { data: ResumeData; stylesheetRevision: number }): Promise<ResumeData>;
|
||||
}) => {
|
||||
restore: async (input: { resumeId: string; versionId: string; userId: string }) => {
|
||||
// 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");
|
||||
@@ -481,10 +440,6 @@ export const resumeService = {
|
||||
const versionData = parseStoredResumeData(version.data);
|
||||
|
||||
// Capture the pre-restore state first so the restore itself is undoable.
|
||||
const restoredData = await input.prepareData({
|
||||
data: versionData,
|
||||
stylesheetRevision: current.stylesheetRevision,
|
||||
});
|
||||
await resumeService.versions.snapshot({
|
||||
resumeId: input.resumeId,
|
||||
userId: input.userId,
|
||||
@@ -495,8 +450,7 @@ export const resumeService = {
|
||||
const updated = await resumeService.update({
|
||||
id: input.resumeId,
|
||||
userId: input.userId,
|
||||
data: restoredData,
|
||||
restoreStylesheet: true,
|
||||
data: versionData,
|
||||
skipAutoSnapshot: true,
|
||||
});
|
||||
|
||||
@@ -550,7 +504,6 @@ 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`,
|
||||
})
|
||||
@@ -599,12 +552,7 @@ export const resumeService = {
|
||||
}
|
||||
}
|
||||
|
||||
const stylesheetMode = resume.data.metadata.stylesheet?.mode ?? "legacy";
|
||||
return toSharedResumeResponse(
|
||||
redactResumeForViewer(resume, isOwner(resume, viewer)),
|
||||
resume.hasPassword,
|
||||
stylesheetMode,
|
||||
);
|
||||
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
|
||||
},
|
||||
|
||||
create: async (input: {
|
||||
@@ -659,7 +607,6 @@ export const resumeService = {
|
||||
tags?: string[];
|
||||
data?: ResumeData;
|
||||
isPublic?: boolean;
|
||||
restoreStylesheet?: boolean;
|
||||
skipAutoSnapshot?: boolean;
|
||||
}) => {
|
||||
const resume = await db
|
||||
@@ -668,8 +615,6 @@ export const resumeService = {
|
||||
.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)))
|
||||
@@ -677,29 +622,13 @@ export const resumeService = {
|
||||
|
||||
if (!existing) throw new ORPCError("NOT_FOUND");
|
||||
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
|
||||
const inputData = input.data ? parseWritableResumeData(input.data) : undefined;
|
||||
|
||||
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 normalizedData = 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 } : {}),
|
||||
...(normalizedData ? { data: normalizedData } : {}),
|
||||
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
|
||||
...(input.restoreStylesheet ? { stylesheetRevision: existing.stylesheetRevision + 1 } : {}),
|
||||
...(renderDataChanged ? { renderDataVersion: existing.renderDataVersion + 1 } : {}),
|
||||
};
|
||||
|
||||
const [updated] = await tx
|
||||
@@ -720,8 +649,6 @@ export const resumeService = {
|
||||
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`,
|
||||
});
|
||||
|
||||
@@ -2,7 +2,6 @@ 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 = {
|
||||
@@ -27,29 +26,6 @@ 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",
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,74 +0,0 @@
|
||||
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"] }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
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.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,
|
||||
});
|
||||
}
|
||||
@@ -1,163 +0,0 @@
|
||||
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 { prepareImportedResumeData, validateHistoricalStylesheet } from "./stylesheet-preflight";
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,192 +0,0 @@
|
||||
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 { 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;
|
||||
}
|
||||
}
|
||||
@@ -1,121 +0,0 @@
|
||||
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("always seeds an empty semantic stylesheet", () => {
|
||||
expect(createResumeData({}).metadata.stylesheet).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||
});
|
||||
});
|
||||
|
||||
it("clones normal and sample defaults instead of mutating shared data", () => {
|
||||
const normal = createResumeData({ locale: "de-DE" });
|
||||
const sample = createResumeData({
|
||||
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");
|
||||
});
|
||||
});
|
||||
@@ -1,73 +0,0 @@
|
||||
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 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);
|
||||
});
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,497 +0,0 @@
|
||||
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;
|
||||
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 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);
|
||||
},
|
||||
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, 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("activates an explicitly requested mode transition without checking legacy parity", 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.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("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.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);
|
||||
});
|
||||
});
|
||||
@@ -1,426 +0,0 @@
|
||||
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 { 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>;
|
||||
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 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,
|
||||
}),
|
||||
}
|
||||
: {}),
|
||||
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 } : {}),
|
||||
});
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
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_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,8 +2,6 @@ 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
|
||||
@@ -37,46 +35,11 @@ export const versionsRouter = {
|
||||
.input(resumeDto.restoreVersion.input)
|
||||
.use(resumeMutationRateLimit)
|
||||
.output(resumeDto.restoreVersion.output)
|
||||
.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({
|
||||
.handler(({ context, input }) =>
|
||||
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,8 +19,6 @@ 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()
|
||||
|
||||
@@ -8,6 +8,23 @@ describe("parseReactiveResumeJSON", () => {
|
||||
expect(result.basics.name).toBe(defaultResumeData.basics.name);
|
||||
});
|
||||
|
||||
it("imports a historical applied stylesheet as canonical source-only data", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: red; }\n" };
|
||||
const data = {
|
||||
...structuredClone(defaultResumeData),
|
||||
metadata: {
|
||||
...structuredClone(defaultResumeData.metadata),
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source,
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(parseReactiveResumeJSON(JSON.stringify(data)).metadata.stylesheet).toEqual({ mode: "semantic", source });
|
||||
});
|
||||
|
||||
it("throws a JSON-serialised validation error for an invalid object", () => {
|
||||
// Missing required top-level fields.
|
||||
expect(() => parseReactiveResumeJSON(JSON.stringify({ foo: "bar" }))).toThrow();
|
||||
|
||||
@@ -6,10 +6,8 @@
|
||||
"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-legacy": "./src/semantic/legacy-converter.ts",
|
||||
"./semantic-manifest": "./src/semantic/template-manifest.ts",
|
||||
"./semantic-tree": "./src/semantic/tree.ts",
|
||||
"./section-title": "./src/section-title.ts",
|
||||
|
||||
@@ -2,7 +2,6 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
import { createPublicStyleProjection } from "./semantic/public-projection";
|
||||
|
||||
const rendererMock = vi.hoisted(() => ({
|
||||
pdf: vi.fn(() => ({
|
||||
@@ -120,70 +119,12 @@ describe("createResumePdfBlob", () => {
|
||||
await expect(promise).rejects.toThrow("renderer failed");
|
||||
});
|
||||
|
||||
it("renders a source-free public projection through the semantic runtime", async () => {
|
||||
const semanticData = structuredClone(sampleResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||
const publicData = structuredClone(semanticData);
|
||||
delete publicData.metadata.stylesheet;
|
||||
it("renders with base styles when the source is fatal", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
await createResumePdfBlob({ data: publicData, publicStyleProjection: projection });
|
||||
|
||||
expect(rendererMock.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
data: publicData,
|
||||
semanticRuntime: expect.objectContaining({
|
||||
presentation: expect.objectContaining({
|
||||
"page-1/region-header/header/name": { style: { color: "#123456" } },
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns semantic diagnostics without rendering an invalid applied source", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfBlobResult } = await import("./browser");
|
||||
|
||||
const result = await createResumePdfBlobResult({ data });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unchecked rendering instead of producing an unstyled PDF for semantic errors", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfBlob } = await import("./browser");
|
||||
|
||||
await expect(createResumePdfBlob({ data })).rejects.toMatchObject({
|
||||
cause: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("accepts an optional prior semantic inspection on the result path", async () => {
|
||||
const { createResumePdfBlobResult } = await import("./browser");
|
||||
const inspection = {
|
||||
presentation: {},
|
||||
sourceTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
renderTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||
diagnostics: [],
|
||||
} as const;
|
||||
|
||||
const result = await createResumePdfBlobResult({ data: sampleResumeData, inspection });
|
||||
|
||||
expect(result).toMatchObject({ ok: true, diagnostics: [] });
|
||||
await expect(createResumePdfBlob({ data })).resolves.toHaveProperty("type", "application/pdf");
|
||||
expect(rendererMock.pdf).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,79 +2,31 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ResumeRenderOptions } from "./context";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResolvedResumeRuntime, ResumePdfRenderResult } from "./semantic";
|
||||
import type { PublicStyleProjection } from "./semantic/public-projection";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { pdf } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "./document";
|
||||
import { hasSemanticErrors, inspectResumePdf } from "./semantic";
|
||||
import { resolvePublicStyleProjectionRuntime } from "./semantic/public-projection";
|
||||
|
||||
export type {
|
||||
BrowserPdfPreflightResult,
|
||||
PdfPreflightFailure,
|
||||
PdfPreflightPageLimits,
|
||||
PdfPreflightResult,
|
||||
RenderPreflightPdfResult,
|
||||
StylesheetPreflightInput,
|
||||
} from "./semantic/preflight-core";
|
||||
export { renderPreflightPdf } from "./semantic/preflight-core";
|
||||
|
||||
export type CreateResumePdfBlobOptions = {
|
||||
data: ResumeData;
|
||||
template?: Template | undefined;
|
||||
renderOptions?: ResumeRenderOptions | undefined;
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
publicStyleProjection?: PublicStyleProjection | undefined;
|
||||
};
|
||||
|
||||
export type CreateResumePdfBlobResultOptions = CreateResumePdfBlobOptions & {
|
||||
inspection?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const renderResumePdfBlob = async ({
|
||||
data,
|
||||
export const createResumePdfBlob = async ({
|
||||
data: input,
|
||||
template,
|
||||
renderOptions,
|
||||
resolveSectionTitle,
|
||||
publicStyleProjection,
|
||||
}: CreateResumePdfBlobOptions) => {
|
||||
const semanticRuntime = publicStyleProjection
|
||||
? await resolvePublicStyleProjectionRuntime(data, publicStyleProjection)
|
||||
: undefined;
|
||||
}: CreateResumePdfBlobOptions): Promise<Blob> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
...(renderOptions ? { renderOptions } : {}),
|
||||
resolveSectionTitle,
|
||||
...(semanticRuntime ? { semanticRuntime } : {}),
|
||||
}) as Parameters<typeof pdf>[0];
|
||||
|
||||
return pdf(document).toBlob();
|
||||
};
|
||||
|
||||
export const createResumePdfBlobResult = async ({
|
||||
inspection,
|
||||
...options
|
||||
}: CreateResumePdfBlobResultOptions): Promise<ResumePdfRenderResult<Blob>> => {
|
||||
const normalizedOptions = { ...options, data: parseResumeData(options.data) };
|
||||
const resolvedInspection = inspection ?? inspectResumePdf(normalizedOptions);
|
||||
if (hasSemanticErrors(resolvedInspection)) {
|
||||
return { ok: false, diagnostics: resolvedInspection.diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: await renderResumePdfBlob(normalizedOptions),
|
||||
diagnostics: resolvedInspection.diagnostics,
|
||||
};
|
||||
};
|
||||
|
||||
export const createResumePdfBlob = async (options: CreateResumePdfBlobOptions): Promise<Blob> => {
|
||||
const result = await createResumePdfBlobResult(options);
|
||||
if (!result.ok) {
|
||||
throw new Error("The semantic stylesheet could not be rendered.", { cause: result.diagnostics });
|
||||
}
|
||||
return result.value;
|
||||
return await pdf(document).toBlob();
|
||||
};
|
||||
|
||||
@@ -54,7 +54,6 @@ export const buildAllTemplatesFixture = (template: Template) => {
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: comprehensiveStylesheet,
|
||||
applied: comprehensiveStylesheet,
|
||||
};
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ describe("Semantic CSS all-template presentation", () => {
|
||||
const runtime = resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied: comprehensiveStylesheet,
|
||||
source: comprehensiveStylesheet,
|
||||
mode: "semantic",
|
||||
});
|
||||
|
||||
@@ -34,7 +34,7 @@ describe("Semantic CSS all-template presentation", () => {
|
||||
const { sourceTree } = resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied: comprehensiveStylesheet,
|
||||
source: comprehensiveStylesheet,
|
||||
mode: "semantic",
|
||||
});
|
||||
const templateParts = new Set<string>();
|
||||
|
||||
@@ -54,7 +54,7 @@ const buildFixture = (template: Template, rule = ""): ResumeData => {
|
||||
: [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -88,7 +88,7 @@ const finalOnyxCompanyStyle = async (keyword?: "inherit" | "initial" | "revert"
|
||||
keyword ? `section[type="experience"] field[name="company"] { font-weight: ${keyword}; }` : ""
|
||||
}`;
|
||||
const stylesheet = { languageVersion: 1, text };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
|
||||
const instance = pdf(element);
|
||||
await expect.poll(() => instance.container.document).not.toBeNull();
|
||||
|
||||
@@ -97,7 +97,7 @@ const buildFixture = (
|
||||
data.metadata.page.hideIcons = false;
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${text}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ const fixture = (mode: "legacy" | "semantic", section: "experience" | "education
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [section], sidebar: [] }];
|
||||
if (mode === "semantic") {
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet };
|
||||
}
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -1,44 +1,9 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetMode, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ResolvedResumeRuntime } from "./resolve";
|
||||
import { resolveResumeRuntime, resolveStylesheetMode } from "./resolve";
|
||||
|
||||
export type InspectResumePdfOptions = {
|
||||
data: ResumeData;
|
||||
template?: Template | undefined;
|
||||
applied?: StylesheetSource | undefined;
|
||||
mode?: StylesheetMode | undefined;
|
||||
};
|
||||
|
||||
export type ResumePdfRenderResult<T> =
|
||||
| { ok: true; value: T; diagnostics: ResolvedResumeRuntime["diagnostics"] }
|
||||
| { ok: false; diagnostics: ResolvedResumeRuntime["diagnostics"] };
|
||||
|
||||
export const inspectResumePdf = ({
|
||||
data,
|
||||
template = data.metadata.template,
|
||||
applied,
|
||||
mode = resolveStylesheetMode(data),
|
||||
}: InspectResumePdfOptions): ResolvedResumeRuntime =>
|
||||
resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
mode,
|
||||
...(applied ? { applied } : {}),
|
||||
});
|
||||
|
||||
export const hasSemanticErrors = ({ diagnostics }: Pick<ResolvedResumeRuntime, "diagnostics">): boolean =>
|
||||
diagnostics.some(({ severity }) => severity === "error");
|
||||
|
||||
export type {
|
||||
ResolvedResumeRuntime,
|
||||
ResolveResumePresentationInput,
|
||||
} from "./resolve";
|
||||
export * from "./legacy-converter";
|
||||
export * from "./legacy-parity";
|
||||
export * from "./preflight-core";
|
||||
export * from "./public-projection";
|
||||
export {
|
||||
resolveResumePresentation,
|
||||
resolveResumeRuntime,
|
||||
|
||||
@@ -8,7 +8,7 @@ import { ResumeDocument } from "../document";
|
||||
import { semanticNodeKeys } from "./node-keys";
|
||||
import { resolveResumePresentation, resolveStylesheetMode } from "./resolve";
|
||||
|
||||
const applied = (text: string) => ({ languageVersion: 1, text });
|
||||
const source = (text: string) => ({ languageVersion: 1, text });
|
||||
|
||||
type HostNode = {
|
||||
type: string;
|
||||
@@ -90,7 +90,7 @@ const resolveIssueFixture = (text: string) => {
|
||||
return resolveResumePresentation({
|
||||
data,
|
||||
template: "onyx",
|
||||
applied: applied(text),
|
||||
source: source(text),
|
||||
mode: "semantic",
|
||||
});
|
||||
};
|
||||
@@ -138,7 +138,7 @@ describe("semantic issue fixtures", () => {
|
||||
resolveResumePresentation({
|
||||
data,
|
||||
template: "onyx",
|
||||
applied: applied(`@version 1;${text}`),
|
||||
source: source(`@version 1;${text}`),
|
||||
mode: "semantic",
|
||||
});
|
||||
|
||||
@@ -157,11 +157,11 @@ describe("semantic issue fixtures", () => {
|
||||
header { background-color: #1e293b; }
|
||||
name { color: white; }
|
||||
`);
|
||||
const invalid = compileStylesheet(applied("@version 1; header { background-image: linear-gradient(red, blue); }"));
|
||||
const invalid = compileStylesheet(source("@version 1; header { background-image: linear-gradient(red, blue); }"));
|
||||
|
||||
expect(valid[headerKey]?.style?.backgroundColor).toBe("#1e293b");
|
||||
expect(valid[semanticNodeKeys.headerPart(headerKey, "name")]?.style?.color).toBe("white");
|
||||
expect(invalid.program).toBeNull();
|
||||
expect(invalid.program).not.toBeNull();
|
||||
});
|
||||
|
||||
it("unbolds only skill names and leaves experience titles unchanged (#2223)", () => {
|
||||
@@ -180,8 +180,7 @@ describe("semantic issue fixtures", () => {
|
||||
const semanticData = buildIssueFixture();
|
||||
semanticData.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: applied("@version 1;"),
|
||||
applied: applied("@version 1;"),
|
||||
source: source("@version 1;"),
|
||||
};
|
||||
const legacyData = buildIssueFixture();
|
||||
|
||||
@@ -191,7 +190,7 @@ describe("semantic issue fixtures", () => {
|
||||
resolveResumePresentation({
|
||||
data: legacyData,
|
||||
template: "onyx",
|
||||
applied: applied("@version 1; name { color: red; }"),
|
||||
source: source("@version 1; name { color: red; }"),
|
||||
mode: "legacy",
|
||||
}),
|
||||
).toEqual({});
|
||||
@@ -199,7 +198,7 @@ describe("semantic issue fixtures", () => {
|
||||
|
||||
it("applies issue-regression styles to the final existing PDF primitives", async () => {
|
||||
const data = buildIssueFixture();
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@version 1;
|
||||
header { background-color: #1e293b; }
|
||||
name { color: white; }
|
||||
@@ -208,7 +207,7 @@ describe("semantic issue fixtures", () => {
|
||||
section[type="skills"] field[name="name"] { font-weight: 400; }
|
||||
level icon[role~="active"] { opacity: 0.2; }
|
||||
`);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
|
||||
const instance = pdf(element);
|
||||
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
|
||||
@@ -226,11 +225,11 @@ describe("semantic issue fixtures", () => {
|
||||
|
||||
it("unbolds only the final skill-name primitive and preserves the experience title weight (#2223)", async () => {
|
||||
const data = buildIssueFixture();
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@version 1;
|
||||
section[type="skills"] field[name="name"] { font-weight: 400; }
|
||||
`);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
|
||||
const instance = pdf(element);
|
||||
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
|
||||
@@ -253,12 +252,12 @@ describe("semantic issue fixtures", () => {
|
||||
keywords: [],
|
||||
}));
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
const stylesheet = applied(`
|
||||
const stylesheet = source(`
|
||||
@version 1;
|
||||
section[type="skills"] item:nth-child(2) { display: none; }
|
||||
section[type="skills"] item:last-child { order: -1; }
|
||||
`);
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
const element = createElement(ResumeDocument, { data, template: "onyx" }) as unknown as Parameters<typeof pdf>[0];
|
||||
const instance = pdf(element);
|
||||
await vi.waitFor(() => expect(instance.container.document).not.toBeNull());
|
||||
|
||||
@@ -286,7 +286,6 @@ export async function compareLegacySemanticPresentation(
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: input.convertedSource,
|
||||
applied: input.convertedSource,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -162,7 +162,7 @@ const semanticData = (data: ResumeData): ResumeData => {
|
||||
const conversion = convertLegacyStyleRules(data);
|
||||
const semantic = structuredClone(data);
|
||||
semantic.metadata.styleRules = [...conversion.sanitizedRules];
|
||||
semantic.metadata.stylesheet = { mode: "semantic", source: conversion.source, applied: conversion.source };
|
||||
semantic.metadata.stylesheet = { mode: "semantic", source: conversion.source };
|
||||
return semantic;
|
||||
};
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ const buildFixture = (
|
||||
|
||||
if (mode !== "missing") {
|
||||
const stylesheet = { languageVersion: 1, text };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode, source: stylesheet };
|
||||
}
|
||||
|
||||
return data;
|
||||
|
||||
@@ -53,7 +53,6 @@ const buildFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
data.metadata.stylesheet = {
|
||||
mode,
|
||||
source: semanticSource(),
|
||||
applied: semanticSource(),
|
||||
};
|
||||
}
|
||||
return data;
|
||||
|
||||
@@ -57,7 +57,7 @@ const buildFixture = (value: string): ResumeData => {
|
||||
languageVersion: 1,
|
||||
text: `@version 1; section[type="summary"] { break-before: ${value}; break-inside: ${value}; }`,
|
||||
};
|
||||
data.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -58,7 +58,7 @@ const parsePdf = (data: Uint8Array): Promise<ParsedPdf> => getDocument({ data })
|
||||
|
||||
const overflowingFixture = (pageSize: "A4" | "LETTER"): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -75,7 +75,7 @@ const overflowingFixture = (pageSize: "A4" | "LETTER"): ResumeData => {
|
||||
(_value, index) => `<p>Overflow line ${index + 1} with enough text to occupy the authored page.</p>`,
|
||||
).join("");
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
@@ -96,14 +96,14 @@ const readPhysicalPages = async (document: ParsedPdf) => {
|
||||
describe("semantic pagination bindings", () => {
|
||||
it("passes resolved authored-page size to the existing Page primitive", async () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: '@version 1;\npage[page-number="1"] { size: LETTER; }',
|
||||
};
|
||||
data.picture.hidden = true;
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
|
||||
const page = findFirst(await renderHostTree(data), "PAGE");
|
||||
|
||||
|
||||
@@ -1,204 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
|
||||
let renderPreflightPdf: typeof import("./preflight-core").renderPreflightPdf;
|
||||
|
||||
const rendererMock = vi.hoisted(() => ({
|
||||
pdf: vi.fn(() => ({
|
||||
toBlob: vi.fn(async () => new Blob(["%PDF-1.7"], { type: "application/pdf" })),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("#react-pdf-renderer", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("@react-pdf/renderer")>()),
|
||||
pdf: rendererMock.pdf,
|
||||
}));
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ renderPreflightPdf } = await import("./preflight-core"));
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("#react-pdf-renderer");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const validStylesheet = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;",
|
||||
} as const;
|
||||
|
||||
const pageLimits = {
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
} as const;
|
||||
|
||||
const createRendererUnsafeResumeData = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.customSections = [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||
} as never,
|
||||
];
|
||||
return data;
|
||||
};
|
||||
|
||||
const createLegacyRendererSafeResumeData = (): ResumeData =>
|
||||
({
|
||||
...structuredClone(defaultResumeData),
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [
|
||||
{
|
||||
id: "experience-item",
|
||||
hidden: false,
|
||||
company: "Analytical Engines",
|
||||
position: "Programmer",
|
||||
location: "London",
|
||||
period: "1842–1843",
|
||||
description: "<p>Wrote the first algorithm.</p>",
|
||||
content: "<p>Compatible overlap</p>",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}) as unknown as ResumeData;
|
||||
|
||||
describe("renderPreflightPdf", () => {
|
||||
beforeEach(() => {
|
||||
rendererMock.pdf.mockReset();
|
||||
rendererMock.pdf.mockImplementation(() => ({
|
||||
toBlob: vi.fn(async () => new Blob(["%PDF-1.7"], { type: "application/pdf" })),
|
||||
}));
|
||||
});
|
||||
|
||||
it("renders a valid semantic candidate to transferable PDF bytes", async () => {
|
||||
const result = await renderPreflightPdf(
|
||||
{
|
||||
data: createLegacyRendererSafeResumeData(),
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: validStylesheet,
|
||||
},
|
||||
pageLimits,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({ ok: true, diagnostics: [] });
|
||||
expect(result.ok && new TextDecoder().decode(result.bytes)).toBe("%PDF-1.7");
|
||||
expect(rendererMock.pdf).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
props: expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
customSections: [
|
||||
expect.objectContaining({
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
content: "<p>Compatible overlap</p>",
|
||||
roles: [],
|
||||
website: { url: "", label: "", inlineLink: false },
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns compiler diagnostics without starting the renderer", async () => {
|
||||
const result = await renderPreflightPdf(
|
||||
{
|
||||
data: defaultResumeData,
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: { languageVersion: 1, text: "@version 1; page { color: ; }" },
|
||||
},
|
||||
pageLimits,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_INVALID",
|
||||
diagnostics: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data before semantic inspection or React PDF dispatch", async () => {
|
||||
const result = renderPreflightPdf(
|
||||
{
|
||||
data: createRendererUnsafeResumeData(),
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: validStylesheet,
|
||||
},
|
||||
pageLimits,
|
||||
);
|
||||
|
||||
await expect(result).rejects.toMatchObject({
|
||||
name: "ZodError",
|
||||
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a stable public failure when React PDF throws", async () => {
|
||||
rendererMock.pdf.mockReturnValueOnce({
|
||||
toBlob: vi.fn(() => Promise.reject(new Error("sensitive renderer details"))),
|
||||
});
|
||||
|
||||
const result = await renderPreflightPdf(
|
||||
{
|
||||
data: defaultResumeData,
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: validStylesheet,
|
||||
},
|
||||
pageLimits,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||
message: "PDF rendering failed.",
|
||||
diagnostics: [],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["width", "2001pt 1000pt"],
|
||||
["height", "1000pt 20001pt"],
|
||||
["area", "1500pt 15000pt"],
|
||||
])("rejects authored page %s limits before starting the renderer", async (_limit, size) => {
|
||||
const result = await renderPreflightPdf(
|
||||
{
|
||||
data: defaultResumeData,
|
||||
template: defaultResumeData.metadata.template,
|
||||
stylesheet: { languageVersion: 1, text: `@version 1; page { size: ${size}; }` },
|
||||
},
|
||||
pageLimits,
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT",
|
||||
});
|
||||
expect(rendererMock.pdf).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,138 +0,0 @@
|
||||
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { PdfPreflightFailureCode } from "./preflight-reference";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { pdf } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "../document";
|
||||
import { getTemplatePageSize } from "../templates/shared/page-size";
|
||||
import { semanticNodeKeys } from "./node-keys";
|
||||
import { resolveResumeRuntime } from "./resolve";
|
||||
|
||||
export type { PdfPreflightFailureCode } from "./preflight-reference";
|
||||
|
||||
export type StylesheetPreflightInput = {
|
||||
data: ResumeData;
|
||||
template: Template;
|
||||
stylesheet: StylesheetSource;
|
||||
};
|
||||
|
||||
export type PdfPreflightPageLimits = {
|
||||
maxPageWidthPt: number;
|
||||
maxPageHeightPt: number;
|
||||
maxPageAreaPt2: number;
|
||||
};
|
||||
|
||||
export type PdfPreflightFailure = {
|
||||
ok: false;
|
||||
code: PdfPreflightFailureCode;
|
||||
message: string;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
};
|
||||
|
||||
export type PdfPreflightResult =
|
||||
| {
|
||||
ok: true;
|
||||
pageCount: number;
|
||||
byteCount: number;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
}
|
||||
| PdfPreflightFailure;
|
||||
|
||||
export type RenderPreflightPdfResult =
|
||||
| {
|
||||
ok: true;
|
||||
bytes: Uint8Array;
|
||||
diagnostics: readonly SemanticCssDiagnostic[];
|
||||
}
|
||||
| PdfPreflightFailure;
|
||||
|
||||
export type StylesheetPreflightRunner = {
|
||||
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult>;
|
||||
};
|
||||
|
||||
export type BrowserPdfPreflightResult =
|
||||
| (Extract<PdfPreflightResult, { ok: true }> & { pdf: ArrayBuffer })
|
||||
| PdfPreflightFailure;
|
||||
|
||||
const pageDimensions = (size: "A4" | "LETTER" | { width: number; height?: number }) => {
|
||||
if (size === "LETTER") return { width: 612, height: 792 };
|
||||
if (size === "A4") return { width: 595.28, height: 841.89 };
|
||||
return { width: size.width, height: size.height ?? 841.89 };
|
||||
};
|
||||
|
||||
const pageSizeFailure = (
|
||||
data: ResumeData,
|
||||
presentation: ReturnType<typeof resolveResumeRuntime>["presentation"],
|
||||
limits: PdfPreflightPageLimits,
|
||||
): PdfPreflightFailure | undefined => {
|
||||
const fallbackSize = getTemplatePageSize(data.metadata.page.format);
|
||||
|
||||
for (const index of data.metadata.layout.pages.keys()) {
|
||||
const size = presentation[semanticNodeKeys.page(index + 1)]?.size ?? fallbackSize;
|
||||
const { width, height } = pageDimensions(size);
|
||||
if (width > limits.maxPageWidthPt || height > limits.maxPageHeightPt || width * height > limits.maxPageAreaPt2) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT",
|
||||
message: "The authored page size exceeds the PDF preflight limit.",
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export async function renderPreflightPdf(
|
||||
input: StylesheetPreflightInput,
|
||||
pageLimits: PdfPreflightPageLimits,
|
||||
): Promise<RenderPreflightPdfResult> {
|
||||
const parsedData = parseResumeData(input.data);
|
||||
const data = {
|
||||
...parsedData,
|
||||
metadata: {
|
||||
...parsedData.metadata,
|
||||
stylesheet: {
|
||||
mode: "semantic" as const,
|
||||
source: input.stylesheet,
|
||||
applied: input.stylesheet,
|
||||
},
|
||||
},
|
||||
};
|
||||
const inspection = resolveResumeRuntime({
|
||||
data,
|
||||
template: input.template,
|
||||
applied: input.stylesheet,
|
||||
mode: "semantic",
|
||||
});
|
||||
|
||||
if (inspection.diagnostics.some(({ severity }) => severity === "error")) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_INVALID",
|
||||
message: "The stylesheet cannot be rendered.",
|
||||
diagnostics: inspection.diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
const pageFailure = pageSizeFailure(data, inspection.presentation, pageLimits);
|
||||
if (pageFailure) return { ...pageFailure, diagnostics: inspection.diagnostics };
|
||||
|
||||
try {
|
||||
const document = createElement(ResumeDocument, { data, template: input.template }) as Parameters<typeof pdf>[0];
|
||||
const blob = await pdf(document).toBlob();
|
||||
return {
|
||||
ok: true,
|
||||
bytes: new Uint8Array(await blob.arrayBuffer()),
|
||||
diagnostics: inspection.diagnostics,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||
message: "PDF rendering failed.",
|
||||
diagnostics: inspection.diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
export const PDF_PREFLIGHT_DIAGNOSTIC_CATALOG = {
|
||||
STYLESHEET_PREFLIGHT_INVALID: {
|
||||
meaning: "The stylesheet has compiler or semantic errors.",
|
||||
action: "Fix the accompanying Semantic CSS diagnostics.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_PAGE_SIZE_LIMIT: {
|
||||
meaning: "An authored page exceeds the PDF dimension or area budget.",
|
||||
action: "Use a smaller page size.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_BYTE_LIMIT: {
|
||||
meaning: "The rendered PDF exceeds the byte budget.",
|
||||
action: "Reduce pages, images, or styled content.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_PAGE_LIMIT: {
|
||||
meaning: "The rendered PDF exceeds the page-count budget.",
|
||||
action: "Reduce content or pagination.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_TIMEOUT: {
|
||||
meaning: "PDF preflight exceeded its deadline.",
|
||||
action: "Reduce stylesheet or document complexity and retry.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_MEMORY_LIMIT: {
|
||||
meaning: "PDF preflight exceeded its memory budget.",
|
||||
action: "Reduce document, image, or layout complexity.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_RENDER_FAILED: {
|
||||
meaning: "The PDF renderer could not render the candidate stylesheet.",
|
||||
action: "Simplify the candidate and inspect accompanying diagnostics.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_PARSE_FAILED: {
|
||||
meaning: "The rendered PDF could not be inspected.",
|
||||
action: "Retry after simplifying the candidate.",
|
||||
},
|
||||
STYLESHEET_PREFLIGHT_WORKER_FAILED: {
|
||||
meaning: "The isolated PDF preflight worker failed or its queue was full.",
|
||||
action: "Retry; simplify the candidate if the failure repeats.",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type PdfPreflightFailureCode = keyof typeof PDF_PREFLIGHT_DIAGNOSTIC_CATALOG;
|
||||
|
||||
export const STYLESHEET_PREFLIGHT_LIMITS = Object.freeze({
|
||||
// Render deadline (after the worker is warm). A rich resume on a throttled/shared
|
||||
// vCPU renders in ~5-18s, so 5s spuriously failed real resumes; the worker is now
|
||||
// warmed+reused so this ceiling only bounds a genuinely stuck render.
|
||||
timeoutMs: 30_000,
|
||||
maxPages: 20,
|
||||
maxBytes: 10_000_000,
|
||||
maxPageWidthPt: 2_000,
|
||||
maxPageHeightPt: 20_000,
|
||||
maxPageAreaPt2: 20_000_000,
|
||||
maxOldGenerationMb: 256,
|
||||
maxConcurrentWorkers: 1,
|
||||
maxQueuedRequests: 32,
|
||||
});
|
||||
@@ -1,135 +0,0 @@
|
||||
import type { PublicStyleProjection } from "./public-projection";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import {
|
||||
createPublicStyleProjection,
|
||||
PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
|
||||
SEMANTIC_TREE_VERSION,
|
||||
validatePublicStyleProjection,
|
||||
} from "./public-projection";
|
||||
|
||||
const buildData = () => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nname { color: #123456; }\n",
|
||||
};
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
return data;
|
||||
};
|
||||
|
||||
describe("public semantic style projection", () => {
|
||||
it("contains only resolved, JSON-safe presentation keyed by stable node key", async () => {
|
||||
const projection = await createPublicStyleProjection({ data: buildData() });
|
||||
const serialized = JSON.stringify(projection);
|
||||
|
||||
expect(projection).toMatchObject({
|
||||
formatVersion: PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
|
||||
languageVersion: 1,
|
||||
semanticTreeVersion: SEMANTIC_TREE_VERSION,
|
||||
registryFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
adapterFingerprint: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
renderDataHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(projection.nodes["page-1/region-header/header/name"]).toEqual({
|
||||
style: { color: "#123456" },
|
||||
});
|
||||
expect(serialized).not.toContain("@version");
|
||||
expect(serialized).not.toMatch(/source|comment|diagnostic|selector|variable|range/i);
|
||||
expect(serialized).not.toContain("undefined");
|
||||
});
|
||||
|
||||
it("carries final sibling visibility and order without stylesheet source", async () => {
|
||||
const data = buildData();
|
||||
data.basics.email = "ada@example.com";
|
||||
data.basics.phone = "+44 123";
|
||||
data.basics.location = "London";
|
||||
const applied = {
|
||||
languageVersion: 1,
|
||||
text: `@version 1;
|
||||
contact-item[name="location"] { display: none; }
|
||||
contact-item[name="phone"] { order: -1; }
|
||||
`,
|
||||
};
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
|
||||
const projection = await createPublicStyleProjection({ data });
|
||||
|
||||
expect(projection.nodes["page-1/region-header/header/contact-list/contact-location"]).toMatchObject({
|
||||
hidden: true,
|
||||
});
|
||||
expect(projection.nodes["page-1/region-header/header/contact-list/contact-phone"]).toMatchObject({ order: 0 });
|
||||
expect(projection.nodes["page-1/region-header/header/contact-list/contact-email"]).toMatchObject({ order: 1 });
|
||||
});
|
||||
|
||||
it("rejects changed nodes and every version or fingerprint mismatch", async () => {
|
||||
const data = buildData();
|
||||
const valid = await createPublicStyleProjection({ data });
|
||||
const cases = [
|
||||
{ ...valid, formatVersion: 2 },
|
||||
{ ...valid, languageVersion: 2 },
|
||||
{ ...valid, semanticTreeVersion: 2 },
|
||||
{ ...valid, registryFingerprint: "0".repeat(64) },
|
||||
{ ...valid, adapterFingerprint: "0".repeat(64) },
|
||||
{ ...valid, renderDataHash: "0".repeat(64) },
|
||||
{
|
||||
...valid,
|
||||
nodes: {
|
||||
...valid.nodes,
|
||||
"page-1/region-header/header/name": { style: { color: "#ff0000" } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
for (const projection of cases) {
|
||||
await expect(validatePublicStyleProjection(data, projection as unknown as PublicStyleProjection)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects projections hashed for different public render data", async () => {
|
||||
const data = buildData();
|
||||
const projection = await createPublicStyleProjection({ data });
|
||||
const changed = structuredClone(data);
|
||||
changed.basics.name = "Grace Hopper";
|
||||
|
||||
await expect(validatePublicStyleProjection(changed, projection)).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("changes the projection hash when only the applied presentation changes", async () => {
|
||||
const red = buildData();
|
||||
const blue = buildData();
|
||||
const blueApplied = {
|
||||
languageVersion: 1,
|
||||
text: "@version 1;\nname { color: #654321; }\n",
|
||||
};
|
||||
blue.metadata.stylesheet = { mode: "semantic", source: blueApplied, applied: blueApplied };
|
||||
|
||||
const redProjection = await createPublicStyleProjection({ data: red });
|
||||
const blueProjection = await createPublicStyleProjection({ data: blue });
|
||||
|
||||
expect(redProjection.nodes["page-1/region-header/header/name"]).not.toEqual(
|
||||
blueProjection.nodes["page-1/region-header/header/name"],
|
||||
);
|
||||
expect(redProjection.renderDataHash).not.toBe(blueProjection.renderDataHash);
|
||||
});
|
||||
|
||||
it("rejects extra or non-JSON node fields instead of exposing compiler internals", async () => {
|
||||
const data = buildData();
|
||||
const projection = await createPublicStyleProjection({ data });
|
||||
const node = projection.nodes["page-1/region-header/header/name"];
|
||||
|
||||
await expect(
|
||||
validatePublicStyleProjection(data, {
|
||||
...projection,
|
||||
nodes: {
|
||||
...projection.nodes,
|
||||
"page-1/region-header/header/name": { ...node, diagnostics: [{ message: "private" }] },
|
||||
},
|
||||
} as unknown as PublicStyleProjection),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,369 +0,0 @@
|
||||
import type { SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { ResolvedPdfNodePresentation } from "./adapter";
|
||||
import type { ResolvedResumeRuntime } from "./resolve";
|
||||
import {
|
||||
computeRenderDataHash,
|
||||
PROPERTY_REGISTRY_V1,
|
||||
projectPublicRenderData,
|
||||
SEMANTIC_REGISTRY_V1,
|
||||
SUPPORTED_SEMANTIC_CSS_VERSIONS,
|
||||
TEMPLATE_PART_CHILD_KINDS_V1,
|
||||
} from "@reactive-resume/resume/stylesheet";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { resolveResumeRuntime, resolveStylesheetMode } from "./resolve";
|
||||
import { getTemplateSemanticRegistryFingerprintInput } from "./template-manifest";
|
||||
|
||||
export const PUBLIC_STYLE_PROJECTION_FORMAT_VERSION = 1;
|
||||
export const SEMANTIC_TREE_VERSION = 1;
|
||||
const PDF_ADAPTER_VERSION = 1;
|
||||
const REACT_PDF_RENDERER_VERSION = "4.5";
|
||||
|
||||
type PublicPdfStyleValue = string | number | null;
|
||||
type PublicPdfPageSize = "A4" | "LETTER" | { width: number; height?: number };
|
||||
|
||||
export type PublicPdfNodePresentation = {
|
||||
style?: Readonly<Record<string, PublicPdfStyleValue>>;
|
||||
size?: PublicPdfPageSize;
|
||||
break?: boolean;
|
||||
wrap?: boolean;
|
||||
fixed?: boolean;
|
||||
minPresenceAhead?: number;
|
||||
orphans?: number;
|
||||
widows?: number;
|
||||
hidden?: boolean;
|
||||
order?: number;
|
||||
};
|
||||
|
||||
export type PublicStyleProjection = {
|
||||
formatVersion: typeof PUBLIC_STYLE_PROJECTION_FORMAT_VERSION;
|
||||
languageVersion: number;
|
||||
semanticTreeVersion: typeof SEMANTIC_TREE_VERSION;
|
||||
registryFingerprint: string;
|
||||
adapterFingerprint: string;
|
||||
renderDataHash: string;
|
||||
nodes: Readonly<Record<string, PublicPdfNodePresentation>>;
|
||||
};
|
||||
|
||||
type ProjectionFingerprints = Pick<
|
||||
PublicStyleProjection,
|
||||
"formatVersion" | "languageVersion" | "semanticTreeVersion" | "registryFingerprint" | "adapterFingerprint"
|
||||
>;
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
return prototype === Object.prototype || prototype === null;
|
||||
};
|
||||
|
||||
const hasExactKeys = (value: Record<string, unknown>, allowed: readonly string[]): boolean =>
|
||||
Object.keys(value).every((key) => allowed.includes(key)) && Object.getOwnPropertySymbols(value).length === 0;
|
||||
|
||||
const finiteNumber = (value: unknown): value is number => typeof value === "number" && Number.isFinite(value);
|
||||
|
||||
const isPageSize = (value: unknown): value is PublicPdfPageSize => {
|
||||
if (value === "A4" || value === "LETTER") return true;
|
||||
if (!isPlainObject(value) || !hasExactKeys(value, ["width", "height"]) || !finiteNumber(value.width)) return false;
|
||||
return value.height === undefined || finiteNumber(value.height);
|
||||
};
|
||||
|
||||
const isPublicNode = (value: unknown): value is PublicPdfNodePresentation => {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
!hasExactKeys(value, [
|
||||
"style",
|
||||
"size",
|
||||
"break",
|
||||
"wrap",
|
||||
"fixed",
|
||||
"minPresenceAhead",
|
||||
"orphans",
|
||||
"widows",
|
||||
"hidden",
|
||||
"order",
|
||||
])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (value.style !== undefined) {
|
||||
if (!isPlainObject(value.style)) return false;
|
||||
for (const styleValue of Object.values(value.style)) {
|
||||
if (styleValue !== null && typeof styleValue !== "string" && !finiteNumber(styleValue)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value.size !== undefined && !isPageSize(value.size)) return false;
|
||||
for (const key of ["break", "wrap", "fixed"] as const) {
|
||||
if (value[key] !== undefined && typeof value[key] !== "boolean") return false;
|
||||
}
|
||||
for (const key of ["minPresenceAhead", "orphans", "widows"] as const) {
|
||||
if (value[key] !== undefined && !finiteNumber(value[key])) return false;
|
||||
}
|
||||
if (value.hidden !== undefined && typeof value.hidden !== "boolean") return false;
|
||||
if (value.order !== undefined && (!Number.isInteger(value.order) || (value.order as number) < 0)) return false;
|
||||
return true;
|
||||
};
|
||||
|
||||
const isProjectionShape = (value: unknown): value is PublicStyleProjection => {
|
||||
if (
|
||||
!isPlainObject(value) ||
|
||||
!hasExactKeys(value, [
|
||||
"formatVersion",
|
||||
"languageVersion",
|
||||
"semanticTreeVersion",
|
||||
"registryFingerprint",
|
||||
"adapterFingerprint",
|
||||
"renderDataHash",
|
||||
"nodes",
|
||||
]) ||
|
||||
!Number.isInteger(value.formatVersion) ||
|
||||
!Number.isInteger(value.languageVersion) ||
|
||||
!Number.isInteger(value.semanticTreeVersion) ||
|
||||
typeof value.registryFingerprint !== "string" ||
|
||||
typeof value.adapterFingerprint !== "string" ||
|
||||
typeof value.renderDataHash !== "string" ||
|
||||
!isPlainObject(value.nodes)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.values(value.nodes).every(isPublicNode);
|
||||
};
|
||||
|
||||
type PublicNodeStructure = Pick<PublicPdfNodePresentation, "hidden" | "order">;
|
||||
|
||||
const toPublicNode = (
|
||||
presentation: ResolvedPdfNodePresentation,
|
||||
structure: PublicNodeStructure,
|
||||
): PublicPdfNodePresentation => ({
|
||||
...(presentation.style
|
||||
? {
|
||||
style: Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(presentation.style).map(([property, value]) => [
|
||||
property,
|
||||
value === undefined ? null : (value as string | number),
|
||||
]),
|
||||
),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(presentation.size === undefined ? {} : { size: presentation.size }),
|
||||
...(presentation.break === undefined ? {} : { break: presentation.break }),
|
||||
...(presentation.wrap === undefined ? {} : { wrap: presentation.wrap }),
|
||||
...(presentation.fixed === undefined ? {} : { fixed: presentation.fixed }),
|
||||
...(presentation.minPresenceAhead === undefined ? {} : { minPresenceAhead: presentation.minPresenceAhead }),
|
||||
...(presentation.orphans === undefined ? {} : { orphans: presentation.orphans }),
|
||||
...(presentation.widows === undefined ? {} : { widows: presentation.widows }),
|
||||
...(structure.hidden === undefined ? {} : { hidden: structure.hidden }),
|
||||
...(structure.order === undefined ? {} : { order: structure.order }),
|
||||
});
|
||||
|
||||
const indexChildren = (tree: SemanticNode) => {
|
||||
const children = new Map<string, readonly string[]>();
|
||||
const visit = (node: SemanticNode) => {
|
||||
children.set(
|
||||
node.key,
|
||||
node.children.map(({ key }) => key),
|
||||
);
|
||||
for (const child of node.children) visit(child);
|
||||
};
|
||||
visit(tree);
|
||||
return children;
|
||||
};
|
||||
|
||||
const projectNodeStructure = (
|
||||
sourceTree: SemanticNode,
|
||||
renderTree: SemanticNode,
|
||||
): Readonly<Record<string, PublicNodeStructure>> => {
|
||||
const renderedChildren = indexChildren(renderTree);
|
||||
const structure: Record<string, PublicNodeStructure> = {};
|
||||
const visit = (node: SemanticNode) => {
|
||||
const rendered = renderedChildren.get(node.key) ?? [];
|
||||
const order = new Map(rendered.map((key, index) => [key, index]));
|
||||
for (const [sourceIndex, child] of node.children.entries()) {
|
||||
const renderedIndex = order.get(child.key);
|
||||
structure[child.key] =
|
||||
renderedIndex === undefined ? { hidden: true } : renderedIndex === sourceIndex ? {} : { order: renderedIndex };
|
||||
visit(child);
|
||||
}
|
||||
};
|
||||
visit(sourceTree);
|
||||
return structure;
|
||||
};
|
||||
|
||||
const fingerprints = Promise.all([
|
||||
computeRenderDataHash({
|
||||
domainVersion: 1,
|
||||
data: {
|
||||
semanticTreeVersion: SEMANTIC_TREE_VERSION,
|
||||
semanticRegistry: SEMANTIC_REGISTRY_V1,
|
||||
templateParts: TEMPLATE_PART_CHILD_KINDS_V1,
|
||||
templateManifests: getTemplateSemanticRegistryFingerprintInput(),
|
||||
},
|
||||
}),
|
||||
computeRenderDataHash({
|
||||
domainVersion: 1,
|
||||
data: {
|
||||
adapterVersion: PDF_ADAPTER_VERSION,
|
||||
reactPdfRendererVersion: REACT_PDF_RENDERER_VERSION,
|
||||
propertyRegistry: PROPERTY_REGISTRY_V1,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const getFingerprints = async () => {
|
||||
const [registryFingerprint, adapterFingerprint] = await fingerprints;
|
||||
return { registryFingerprint, adapterFingerprint };
|
||||
};
|
||||
|
||||
export const getPublicStyleProjectionFingerprints = getFingerprints;
|
||||
|
||||
const dataForPublicProjection = (data: ResumeData, languageVersion: number): ResumeData => {
|
||||
if (data.metadata.stylesheet?.mode === "semantic") return data;
|
||||
const source = { languageVersion, text: EMPTY_SEMANTIC_CSS_SOURCE };
|
||||
return {
|
||||
...data,
|
||||
metadata: {
|
||||
...data.metadata,
|
||||
stylesheet: { mode: "semantic", source, applied: source },
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
const projectionFingerprints = async (data: ResumeData, languageVersion?: number): Promise<ProjectionFingerprints> => ({
|
||||
formatVersion: PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
|
||||
languageVersion:
|
||||
languageVersion ??
|
||||
(data.metadata.stylesheet?.mode === "semantic" ? data.metadata.stylesheet.applied.languageVersion : 1),
|
||||
semanticTreeVersion: SEMANTIC_TREE_VERSION,
|
||||
...(await getFingerprints()),
|
||||
});
|
||||
|
||||
const hashProjection = (
|
||||
data: ResumeData,
|
||||
nodes: PublicStyleProjection["nodes"],
|
||||
projection: ProjectionFingerprints,
|
||||
): Promise<string> =>
|
||||
computeRenderDataHash({
|
||||
domainVersion: 1,
|
||||
data: projectPublicRenderData(data),
|
||||
resolvedNodes: nodes,
|
||||
projectionFingerprints: projection,
|
||||
});
|
||||
|
||||
export async function createPublicStyleProjection(input: { data: ResumeData }): Promise<PublicStyleProjection> {
|
||||
const runtime = resolveResumeRuntime({
|
||||
data: input.data,
|
||||
template: input.data.metadata.template,
|
||||
mode: resolveStylesheetMode(input.data),
|
||||
});
|
||||
if (runtime.diagnostics.some(({ severity }) => severity === "error")) {
|
||||
throw new Error("Applied semantic stylesheet cannot be projected");
|
||||
}
|
||||
|
||||
const structure = projectNodeStructure(runtime.sourceTree, runtime.renderTree);
|
||||
const nodes = Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(runtime.presentation).map(([nodeKey, presentation]) => [
|
||||
nodeKey,
|
||||
toPublicNode(presentation, structure[nodeKey] ?? {}),
|
||||
]),
|
||||
),
|
||||
);
|
||||
const projection = await projectionFingerprints(input.data);
|
||||
return Object.freeze({
|
||||
...projection,
|
||||
renderDataHash: await hashProjection(input.data, nodes, projection),
|
||||
nodes,
|
||||
});
|
||||
}
|
||||
|
||||
export async function validatePublicStyleProjection(
|
||||
data: ResumeData,
|
||||
projection: PublicStyleProjection,
|
||||
): Promise<boolean> {
|
||||
if (!isProjectionShape(projection)) return false;
|
||||
if (!SUPPORTED_SEMANTIC_CSS_VERSIONS.includes(projection.languageVersion as 1)) return false;
|
||||
if (
|
||||
data.metadata.stylesheet?.mode === "semantic" &&
|
||||
projection.languageVersion !== data.metadata.stylesheet.applied.languageVersion
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const projectionData = dataForPublicProjection(data, projection.languageVersion);
|
||||
const expected = await projectionFingerprints(projectionData, projection.languageVersion);
|
||||
if (
|
||||
projection.formatVersion !== expected.formatVersion ||
|
||||
projection.languageVersion !== expected.languageVersion ||
|
||||
projection.semanticTreeVersion !== expected.semanticTreeVersion ||
|
||||
projection.registryFingerprint !== expected.registryFingerprint ||
|
||||
projection.adapterFingerprint !== expected.adapterFingerprint
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return projection.renderDataHash === (await hashProjection(projectionData, projection.nodes, expected));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
const toResolvedPresentation = (
|
||||
nodes: PublicStyleProjection["nodes"],
|
||||
): Readonly<Record<string, ResolvedPdfNodePresentation>> =>
|
||||
Object.freeze(
|
||||
Object.fromEntries(
|
||||
Object.entries(nodes).map(([nodeKey, { hidden: _hidden, order: _order, style, ...presentation }]) => [
|
||||
nodeKey,
|
||||
{
|
||||
...presentation,
|
||||
...(style
|
||||
? {
|
||||
style: Object.fromEntries(
|
||||
Object.entries(style).map(([property, value]) => [property, value === null ? undefined : value]),
|
||||
) as NonNullable<ResolvedPdfNodePresentation["style"]>,
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
const applyProjectedStructure = (node: SemanticNode, nodes: PublicStyleProjection["nodes"]): SemanticNode => ({
|
||||
...node,
|
||||
attributes: { ...node.attributes },
|
||||
roles: [...node.roles],
|
||||
children: node.children
|
||||
.map((child, sourceIndex) => ({ child, sourceIndex, structure: nodes[child.key] }))
|
||||
.filter(({ structure }) => !structure?.hidden)
|
||||
.sort(
|
||||
(left, right) =>
|
||||
(left.structure?.order ?? left.sourceIndex) - (right.structure?.order ?? right.sourceIndex) ||
|
||||
left.sourceIndex - right.sourceIndex,
|
||||
)
|
||||
.map(({ child }) => applyProjectedStructure(child, nodes)),
|
||||
});
|
||||
|
||||
export async function resolvePublicStyleProjectionRuntime(
|
||||
data: ResumeData,
|
||||
projection: PublicStyleProjection,
|
||||
): Promise<ResolvedResumeRuntime> {
|
||||
if (!(await validatePublicStyleProjection(data, projection))) {
|
||||
throw new Error("Public style projection does not match the resume render data");
|
||||
}
|
||||
const projectionData = dataForPublicProjection(data, projection.languageVersion);
|
||||
const base = resolveResumeRuntime({
|
||||
data: projectionData,
|
||||
template: projectionData.metadata.template,
|
||||
mode: "legacy",
|
||||
});
|
||||
return {
|
||||
presentation: toResolvedPresentation(projection.nodes),
|
||||
sourceTree: base.sourceTree,
|
||||
renderTree: applyProjectedStructure(base.sourceTree, projection.nodes),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
@@ -1,16 +1,52 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { inspectResumePdf } from "@reactive-resume/pdf/semantic";
|
||||
import { resolveResumeRuntime } from "@reactive-resume/pdf/semantic";
|
||||
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||
|
||||
describe("@reactive-resume/pdf/semantic", () => {
|
||||
it("publicly exposes invalid applied-source diagnostics", () => {
|
||||
it("falls back to base presentation and preserves fatal source diagnostics", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
|
||||
const inspection = inspectResumePdf({ data });
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.diagnostics).toContainEqual(expect.objectContaining({ severity: "error" }));
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "UNSUPPORTED_VERSION", severity: "error" }),
|
||||
);
|
||||
expect(inspection.presentation).toEqual({});
|
||||
});
|
||||
|
||||
it("falls back to base presentation when a selector list exceeds the resource limit", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const selectors = new Array(65).fill("section").join(",");
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: `@version 1;\n${selectors} { color: red; }` },
|
||||
};
|
||||
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }),
|
||||
);
|
||||
expect(inspection.presentation).toEqual({});
|
||||
expect(inspection.renderTree).toEqual(inspection.sourceTree);
|
||||
});
|
||||
|
||||
it("keeps valid PDF presentation when a neighboring value is recoverable", () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
data.metadata.stylesheet = {
|
||||
mode: "semantic",
|
||||
source: {
|
||||
languageVersion: 1,
|
||||
text: "@version 1; name { color: #123456; opacity: var(--missing); }",
|
||||
},
|
||||
};
|
||||
|
||||
const inspection = resolveResumeRuntime({ data, template: data.metadata.template, mode: "semantic" });
|
||||
|
||||
expect(inspection.presentation["page-1/region-header/header/name"]?.style?.color).toBe("#123456");
|
||||
expect(inspection.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { StylesheetMode, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { ResolvedResumePresentation } from "./context";
|
||||
import { compileStylesheet, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { compileStylesheet, isFatalStylesheetDiagnostic, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||
import { EMPTY_SEMANTIC_CSS_SOURCE } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import { shouldShowResumeHeader } from "../templates/shared/cover-letter";
|
||||
import { getTemplatePageSize } from "../templates/shared/page-size";
|
||||
@@ -17,7 +17,7 @@ import { buildSemanticTree } from "./tree";
|
||||
export type ResolveResumePresentationInput = {
|
||||
data: ResumeData;
|
||||
template: Template;
|
||||
applied?: StylesheetSource;
|
||||
source?: StylesheetSource;
|
||||
mode: StylesheetMode;
|
||||
};
|
||||
|
||||
@@ -85,7 +85,7 @@ export function resolveStylesheetMode(data: ResumeData): StylesheetMode {
|
||||
export function resolveResumeRuntime({
|
||||
data,
|
||||
template,
|
||||
applied,
|
||||
source,
|
||||
mode,
|
||||
}: ResolveResumePresentationInput): ResolvedResumeRuntime {
|
||||
const sourceTree = mergeAuthoredPageTrees(data, template);
|
||||
@@ -93,12 +93,12 @@ export function resolveResumeRuntime({
|
||||
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: [] };
|
||||
}
|
||||
|
||||
const source = applied ??
|
||||
data.metadata.stylesheet?.applied ?? {
|
||||
const stylesheetSource = source ??
|
||||
data.metadata.stylesheet?.source ?? {
|
||||
languageVersion: 1,
|
||||
text: EMPTY_SEMANTIC_CSS_SOURCE,
|
||||
};
|
||||
const compiled = compileStylesheet(source);
|
||||
const compiled = compileStylesheet(stylesheetSource);
|
||||
if (!compiled.program) {
|
||||
return { presentation: EMPTY_PRESENTATION, sourceTree, renderTree: sourceTree, diagnostics: compiled.diagnostics };
|
||||
}
|
||||
@@ -123,7 +123,7 @@ export function resolveResumeRuntime({
|
||||
pages: authoredPageDimensions(data),
|
||||
aliases,
|
||||
});
|
||||
if (resolved.diagnostics.some(({ severity }) => severity === "error")) {
|
||||
if (resolved.diagnostics.some(isFatalStylesheetDiagnostic)) {
|
||||
return {
|
||||
presentation: EMPTY_PRESENTATION,
|
||||
sourceTree,
|
||||
|
||||
@@ -47,7 +47,7 @@ const findSemanticNode = (
|
||||
|
||||
const buildFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -60,7 +60,7 @@ const buildFixture = (): ResumeData => {
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.summary.content = "<p>First <strong>bold</strong></p><ul><li>Item</li></ul>";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["summary"], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ const findFirst = (node: HostNode, predicate: (candidate: HostNode) => boolean):
|
||||
|
||||
const buildFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = {
|
||||
const source = {
|
||||
languageVersion: 1,
|
||||
text: `
|
||||
@version 1;
|
||||
@@ -50,15 +50,15 @@ const buildFixture = (): ResumeData => {
|
||||
data.picture.hidden = true;
|
||||
data.basics.name = "Ada Lovelace";
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||
data.metadata.stylesheet = { mode: "semantic", source };
|
||||
return data;
|
||||
};
|
||||
|
||||
const buildNodeBudgetFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
const applied = { languageVersion: 1, text: "@version 1;\n" };
|
||||
const source = { languageVersion: 1, text: "@version 1;\n" };
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: ["skills"], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode, source: applied, applied };
|
||||
data.metadata.stylesheet = { mode, source };
|
||||
data.sections.skills.items = Array.from({ length: 2_000 }, (_, index) => ({
|
||||
id: `skill-${index}`,
|
||||
hidden: false,
|
||||
@@ -72,6 +72,13 @@ const buildNodeBudgetFixture = (mode: "legacy" | "semantic"): ResumeData => {
|
||||
return data;
|
||||
};
|
||||
|
||||
const buildFatalSourceFixture = (): ResumeData => {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.layout.pages = [{ fullWidth: true, main: [], sidebar: [] }];
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
return data;
|
||||
};
|
||||
|
||||
const renderFinalProps = async (element: unknown) => {
|
||||
const renderer = await vi.importActual<typeof import("@react-pdf/renderer")>("@react-pdf/renderer");
|
||||
const instance = renderer.pdf(element as Parameters<typeof renderer.pdf>[0]);
|
||||
@@ -100,28 +107,15 @@ describe("browser/server semantic runtime identity", () => {
|
||||
expect(browserProps.fixed).toMatchObject({ type: "VIEW", fixed: true });
|
||||
}, 30_000);
|
||||
|
||||
it("rejects a valid stylesheet when later content exceeds the Semantic CSS node budget", async () => {
|
||||
const data = buildNodeBudgetFixture("semantic");
|
||||
it("renders browser and server PDFs with base styles when the stylesheet is fatal", async () => {
|
||||
const data = buildFatalSourceFixture();
|
||||
|
||||
const results = await Promise.allSettled([
|
||||
createResumePdfBlob({ data, template: "onyx" }),
|
||||
createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" }),
|
||||
]);
|
||||
const blob = await createResumePdfBlob({ data, template: "onyx" });
|
||||
const file = await createResumePdfFile({ data, filename: "resume.pdf", template: "onyx" });
|
||||
|
||||
expect(results).toEqual([
|
||||
expect.objectContaining({
|
||||
status: "rejected",
|
||||
reason: expect.objectContaining({
|
||||
cause: expect.arrayContaining([expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })]),
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
status: "rejected",
|
||||
reason: expect.objectContaining({
|
||||
cause: expect.arrayContaining([expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" })]),
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
expect(blob.type).toBe("application/pdf");
|
||||
expect(file.type).toBe("application/pdf");
|
||||
expect(await renderFinalProps(captured.browser)).toEqual(await renderFinalProps(captured.server));
|
||||
}, 15_000);
|
||||
|
||||
it("keeps legacy PDF rendering unaffected by the semantic node budget", async () => {
|
||||
|
||||
@@ -49,7 +49,7 @@ const semanticFixture = (rule: string): ResumeData => {
|
||||
customFields: [],
|
||||
};
|
||||
const stylesheet = { languageVersion: 1, text: `@version 1; ${rule}` };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet, applied: stylesheet };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: stylesheet };
|
||||
return data;
|
||||
};
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import { createBindingInventory } from "./binding-inventory";
|
||||
import {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "./template-manifest";
|
||||
import { buildSemanticTree } from "./tree";
|
||||
@@ -423,8 +422,6 @@ const EXPECTED_LAYOUT = {
|
||||
} as const satisfies Readonly<Record<Template, Omit<TemplateSemanticManifest, "template" | "parts">>>;
|
||||
|
||||
const flattenTree = (node: SemanticNode): SemanticNode[] => [node, ...node.children.flatMap(flattenTree)];
|
||||
const flattenValues = (value: unknown): unknown[] =>
|
||||
typeof value === "object" && value !== null ? [value, ...Object.values(value).flatMap(flattenValues)] : [value];
|
||||
const findNodes = (node: SemanticNode, predicate: (candidate: SemanticNode) => boolean): SemanticNode[] =>
|
||||
flattenTree(node).filter(predicate);
|
||||
const findPart = (node: SemanticNode, name: string): SemanticNode | undefined =>
|
||||
@@ -488,7 +485,8 @@ describe("template semantic manifests", () => {
|
||||
});
|
||||
|
||||
it("registers child kinds for every primitive template part", () => {
|
||||
for (const manifest of Object.values(getTemplateSemanticRegistryFingerprintInput())) {
|
||||
for (const template of templateSchema.options) {
|
||||
const manifest = getTemplateSemanticManifest(template);
|
||||
for (const part of manifest.parts) {
|
||||
if (part.binding.type === "alias") continue;
|
||||
expect(TEMPLATE_PART_CHILD_KINDS_V1, `${manifest.template}:${part.name}`).toHaveProperty(part.name);
|
||||
@@ -1315,22 +1313,6 @@ describe("template semantic manifests", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("publishes stable, deterministic, deeply frozen fingerprint input without functions", () => {
|
||||
const first = getTemplateSemanticRegistryFingerprintInput();
|
||||
const second = getTemplateSemanticRegistryFingerprintInput();
|
||||
const serialized = JSON.stringify(first);
|
||||
|
||||
expect(second).toBe(first);
|
||||
expect(JSON.stringify(second)).toBe(serialized);
|
||||
expect(Object.isFrozen(first)).toBe(true);
|
||||
expect(Object.isFrozen(first.azurill.parts)).toBe(true);
|
||||
expect(flattenValues(first).every((value) => typeof value !== "function")).toBe(true);
|
||||
expect(() => {
|
||||
(first.azurill.parts as unknown as object[]).pop();
|
||||
}).toThrow();
|
||||
expect(JSON.stringify(getTemplateSemanticRegistryFingerprintInput())).toBe(serialized);
|
||||
});
|
||||
|
||||
it.each(templateSchema.options)(
|
||||
"%s binds every manifest node to existing chrome without synthetic wrappers",
|
||||
(template) => {
|
||||
|
||||
@@ -334,10 +334,6 @@ export function getTemplateSemanticManifest(template: Template): TemplateSemanti
|
||||
return TEMPLATE_SEMANTIC_MANIFESTS[template];
|
||||
}
|
||||
|
||||
export function getTemplateSemanticRegistryFingerprintInput(): Readonly<Record<Template, TemplateSemanticManifest>> {
|
||||
return TEMPLATE_SEMANTIC_MANIFESTS;
|
||||
}
|
||||
|
||||
export function getTemplateSemanticBindingRegistry(template: Template): SemanticBindingRegistry {
|
||||
const manifest = getTemplateSemanticManifest(template);
|
||||
const canonicalBindings = Object.fromEntries(
|
||||
|
||||
@@ -998,6 +998,5 @@ export { semanticNodeKeys } from "./node-keys";
|
||||
export {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "./template-manifest";
|
||||
|
||||
@@ -108,31 +108,16 @@ describe("createResumePdfFile", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("returns semantic diagnostics without rendering an invalid applied source", async () => {
|
||||
it("renders with base styles when the source is fatal", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
const { createResumePdfFileResult } = await import("./server");
|
||||
|
||||
const result = await createResumePdfFileResult({ data, filename: "resume.pdf" });
|
||||
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
diagnostics: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.renderToBuffer).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects unchecked rendering instead of producing an unstyled PDF for semantic errors", async () => {
|
||||
const data = structuredClone(sampleResumeData);
|
||||
const invalid = { languageVersion: 1, text: "@version 1; section { color: ; }" };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: invalid, applied: invalid };
|
||||
data.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 2, text: "@version 2;" } };
|
||||
const { createResumePdfFile } = await import("./server");
|
||||
|
||||
await expect(createResumePdfFile({ data, filename: "resume.pdf" })).rejects.toMatchObject({
|
||||
cause: [expect.objectContaining({ severity: "error" })],
|
||||
});
|
||||
expect(rendererMock.renderToBuffer).not.toHaveBeenCalled();
|
||||
await expect(createResumePdfFile({ data, filename: "resume.pdf" })).resolves.toHaveProperty(
|
||||
"type",
|
||||
"application/pdf",
|
||||
);
|
||||
expect(rendererMock.renderToBuffer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("rejects renderer-unsafe data at the server boundary before React PDF dispatch", async () => {
|
||||
|
||||
@@ -1,22 +1,10 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import type { Template } from "@reactive-resume/schema/templates";
|
||||
import type { SectionTitleResolver } from "./section-title";
|
||||
import type { ResolvedResumeRuntime, ResumePdfRenderResult } from "./semantic";
|
||||
import { createElement } from "react";
|
||||
import { parseResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { renderToBuffer } from "#react-pdf-renderer";
|
||||
import { ResumeDocument } from "./document";
|
||||
import { hasSemanticErrors, inspectResumePdf } from "./semantic";
|
||||
|
||||
export type {
|
||||
PdfPreflightFailure,
|
||||
PdfPreflightPageLimits,
|
||||
PdfPreflightResult,
|
||||
RenderPreflightPdfResult,
|
||||
StylesheetPreflightInput,
|
||||
StylesheetPreflightRunner,
|
||||
} from "./semantic/preflight-core";
|
||||
export { renderPreflightPdf } from "./semantic/preflight-core";
|
||||
|
||||
export type CreateResumePdfFileOptions = {
|
||||
data: ResumeData;
|
||||
@@ -25,11 +13,13 @@ export type CreateResumePdfFileOptions = {
|
||||
resolveSectionTitle?: SectionTitleResolver | undefined;
|
||||
};
|
||||
|
||||
export type CreateResumePdfFileResultOptions = CreateResumePdfFileOptions & {
|
||||
inspection?: ResolvedResumeRuntime | undefined;
|
||||
};
|
||||
|
||||
const renderResumePdfFile = async ({ data, filename, template, resolveSectionTitle }: CreateResumePdfFileOptions) => {
|
||||
export const createResumePdfFile = async ({
|
||||
data: input,
|
||||
filename,
|
||||
template,
|
||||
resolveSectionTitle,
|
||||
}: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const data = parseResumeData(input);
|
||||
const document = createElement(ResumeDocument, {
|
||||
data,
|
||||
template: template ?? data.metadata.template,
|
||||
@@ -41,28 +31,3 @@ const renderResumePdfFile = async ({ data, filename, template, resolveSectionTit
|
||||
|
||||
return new File([bytes], filename, { type: "application/pdf" });
|
||||
};
|
||||
|
||||
export const createResumePdfFileResult = async ({
|
||||
inspection,
|
||||
...options
|
||||
}: CreateResumePdfFileResultOptions): Promise<ResumePdfRenderResult<File>> => {
|
||||
const normalizedOptions = { ...options, data: parseResumeData(options.data) };
|
||||
const resolvedInspection = inspection ?? inspectResumePdf(normalizedOptions);
|
||||
if (hasSemanticErrors(resolvedInspection)) {
|
||||
return { ok: false, diagnostics: resolvedInspection.diagnostics };
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
value: await renderResumePdfFile(normalizedOptions),
|
||||
diagnostics: resolvedInspection.diagnostics,
|
||||
};
|
||||
};
|
||||
|
||||
export const createResumePdfFile = async (options: CreateResumePdfFileOptions): Promise<File> => {
|
||||
const result = await createResumePdfFileResult(options);
|
||||
if (!result.ok) {
|
||||
throw new Error("The semantic stylesheet could not be rendered.", { cause: result.diagnostics });
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
|
||||
@@ -12,7 +12,6 @@ describe("templatePages", () => {
|
||||
|
||||
it("exports the semantic manifest registry through the template index", () => {
|
||||
expect(registry).toContain("getTemplateSemanticManifest");
|
||||
expect(registry).toContain("getTemplateSemanticRegistryFingerprintInput");
|
||||
expect(registry).toContain("TemplateSemanticManifest");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -42,6 +42,5 @@ export type { TemplateSemanticManifest } from "../semantic/template-manifest";
|
||||
export {
|
||||
getTemplateSemanticBindingRegistry,
|
||||
getTemplateSemanticManifest,
|
||||
getTemplateSemanticRegistryFingerprintInput,
|
||||
validateTemplateSemanticManifest,
|
||||
} from "../semantic/template-manifest";
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
"./patch": "./src/patch.ts",
|
||||
"./stylesheet": "./src/stylesheet/index.ts",
|
||||
"./stylesheet/registry": "./src/stylesheet/registry/index.ts",
|
||||
"./stylesheet/types": "./src/stylesheet/semantic-types.ts",
|
||||
"./stylesheet/render-data": "./src/stylesheet/render-data.ts"
|
||||
"./stylesheet/types": "./src/stylesheet/semantic-types.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsgo --noEmit",
|
||||
@@ -24,7 +23,6 @@
|
||||
"dependencies": {
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"canonicalize": "^3.0.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"zod": "^4.4.3"
|
||||
|
||||
@@ -280,7 +280,7 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
});
|
||||
if (!compiled.program) throw new Error(compiled.diagnostics.map(({ code }) => code).join(","));
|
||||
const cycled = resolveStylesheet(compiled.program, tree, context);
|
||||
expect(cycled.nodes).toEqual({});
|
||||
expect(cycled.nodes["heading-experience"]?.style.color).toBe("black");
|
||||
expect(cycled.diagnostics).toContainEqual(expect.objectContaining({ code: "VARIABLE_CYCLE", severity: "error" }));
|
||||
});
|
||||
|
||||
@@ -356,6 +356,15 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps valid resolved declarations when a neighboring value is invalid", () => {
|
||||
const result = resolve("section-heading { color: red; opacity: var(--missing); }");
|
||||
|
||||
expect(result.nodes["heading-experience"]?.style.color).toBe("red");
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("warns after variable expansion when a value is extreme but technically renderable", () => {
|
||||
const result = resolve(":root { --tiny: 3pt; } section-heading { font-size: var(--tiny); }");
|
||||
|
||||
@@ -379,7 +388,7 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
languageVersion: 1,
|
||||
text: "@version 1; @media (width: 400pt) { page { size: A4; } }",
|
||||
});
|
||||
expect(invalid.program).toBeNull();
|
||||
expect(invalid.program).not.toBeNull();
|
||||
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "MEDIA_PAGE_SIZE", severity: "error" }));
|
||||
});
|
||||
|
||||
@@ -513,14 +522,14 @@ describe("Semantic CSS cascade and structural resolution", () => {
|
||||
languageVersion: 1,
|
||||
text: `@version 1;section-heading{${declaration}}`,
|
||||
});
|
||||
expect(compiled.program, declaration).toBeNull();
|
||||
expect(compiled.program, declaration).not.toBeNull();
|
||||
expect(compiled.diagnostics, declaration).toContainEqual(
|
||||
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
|
||||
);
|
||||
}
|
||||
|
||||
const variable = resolve(":root { --bad: 1.0001; } section-heading { opacity: var(--bad); }");
|
||||
expect(variable.nodes).toEqual({});
|
||||
expect(variable.nodes["heading-experience"]?.style.color).toBe("black");
|
||||
expect(variable.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import type {
|
||||
StructuralPresentation,
|
||||
StyleProgram,
|
||||
} from "./types";
|
||||
import { createDiagnostic } from "./diagnostics";
|
||||
import { createDiagnostic, isFatalStylesheetDiagnostic } from "./diagnostics";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
|
||||
import { createSystemVariables } from "./registry/system-variables";
|
||||
@@ -927,7 +927,7 @@ export function resolveStylesheet(
|
||||
};
|
||||
}
|
||||
|
||||
if (diagnostics.some(({ severity }) => severity === "error")) {
|
||||
if (diagnostics.some(isFatalStylesheetDiagnostic)) {
|
||||
return { nodes: {}, renderTree: tree, diagnostics };
|
||||
}
|
||||
return { nodes: resolved, renderTree: createRenderTree(tree, flatNodes, resolved), diagnostics };
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||
import type { CompileStylesheetResult } from "./types";
|
||||
import { stylesheetCacheKey, stylesheetCompilationCache } from "./cache";
|
||||
import { createDiagnostic } from "./diagnostics";
|
||||
import { createDiagnostic, isFatalStylesheetDiagnostic } from "./diagnostics";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
import { parseStylesheet } from "./parse";
|
||||
import { PROPERTY_REGISTRY_V1 } from "./registry/properties";
|
||||
@@ -98,13 +98,13 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe
|
||||
);
|
||||
}
|
||||
|
||||
if (!compiler || diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
||||
if (!compiler || diagnostics.some(isFatalStylesheetDiagnostic)) {
|
||||
return { program: null, diagnostics };
|
||||
}
|
||||
|
||||
const compiled = compileProgram(stylesheet, source.languageVersion);
|
||||
diagnostics.push(...compiled.diagnostics);
|
||||
if (!compiled.program || diagnostics.some(({ severity }) => severity === "error")) {
|
||||
if (!compiled.program || diagnostics.some(isFatalStylesheetDiagnostic)) {
|
||||
return { program: null, diagnostics };
|
||||
}
|
||||
|
||||
|
||||
@@ -122,6 +122,18 @@ export const SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 = {
|
||||
|
||||
export type SemanticCssCompilerDiagnosticCode = keyof typeof SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1;
|
||||
|
||||
const FATAL_DIAGNOSTIC_CODES = new Set<string>([
|
||||
"DUPLICATE_VERSION_DIRECTIVE",
|
||||
"INVALID_VERSION",
|
||||
"RESOURCE_LIMIT",
|
||||
"UNSUPPORTED_VERSION",
|
||||
"VERSION_MISMATCH",
|
||||
]);
|
||||
|
||||
export function isFatalStylesheetDiagnostic({ code }: Pick<SemanticCssDiagnostic, "code">): boolean {
|
||||
return FATAL_DIAGNOSTIC_CODES.has(code);
|
||||
}
|
||||
|
||||
export const EMPTY_SOURCE_RANGE: SourceRange = {
|
||||
start: { line: 1, column: 1, offset: 0 },
|
||||
end: { line: 1, column: 1, offset: 0 },
|
||||
|
||||
@@ -5,8 +5,6 @@ export type { SemanticCssCompilerDiagnosticCode } from "./diagnostics";
|
||||
export type { PropertyDefinition, PropertyRegistry } from "./registry/properties";
|
||||
export type { SemanticNodeDefinition, SemanticRegistry } from "./registry/semantic";
|
||||
export type { SystemVariableDefinition, SystemVariableRegistry } from "./registry/system-variables";
|
||||
export type { RenderDataProjection } from "./render-data";
|
||||
export type { RenderDataHashInput } from "./render-hash";
|
||||
export type { CompiledSelector, CompileSelectorResult, Specificity } from "./selector";
|
||||
export type { GeneratedStylesheet, GeneratedStylesheetBlock } from "./serialize";
|
||||
export type {
|
||||
@@ -37,7 +35,7 @@ export type {
|
||||
export { analyzeStylesheet } from "./analyze";
|
||||
export { resolveStylesheet } from "./cascade";
|
||||
export { compileStylesheet } from "./compile";
|
||||
export { SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
|
||||
export { isFatalStylesheetDiagnostic, SEMANTIC_CSS_DIAGNOSTIC_CATALOG_V1 } from "./diagnostics";
|
||||
export { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
export { parseStylesheet } from "./parse";
|
||||
export { PROPERTY_REGISTRY_V1 } from "./registry/properties";
|
||||
@@ -48,8 +46,6 @@ export {
|
||||
TEMPLATE_PART_CHILD_KINDS_V1,
|
||||
} from "./registry/semantic";
|
||||
export { createSystemVariables, SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables";
|
||||
export { projectPublicRenderData, projectRenderData } from "./render-data";
|
||||
export { computeRenderDataHash } from "./render-hash";
|
||||
export { compileSelector, createSelectorMatcher, getSpecificity, matchesSelector } from "./selector";
|
||||
export { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "./serialize";
|
||||
export { SUPPORTED_SEMANTIC_CSS_VERSIONS } from "./version";
|
||||
|
||||
@@ -49,8 +49,20 @@ export function parseStylesheet(source: string): ParsedStylesheet {
|
||||
|
||||
if (!ast) return { ast: null, atRules: [], rules: [], diagnostics };
|
||||
|
||||
const atRules: ParsedAtRule[] = [];
|
||||
const rawRanges = new Set<number>();
|
||||
csstree.walk(ast, function (this: { declaration?: { property?: string } | null }, node: CssNode) {
|
||||
if (node.type === "Atrule" && node.name) {
|
||||
const prelude = node.prelude?.loc
|
||||
? source.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset).trim()
|
||||
: "";
|
||||
atRules.push({
|
||||
name: csstree.ident.decode(node.name).toLowerCase(),
|
||||
prelude,
|
||||
hasBlock: node.block !== null,
|
||||
range: rangeFromLocation(node.loc),
|
||||
});
|
||||
}
|
||||
if (node.type !== "Raw") return;
|
||||
if (this.declaration?.property?.startsWith("--")) return;
|
||||
|
||||
@@ -62,22 +74,6 @@ export function parseStylesheet(source: string): ParsedStylesheet {
|
||||
});
|
||||
|
||||
const nodes = topLevelNodes(ast);
|
||||
const atRules: ParsedAtRule[] = nodes.flatMap((node) => {
|
||||
if (node.type !== "Atrule" || !node.name) return [];
|
||||
|
||||
const prelude = node.prelude?.loc
|
||||
? source.slice(node.prelude.loc.start.offset, node.prelude.loc.end.offset).trim()
|
||||
: "";
|
||||
|
||||
return [
|
||||
{
|
||||
name: csstree.ident.decode(node.name).toLowerCase(),
|
||||
prelude,
|
||||
hasBlock: node.block !== null,
|
||||
range: rangeFromLocation(node.loc),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
ast,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { projectPublicRenderData, projectRenderData } from "./render-data";
|
||||
import { computeRenderDataHash } from "./render-hash";
|
||||
|
||||
const legacyData: ResumeData = {
|
||||
...defaultResumeData,
|
||||
metadata: {
|
||||
...defaultResumeData.metadata,
|
||||
styleRules: [
|
||||
{
|
||||
id: "legacy",
|
||||
label: "Legacy",
|
||||
enabled: true,
|
||||
target: { scope: "global" },
|
||||
slots: { text: { color: "#123456" } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const semanticData: ResumeData = {
|
||||
...legacyData,
|
||||
metadata: {
|
||||
...legacyData.metadata,
|
||||
stylesheet: {
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nfield { color: red; }" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nfield { color: blue; }" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe("render-data projection", () => {
|
||||
it("separates resume render-data identity from stylesheet revision identity", () => {
|
||||
const legacy = projectRenderData(legacyData);
|
||||
const semanticPrivate = projectRenderData(semanticData);
|
||||
const semanticPublic = projectPublicRenderData(semanticData);
|
||||
|
||||
expect(legacy.metadata.styleRules).toEqual(legacyData.metadata.styleRules);
|
||||
expect(semanticPrivate.metadata.styleRules).toBeUndefined();
|
||||
expect(semanticPrivate.metadata.stylesheet).toBeUndefined();
|
||||
expect(semanticPublic.metadata.styleRules).toBeUndefined();
|
||||
expect(semanticPublic.metadata.stylesheet).toBeUndefined();
|
||||
expect(semanticPublic.metadata.notes).toBeUndefined();
|
||||
});
|
||||
|
||||
it("excludes unknown and server-only data at every projection boundary", () => {
|
||||
const looseData = {
|
||||
...semanticData,
|
||||
dashboard: { secret: true },
|
||||
revisions: { stylesheetRevision: 4, renderDataVersion: 8 },
|
||||
metadata: {
|
||||
...semanticData.metadata,
|
||||
notes: "private",
|
||||
diagnostics: [{ message: "private" }],
|
||||
serverMetadata: { secret: true },
|
||||
},
|
||||
picture: { ...semanticData.picture, unknownPictureField: true },
|
||||
} as ResumeData;
|
||||
|
||||
const privateProjection = projectRenderData(looseData) as Record<string, unknown>;
|
||||
const publicProjection = projectPublicRenderData(looseData) as Record<string, unknown>;
|
||||
|
||||
for (const projection of [privateProjection, publicProjection]) {
|
||||
expect(projection).not.toHaveProperty("dashboard");
|
||||
expect(projection).not.toHaveProperty("revisions");
|
||||
expect(projection.metadata).not.toHaveProperty("notes");
|
||||
expect(projection.metadata).not.toHaveProperty("diagnostics");
|
||||
expect(projection.metadata).not.toHaveProperty("serverMetadata");
|
||||
expect(projection.picture).not.toHaveProperty("unknownPictureField");
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps public projection hashes invariant to owner-only metadata", async () => {
|
||||
const baseline = projectPublicRenderData(semanticData);
|
||||
const ownerOnlyMutation = {
|
||||
...semanticData,
|
||||
dashboard: { private: true },
|
||||
revisions: { renderDataVersion: 9, stylesheetRevision: 11 },
|
||||
metadata: {
|
||||
...semanticData.metadata,
|
||||
notes: "owner-only note",
|
||||
diagnostics: [{ message: "owner-only diagnostic" }],
|
||||
serverMetadata: { private: true },
|
||||
},
|
||||
} as ResumeData;
|
||||
const mutated = projectPublicRenderData(ownerOnlyMutation);
|
||||
|
||||
expect(mutated).toEqual(baseline);
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data: mutated })).resolves.toBe(
|
||||
await computeRenderDataHash({ domainVersion: 1, data: baseline }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
export type RenderDataProjection = {
|
||||
picture: ResumeData["picture"];
|
||||
basics: ResumeData["basics"];
|
||||
summary: ResumeData["summary"];
|
||||
sections: ResumeData["sections"];
|
||||
customSections: ResumeData["customSections"];
|
||||
metadata: {
|
||||
template: ResumeData["metadata"]["template"];
|
||||
layout: ResumeData["metadata"]["layout"];
|
||||
page: ResumeData["metadata"]["page"];
|
||||
design: ResumeData["metadata"]["design"];
|
||||
typography: ResumeData["metadata"]["typography"];
|
||||
styleRules?: StyleRule[];
|
||||
stylesheet?: never;
|
||||
notes?: never;
|
||||
};
|
||||
};
|
||||
|
||||
function project(data: ResumeData): RenderDataProjection {
|
||||
const parsed = resumeDataSchema.parse(data);
|
||||
const includeLegacyRules = parsed.metadata.stylesheet?.mode !== "semantic";
|
||||
|
||||
return {
|
||||
picture: parsed.picture,
|
||||
basics: parsed.basics,
|
||||
summary: parsed.summary,
|
||||
sections: parsed.sections,
|
||||
customSections: parsed.customSections,
|
||||
metadata: {
|
||||
template: parsed.metadata.template,
|
||||
layout: parsed.metadata.layout,
|
||||
page: parsed.metadata.page,
|
||||
design: parsed.metadata.design,
|
||||
typography: parsed.metadata.typography,
|
||||
...(includeLegacyRules ? { styleRules: parsed.metadata.styleRules } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function projectRenderData(data: ResumeData): RenderDataProjection {
|
||||
return project(data);
|
||||
}
|
||||
|
||||
export function projectPublicRenderData(data: ResumeData): RenderDataProjection {
|
||||
return project(data);
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { computeRenderDataHash } from "./render-hash";
|
||||
|
||||
describe("public render hashing", () => {
|
||||
it("hashes logically equivalent public render inputs identically", async () => {
|
||||
const first = await computeRenderDataHash({ domainVersion: 1, data: { b: 2, a: 1 } });
|
||||
const second = await computeRenderDataHash({ domainVersion: 1, data: { a: 1, b: 2 } });
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
|
||||
it("uses the domain-separated RFC 8785 SHA-256 vector", async () => {
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data: { a: 1 } })).resolves.toBe(
|
||||
"81d98262808eb01af7bb5cf35b721acf0454659330e1715c665e42efffc27e55",
|
||||
);
|
||||
});
|
||||
|
||||
it("includes source-free resolved nodes and fingerprints", async () => {
|
||||
const base = { domainVersion: 1, data: { resume: { name: "Ada" } } };
|
||||
const first = await computeRenderDataHash({
|
||||
...base,
|
||||
resolvedNodes: { name: { style: { color: "red" } } },
|
||||
projectionFingerprints: { adapter: "v1", registry: "v1" },
|
||||
});
|
||||
const second = await computeRenderDataHash({
|
||||
...base,
|
||||
resolvedNodes: { name: { style: { color: "blue" } } },
|
||||
projectionFingerprints: { adapter: "v1", registry: "v1" },
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
});
|
||||
|
||||
it.each([undefined, Number.NaN, Number.POSITIVE_INFINITY, 1n, new Date()])(
|
||||
"rejects non-I-JSON values",
|
||||
async (data) => {
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unpaired surrogates before canonicalization", async () => {
|
||||
const malformed = String.fromCharCode(0xd800);
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data: malformed })).rejects.toThrow("I-JSON");
|
||||
});
|
||||
|
||||
it("rejects hidden toJSON hooks before canonicalization", async () => {
|
||||
const data = {};
|
||||
Object.defineProperty(data, "toJSON", { value: () => ({ altered: true }) });
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
|
||||
});
|
||||
|
||||
it("rejects array accessors without invoking them", async () => {
|
||||
const data: unknown[] = [];
|
||||
let reads = 0;
|
||||
Object.defineProperty(data, "0", {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads++;
|
||||
return "unsafe";
|
||||
},
|
||||
});
|
||||
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data })).rejects.toThrow("I-JSON");
|
||||
expect(reads).toBe(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
() => {
|
||||
const data: unknown[] = [];
|
||||
Reflect.set(data, Symbol("private"), true);
|
||||
return data;
|
||||
},
|
||||
() => {
|
||||
const data: unknown[] = [];
|
||||
Object.defineProperty(data, "private", { value: true });
|
||||
return data;
|
||||
},
|
||||
() => {
|
||||
const data: unknown[] = [];
|
||||
Object.defineProperty(data, "toJSON", { value: () => ["altered"] });
|
||||
return data;
|
||||
},
|
||||
])("rejects non-index array properties", async (createData) => {
|
||||
await expect(computeRenderDataHash({ domainVersion: 1, data: createData() })).rejects.toThrow("I-JSON");
|
||||
});
|
||||
|
||||
it("rejects unknown hash-domain versions", async () => {
|
||||
await expect(computeRenderDataHash({ domainVersion: 2, data: {} })).rejects.toThrow("domain version");
|
||||
});
|
||||
});
|
||||
@@ -1,120 +0,0 @@
|
||||
import canonicalize from "canonicalize";
|
||||
|
||||
const HASH_DOMAIN_VERSION = 1;
|
||||
const HASH_DOMAIN_PREFIX = "reactive-resume:public-style-projection:v1\0";
|
||||
|
||||
export type RenderDataHashInput = {
|
||||
domainVersion: number;
|
||||
data: unknown;
|
||||
resolvedNodes?: unknown;
|
||||
projectionFingerprints?: unknown;
|
||||
};
|
||||
|
||||
function hasUnpairedSurrogate(value: string): boolean {
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) return true;
|
||||
index++;
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function assertEnumerableDataProperty(
|
||||
descriptor: PropertyDescriptor | undefined,
|
||||
): asserts descriptor is PropertyDescriptor & {
|
||||
value: unknown;
|
||||
} {
|
||||
if (!descriptor?.enumerable || "get" in descriptor || "set" in descriptor) {
|
||||
throw new Error("I-JSON objects must contain only enumerable data properties");
|
||||
}
|
||||
}
|
||||
|
||||
function isArrayIndex(key: string, length: number): boolean {
|
||||
if (!/^(0|[1-9]\d*)$/.test(key)) return false;
|
||||
const index = Number(key);
|
||||
return Number.isSafeInteger(index) && index < length && String(index) === key;
|
||||
}
|
||||
|
||||
function assertIJsonValue(value: unknown, seen = new Set<object>()): void {
|
||||
if (value === null || typeof value === "boolean") return;
|
||||
if (typeof value === "string") {
|
||||
if (hasUnpairedSurrogate(value)) throw new Error("I-JSON values must not contain unpaired surrogates");
|
||||
return;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
if (Number.isFinite(value)) return;
|
||||
throw new Error("I-JSON numbers must be finite");
|
||||
}
|
||||
if (typeof value !== "object") throw new Error("I-JSON values must be JSON primitives, arrays, or plain objects");
|
||||
if (seen.has(value)) throw new Error("I-JSON values must not be circular");
|
||||
|
||||
const prototype = Object.getPrototypeOf(value);
|
||||
if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) {
|
||||
throw new Error("I-JSON objects must be plain objects");
|
||||
}
|
||||
|
||||
seen.add(value);
|
||||
try {
|
||||
if (Array.isArray(value)) {
|
||||
for (const symbol of Object.getOwnPropertySymbols(value)) {
|
||||
throw new Error(`I-JSON arrays must not contain symbol keys: ${String(symbol)}`);
|
||||
}
|
||||
|
||||
const descriptors = Object.getOwnPropertyDescriptors(value);
|
||||
for (const [key, descriptor] of Object.entries(descriptors)) {
|
||||
if (key === "length") continue;
|
||||
if (!isArrayIndex(key, value.length)) throw new Error("I-JSON arrays must not contain named properties");
|
||||
assertEnumerableDataProperty(descriptor);
|
||||
}
|
||||
for (let index = 0; index < value.length; index++) {
|
||||
const descriptor = descriptors[index];
|
||||
if (!descriptor) throw new Error("I-JSON arrays must not contain holes");
|
||||
assertIJsonValue(descriptor.value, seen);
|
||||
}
|
||||
} else {
|
||||
for (const symbol of Object.getOwnPropertySymbols(value)) {
|
||||
throw new Error(`I-JSON objects must not contain symbol keys: ${String(symbol)}`);
|
||||
}
|
||||
for (const key of Object.getOwnPropertyNames(value)) {
|
||||
if (hasUnpairedSurrogate(key)) throw new Error("I-JSON keys must not contain unpaired surrogates");
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
||||
assertEnumerableDataProperty(descriptor);
|
||||
assertIJsonValue(descriptor.value, seen);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
seen.delete(value);
|
||||
}
|
||||
}
|
||||
|
||||
export async function computeRenderDataHash(input: RenderDataHashInput): Promise<string> {
|
||||
if (input.domainVersion !== HASH_DOMAIN_VERSION) {
|
||||
throw new Error(`Unsupported public render hash domain version: ${input.domainVersion}`);
|
||||
}
|
||||
|
||||
const payload =
|
||||
input.resolvedNodes === undefined && input.projectionFingerprints === undefined
|
||||
? input.data
|
||||
: {
|
||||
data: input.data,
|
||||
...(input.resolvedNodes === undefined ? {} : { resolvedNodes: input.resolvedNodes }),
|
||||
...(input.projectionFingerprints === undefined
|
||||
? {}
|
||||
: { projectionFingerprints: input.projectionFingerprints }),
|
||||
};
|
||||
assertIJsonValue(payload);
|
||||
|
||||
const canonical = canonicalize(payload);
|
||||
if (canonical === undefined) throw new Error("I-JSON value required for public render hashing");
|
||||
const digest = await globalThis.crypto.subtle.digest(
|
||||
"SHA-256",
|
||||
new TextEncoder().encode(HASH_DOMAIN_PREFIX + canonical),
|
||||
);
|
||||
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
@@ -161,13 +161,22 @@ describe("semantic selector compilation", () => {
|
||||
expect(compileStylesheet({ languageVersion: 1, text: fixture }).program).not.toBeNull();
|
||||
|
||||
const invalid = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection:hover { color: red; }" });
|
||||
expect(invalid.program).toBeNull();
|
||||
expect(invalid.program).not.toBeNull();
|
||||
expect(invalid.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
|
||||
});
|
||||
|
||||
it("treats selector-count overflow as a fatal resource limit", () => {
|
||||
const selectors = new Array(65).fill("section").join(",");
|
||||
const result = compileStylesheet({ languageVersion: 1, text: `@version 1;\n${selectors} { color: red; }` });
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "RESOURCE_LIMIT", severity: "error" }));
|
||||
expect(result.diagnostics).not.toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
|
||||
});
|
||||
|
||||
it("rejects uppercase pseudo names while compiling a stylesheet", () => {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\n:ROOT { color: red; }" });
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_SELECTOR" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,6 +46,7 @@ export type CompiledSelector = {
|
||||
export type CompileSelectorResult = {
|
||||
selector: CompiledSelector | null;
|
||||
error?: string;
|
||||
resourceLimit?: true;
|
||||
};
|
||||
|
||||
type SelectorAst = {
|
||||
@@ -65,6 +66,8 @@ type CompileContext = {
|
||||
depth: number;
|
||||
};
|
||||
|
||||
class SelectorResourceLimitError extends Error {}
|
||||
|
||||
type TreeNode = {
|
||||
node: SemanticNode;
|
||||
parent: TreeNode | null;
|
||||
@@ -208,7 +211,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
|
||||
}
|
||||
if (["is", "where", "not"].includes(name)) {
|
||||
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
|
||||
throw new Error("Selector function nesting is too deep.");
|
||||
throw new SelectorResourceLimitError("Selector function nesting is too deep.");
|
||||
}
|
||||
const nested = childrenOf(node);
|
||||
if (nested.length !== 1 || nested[0]?.type !== "SelectorList") {
|
||||
@@ -219,7 +222,7 @@ function compileSimple(node: SelectorAst, context: CompileContext): CompiledSimp
|
||||
}
|
||||
if (name === "nth-child" || name === "nth-of-type") {
|
||||
if (context.depth >= SEMANTIC_CSS_LIMITS_V1.maxFunctionDepth) {
|
||||
throw new Error("Selector function nesting is too deep.");
|
||||
throw new SelectorResourceLimitError("Selector function nesting is too deep.");
|
||||
}
|
||||
return compileNth(node, name, context);
|
||||
}
|
||||
@@ -251,7 +254,7 @@ function compileComplex(node: SelectorAst, context: CompileContext): CompiledCom
|
||||
selectors = [];
|
||||
combinators.push(name);
|
||||
if (combinators.length > SEMANTIC_CSS_LIMITS_V1.maxCombinatorsPerSelector) {
|
||||
throw new Error("Selector has too many combinators.");
|
||||
throw new SelectorResourceLimitError("Selector has too many combinators.");
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -270,9 +273,12 @@ function compileComplex(node: SelectorAst, context: CompileContext): CompiledCom
|
||||
function compileSelectorList(node: SelectorAst, context: CompileContext): readonly CompiledComplexSelector[] {
|
||||
if (node.type !== "SelectorList") throw new Error("Expected a SelectorList AST.");
|
||||
const selectors = childrenOf(node);
|
||||
if (selectors.length === 0 || selectors.length > SEMANTIC_CSS_LIMITS_V1.maxSelectorsPerRule) {
|
||||
if (selectors.length === 0) {
|
||||
throw new Error("Selector list has an unsupported number of selectors.");
|
||||
}
|
||||
if (selectors.length > SEMANTIC_CSS_LIMITS_V1.maxSelectorsPerRule) {
|
||||
throw new SelectorResourceLimitError("Selector list has an unsupported number of selectors.");
|
||||
}
|
||||
return selectors.map((selector) => compileComplex(selector, context));
|
||||
}
|
||||
|
||||
@@ -280,13 +286,17 @@ export function compileSelector(source: string | CssNode): CompileSelectorResult
|
||||
try {
|
||||
const text = typeof source === "string" ? source : csstree.generate(source);
|
||||
if (Array.from(text).length > SEMANTIC_CSS_LIMITS_V1.maxSelectorCodePoints)
|
||||
throw new Error("Selector is too long.");
|
||||
throw new SelectorResourceLimitError("Selector is too long.");
|
||||
const ast = (
|
||||
typeof source === "string" ? csstree.parse(source, { context: "selectorList", positions: true }) : source
|
||||
) as SelectorAst;
|
||||
return { selector: { selectors: compileSelectorList(ast, { depth: 0 }) } };
|
||||
} catch (error) {
|
||||
return { selector: null, error: error instanceof Error ? error.message : "Invalid selector." };
|
||||
return {
|
||||
selector: null,
|
||||
error: error instanceof Error ? error.message : "Invalid selector.",
|
||||
...(error instanceof SelectorResourceLimitError ? { resourceLimit: true } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -51,13 +51,44 @@ describe("Semantic CSS value compilation", () => {
|
||||
expect(() => structuredClone(first.program)).not.toThrow();
|
||||
});
|
||||
|
||||
it("keeps valid rules and declarations when neighboring fragments are invalid", () => {
|
||||
const result = compileStylesheet({
|
||||
languageVersion: 1,
|
||||
text: "@version 1; section:hover { color: red; } name { unknown: 1; color: #123456; }",
|
||||
});
|
||||
|
||||
expect(result.program?.rules).toEqual([
|
||||
expect.objectContaining({
|
||||
declarations: [expect.objectContaining({ property: "color", value: "#123456" })],
|
||||
}),
|
||||
]);
|
||||
expect(result.diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code: "INVALID_SELECTOR", severity: "error" }),
|
||||
expect.objectContaining({ code: "UNSUPPORTED_PROPERTY", severity: "error" }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("omits an invalid value without dropping valid declarations in the rule", () => {
|
||||
const result = compileStylesheet({
|
||||
languageVersion: 1,
|
||||
text: "@version 1; name { opacity: 2; color: #123456; }",
|
||||
});
|
||||
|
||||
expect(result.program?.rules[0]?.declarations).toEqual([
|
||||
expect.objectContaining({ property: "color", value: "#123456" }),
|
||||
]);
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
|
||||
});
|
||||
|
||||
it("rejects assignments to reserved system variables", () => {
|
||||
const result = compileStylesheet({
|
||||
languageVersion: 1,
|
||||
text: "@version 1; :root { --resume-primary-color: red; } name { color: blue; }",
|
||||
});
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "SYSTEM_VARIABLE_READONLY", severity: "error" }),
|
||||
);
|
||||
@@ -70,7 +101,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
text: `@version 1; :root { --asset: ${value}; } picture { background-color: var(--asset); }`,
|
||||
});
|
||||
|
||||
expect(result.program, value).toBeNull();
|
||||
expect(result.program, value).not.toBeNull();
|
||||
expect(result.diagnostics, value).toContainEqual(
|
||||
expect.objectContaining({ code: "FORBIDDEN_CSS_VALUE", severity: "error" }),
|
||||
);
|
||||
@@ -93,7 +124,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
languageVersion: 1,
|
||||
text: `@version 1; field { margin-top: ${value}; }`,
|
||||
});
|
||||
expect(result.program, value).toBeNull();
|
||||
expect(result.program, value).not.toBeNull();
|
||||
expect(result.diagnostics, value).toContainEqual(
|
||||
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
|
||||
);
|
||||
@@ -121,7 +152,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
text: `@version 1; section { border-style: ${value}; }`,
|
||||
});
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }));
|
||||
},
|
||||
);
|
||||
@@ -211,7 +242,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
text: `@version 1;section{flex:${grow} auto ${trailing}}`,
|
||||
});
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
|
||||
);
|
||||
@@ -240,7 +271,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
text: `@version 1;section{${declaration}}`,
|
||||
});
|
||||
|
||||
expect(rejected.program, declaration).toBeNull();
|
||||
expect(rejected.program, declaration).not.toBeNull();
|
||||
expect(rejected.diagnostics, declaration).toContainEqual(
|
||||
expect.objectContaining({ code: "INVALID_VALUE", severity: "error" }),
|
||||
);
|
||||
@@ -406,7 +437,7 @@ describe("Semantic CSS value compilation", () => {
|
||||
fc.assert(
|
||||
fc.property(forbiddenBody, ({ body, code }) => {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: `@version 1;${body}` });
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code, severity: "error" }));
|
||||
}),
|
||||
{ numRuns: 100 },
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
StyleProgram,
|
||||
} from "./types";
|
||||
import * as csstree from "css-tree";
|
||||
import { createDiagnostic, EMPTY_SOURCE_RANGE } from "./diagnostics";
|
||||
import { createDiagnostic, EMPTY_SOURCE_RANGE, isFatalStylesheetDiagnostic } from "./diagnostics";
|
||||
import { SEMANTIC_CSS_LIMITS_V1 } from "./limits";
|
||||
import {
|
||||
PROPERTY_REGISTRY_V1,
|
||||
@@ -542,7 +542,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
|
||||
? compileSelector(node.prelude)
|
||||
: { selector: null, error: "Missing selector." };
|
||||
if (!selectorResult.selector) {
|
||||
const code = /too many|too long/i.test(selectorResult.error ?? "") ? "RESOURCE_LIMIT" : "INVALID_SELECTOR";
|
||||
const code = selectorResult.resourceLimit ? "RESOURCE_LIMIT" : "INVALID_SELECTOR";
|
||||
diagnostic(diagnostics, code, selectorResult.error ?? "Invalid selector.", node.prelude ?? node);
|
||||
return;
|
||||
}
|
||||
@@ -594,9 +594,11 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
|
||||
continue;
|
||||
}
|
||||
for (const [expandedProperty, expandedValue] of expanded) {
|
||||
const diagnosticCount = diagnostics.length;
|
||||
validateValue(expandedProperty, expandedValue, declaration, diagnostics);
|
||||
const syntaxError = property.startsWith("--") ? null : valueSyntaxError(expandedProperty, expandedValue);
|
||||
if (syntaxError) diagnostic(diagnostics, "INVALID_VALUE", syntaxError, declaration);
|
||||
if (diagnostics.length > diagnosticCount && diagnostics.at(-1)?.severity === "error") continue;
|
||||
declarations.push({
|
||||
property: expandedProperty,
|
||||
value: expandedValue,
|
||||
@@ -651,7 +653,7 @@ export function compileProgram(stylesheet: ParsedStylesheet, languageVersion: nu
|
||||
const ast = stylesheet.ast as AstNode | null;
|
||||
if (ast) visit(children(ast), [], 0);
|
||||
const program = { languageVersion, rules } satisfies StyleProgram;
|
||||
return diagnostics.some(({ severity }) => severity === "error")
|
||||
return diagnostics.some(isFatalStylesheetDiagnostic)
|
||||
? { program: null, diagnostics }
|
||||
: { program: structuredClone(program), diagnostics };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,18 @@ describe("compileStylesheet", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects a duplicate version directive nested in an at-rule block", () => {
|
||||
const result = compileStylesheet({
|
||||
languageVersion: 1,
|
||||
text: "@version 1; @media (width: 600pt) { @version 1; name { color: red; } }",
|
||||
});
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(
|
||||
expect.objectContaining({ code: "DUPLICATE_VERSION_DIRECTIVE", severity: "error" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects malformed directives", () => {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: "@version one;" });
|
||||
|
||||
@@ -59,10 +71,10 @@ describe("compileStylesheet", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("does not compile a recovered CSS error", () => {
|
||||
it("compiles around a recovered CSS error", () => {
|
||||
const result = compileStylesheet({ languageVersion: 1, text: "@version 1;\nsection { color red; }" });
|
||||
|
||||
expect(result.program).toBeNull();
|
||||
expect(result.program).not.toBeNull();
|
||||
expect(result.diagnostics).toContainEqual(expect.objectContaining({ code: "CSS_PARSE_ERROR", severity: "error" }));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,42 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { z } from "zod";
|
||||
import { describe, expect, expectTypeOf, it } from "vitest";
|
||||
import { resumeDataSchema } from "./data";
|
||||
import { defaultResumeData } from "./default";
|
||||
import { semanticStylesheetSchema, stylesheetSourceSchema } from "./stylesheet";
|
||||
|
||||
describe("semanticStylesheetSchema", () => {
|
||||
it("preserves separate editable and applied sources", () => {
|
||||
it("keeps the public input canonical instead of widening it to unknown", () => {
|
||||
type StylesheetInput = z.input<typeof semanticStylesheetSchema>;
|
||||
type CanonicalInput = {
|
||||
mode: "legacy" | "semantic";
|
||||
source: { languageVersion: number; text: string };
|
||||
};
|
||||
|
||||
expectTypeOf<StylesheetInput>().toEqualTypeOf<CanonicalInput>();
|
||||
expectTypeOf<string>().not.toExtend<StylesheetInput>();
|
||||
});
|
||||
|
||||
it("persists one canonical stylesheet source", () => {
|
||||
const result = semanticStylesheetSchema.parse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nsection {" },
|
||||
applied: { languageVersion: 1, text: "@version 1;\nsection { color: red; }\n" },
|
||||
source: { languageVersion: 1, text: "@version 1;\nsection { color: red; }\n" },
|
||||
});
|
||||
|
||||
expect(result.source.text).toContain("section {");
|
||||
expect(result.applied.text).toContain("color: red");
|
||||
expect(result).toEqual({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "@version 1;\nsection { color: red; }\n" },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes the historical applied source to the canonical source-only shape", () => {
|
||||
const source = { languageVersion: 1, text: "@version 1;\nname { color: red; }\n" };
|
||||
const result = semanticStylesheetSchema.parse({
|
||||
mode: "semantic",
|
||||
source,
|
||||
applied: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
|
||||
});
|
||||
|
||||
expect(result).toEqual({ mode: "semantic", source });
|
||||
});
|
||||
|
||||
it("keeps resumes without a stylesheet valid for legacy rendering", () => {
|
||||
@@ -24,7 +48,6 @@ describe("semanticStylesheetSchema", () => {
|
||||
semanticStylesheetSchema.safeParse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 0, text: "" },
|
||||
applied: { languageVersion: 1, text: "" },
|
||||
}).success,
|
||||
).toBe(false);
|
||||
});
|
||||
@@ -34,7 +57,6 @@ describe("semanticStylesheetSchema", () => {
|
||||
semanticStylesheetSchema.safeParse({
|
||||
mode: "semantic",
|
||||
source: { languageVersion: 1, text: "" },
|
||||
applied: { languageVersion: 1, text: "" },
|
||||
unknown: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
|
||||
@@ -7,12 +7,30 @@ export const stylesheetSourceSchema = z.strictObject({
|
||||
text: z.string(),
|
||||
});
|
||||
|
||||
export const semanticStylesheetSchema = z.strictObject({
|
||||
const canonicalSemanticStylesheetSchema = z.strictObject({
|
||||
mode: z.enum(["legacy", "semantic"]),
|
||||
source: stylesheetSourceSchema,
|
||||
applied: stylesheetSourceSchema,
|
||||
});
|
||||
|
||||
type SemanticStylesheetInput = z.input<typeof canonicalSemanticStylesheetSchema>;
|
||||
|
||||
const normalizeHistoricalStylesheet = (input: unknown): unknown => {
|
||||
if (typeof input !== "object" || input === null || Array.isArray(input) || !Object.hasOwn(input, "applied")) {
|
||||
return input;
|
||||
}
|
||||
|
||||
const { applied, ...stylesheet } = input as Record<string, unknown>;
|
||||
return stylesheetSourceSchema.safeParse(applied).success ? stylesheet : input;
|
||||
};
|
||||
|
||||
const semanticStylesheetCompatibilityTransform = z.transform<SemanticStylesheetInput, SemanticStylesheetInput>(
|
||||
(input) => normalizeHistoricalStylesheet(input) as SemanticStylesheetInput,
|
||||
);
|
||||
|
||||
export const semanticStylesheetSchema = semanticStylesheetCompatibilityTransform.pipe(
|
||||
canonicalSemanticStylesheetSchema,
|
||||
);
|
||||
|
||||
export type StylesheetSource = z.infer<typeof stylesheetSourceSchema>;
|
||||
export type SemanticStylesheet = z.infer<typeof semanticStylesheetSchema>;
|
||||
export type StylesheetMode = SemanticStylesheet["mode"];
|
||||
|
||||
Reference in New Issue
Block a user