From db97381b3d83da2b8184e5f2195d59410d2dfa26 Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 15:09:57 +0200 Subject: [PATCH] feat(api): convert legacy style rules to semantic css on every read and save Resumes from before Semantic CSS carry legacy style rules that only the old renderer understood. The API now converts them (with the existing, parity-tested converter) whenever resume data is read or written, so everything it hands out uses a Semantic CSS stylesheet and the database catches up on the next save. The rules stay stored for rollback, and a draft typed in the old editor but never activated is kept, commented out, after the conversion. New resumes start with an empty stylesheet. --- .../src/features/resume/initial-data.test.ts | 2 +- .../resume/resume-data-validation.test.ts | 57 ++++++++++++++++++- .../features/resume/resume-data-validation.ts | 27 ++++++++- packages/schema/src/resume/stylesheet.ts | 2 +- 4 files changed, 84 insertions(+), 4 deletions(-) diff --git a/packages/api/src/features/resume/initial-data.test.ts b/packages/api/src/features/resume/initial-data.test.ts index b583b08f7..0539cec66 100644 --- a/packages/api/src/features/resume/initial-data.test.ts +++ b/packages/api/src/features/resume/initial-data.test.ts @@ -6,7 +6,7 @@ describe("createResumeData", () => { it("seeds one canonical empty stylesheet source", () => { expect(createResumeData({}).metadata.stylesheet).toEqual({ mode: "semantic", - source: { languageVersion: 1, text: "@version 1;\n" }, + source: { languageVersion: 1, text: "" }, }); }); diff --git a/packages/api/src/features/resume/resume-data-validation.test.ts b/packages/api/src/features/resume/resume-data-validation.test.ts index 58e278b8a..b796b2327 100644 --- a/packages/api/src/features/resume/resume-data-validation.test.ts +++ b/packages/api/src/features/resume/resume-data-validation.test.ts @@ -3,7 +3,7 @@ import { set } from "es-toolkit/compat"; import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; -import { parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation"; +import { adoptLegacyStyles, parseStoredResumeData, parseWritableResumeData } from "./resume-data-validation"; describe("parseWritableResumeData", () => { it("rejects stylesheet source above the Semantic CSS byte limit", () => { @@ -63,3 +63,58 @@ describe("strict write bounds", () => { }); }); }); + +describe("adoptLegacyStyles", () => { + const legacy = () => { + const data = structuredClone(sampleResumeData); + data.metadata.stylesheet = undefined; + data.metadata.styleRules = [ + { + id: "rule-1", + label: "Teal headings", + enabled: true, + target: { scope: "global" }, + slots: { heading: { color: "#0f766e" } }, + }, + ]; + return data; + }; + + it("converts legacy rules into a semantic stylesheet and keeps the rules", () => { + const adopted = adoptLegacyStyles(legacy()); + + expect(adopted.metadata.stylesheet?.mode).toBe("semantic"); + expect(adopted.metadata.stylesheet?.source.text).toContain("color: #0f766e;"); + expect(adopted.metadata.stylesheet?.source.text).not.toContain("@version"); + expect(adopted.metadata.styleRules).toHaveLength(1); + }); + + it("keeps an unapplied draft from the old editor as a comment after the conversion", () => { + const data = legacy(); + data.metadata.stylesheet = { + mode: "legacy", + source: { languageVersion: 1, text: "@version 1;\nname { color: red; } /* note */" }, + }; + const text = adoptLegacyStyles(data).metadata.stylesheet?.source.text ?? ""; + + expect(text.indexOf("color: #0f766e;")).toBeLessThan(text.indexOf("Unapplied draft")); + expect(text).toContain("name { color: red; } /* note *\\/"); + }); + + it("leaves semantic stylesheets alone, and gives a resume with no rules an empty one", () => { + const semantic = structuredClone(sampleResumeData); + semantic.metadata.stylesheet = { mode: "semantic", source: { languageVersion: 1, text: "name { color: red; }" } }; + expect(adoptLegacyStyles(semantic)).toBe(semantic); + + const plain = structuredClone(defaultResumeData); + plain.metadata.stylesheet = undefined; + expect(adoptLegacyStyles(plain).metadata.stylesheet).toEqual({ + mode: "semantic", + source: { languageVersion: 1, text: "" }, + }); + }); + + it("runs on reads", () => { + expect(parseStoredResumeData(legacy()).metadata.stylesheet?.mode).toBe("semantic"); + }); +}); diff --git a/packages/api/src/features/resume/resume-data-validation.ts b/packages/api/src/features/resume/resume-data-validation.ts index e42bbf45c..586333285 100644 --- a/packages/api/src/features/resume/resume-data-validation.ts +++ b/packages/api/src/features/resume/resume-data-validation.ts @@ -1,13 +1,38 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; import { ORPCError } from "@orpc/client"; +import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy"; import { SEMANTIC_CSS_LIMITS_V1 } from "@reactive-resume/resume/stylesheet"; import { parseResumeData } from "@reactive-resume/schema/resume/data"; import { syncResumeDates, upgradeResumeDates } from "@reactive-resume/schema/resume/dates"; import { parseResumeDataForWrite } from "@reactive-resume/schema/resume/write"; +/** + * Resumes saved before Semantic CSS styled themselves with legacy style rules (`metadata.styleRules`), which only the + * old renderer read. Every resume read or written through the API comes out with those rules converted to a Semantic + * CSS stylesheet, so the renderer has one styling system; the database catches up on the next save. The rules + * themselves are kept for rollback. A draft typed in the old editor before it was activated was never shown on the + * page, so the conversion (what the page showed) wins and the draft is kept below it, commented out. + */ +export function adoptLegacyStyles(data: ResumeData): ResumeData { + const stylesheet = data.metadata.stylesheet; + if (stylesheet?.mode === "semantic") return data; + + const converted = convertLegacyStyleRules(data).source; + const draft = stylesheet?.source.text.replace(/^\s*@version\s+\d+\s*;\s*/, "").trim(); + const withDraft = + draft && draft !== converted.text.trim() + ? `${converted.text}${converted.text ? "\n" : ""}/* Unapplied draft from the old editor:\n${draft.replaceAll("*/", "*\\/")}\n*/\n` + : converted.text; + // Never let the kept draft push the stylesheet over its size limit (reads would fail). + const text = + new TextEncoder().encode(withDraft).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes ? converted.text : withDraft; + + return { ...data, metadata: { ...data.metadata, stylesheet: { mode: "semantic", source: { ...converted, text } } } }; +} + function parseApiResumeData(data: unknown, code: "BAD_REQUEST" | "INTERNAL_SERVER_ERROR", message: string): ResumeData { try { - const parsed = code === "BAD_REQUEST" ? parseResumeDataForWrite(data) : parseResumeData(data); + const parsed = adoptLegacyStyles(code === "BAD_REQUEST" ? parseResumeDataForWrite(data) : 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."); diff --git a/packages/schema/src/resume/stylesheet.ts b/packages/schema/src/resume/stylesheet.ts index 08b56ab78..d3dac7781 100644 --- a/packages/schema/src/resume/stylesheet.ts +++ b/packages/schema/src/resume/stylesheet.ts @@ -1,6 +1,6 @@ import { z } from "zod"; -export const EMPTY_SEMANTIC_CSS_SOURCE = "@version 1;\n"; +export const EMPTY_SEMANTIC_CSS_SOURCE = ""; export const stylesheetSourceSchema = z.strictObject({ languageVersion: z.number().int().positive(),