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(),