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.
This commit is contained in:
Amruth Pillai
2026-09-29 15:09:57 +02:00
parent cfb272229b
commit db97381b3d
4 changed files with 84 additions and 4 deletions
@@ -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: "" },
});
});
@@ -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");
});
});
@@ -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.");