refactor(stylesheet): move Semantic CSS to the browser (#3329)

This commit is contained in:
Amruth Pillai
2026-08-16 16:50:27 +02:00
committed by GitHub
parent f848e57436
commit 9509b5bc2e
203 changed files with 11838 additions and 14842 deletions
@@ -35,10 +35,6 @@ const toastMocks = vi.hoisted(() => ({
error: vi.fn(() => "sync-error-toast"),
}));
const stylesheetMocks = vi.hoisted(() => ({
refresh: vi.fn(),
}));
vi.mock("@orpc/client", () => ({
consumeEventIterator: consumeEventIteratorMock,
}));
@@ -81,10 +77,6 @@ vi.mock("sonner", () => ({
toast: toastMocks,
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
refreshStylesheetStore: stylesheetMocks.refresh,
}));
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
@@ -135,7 +127,6 @@ describe("builder resume autosave", () => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
toastMocks.dismiss.mockClear();
toastMocks.error.mockClear();
stylesheetMocks.refresh.mockReset();
useResumeStore.getState().reset();
});
@@ -169,6 +160,26 @@ describe("builder resume autosave", () => {
expect(orpcMocks.patchResume).not.toHaveBeenCalled();
});
it("autosaves stylesheet source through the ordinary full-data update", async () => {
const initial = makeResume("resume-stylesheet-autosave");
const source = { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" };
const updated = makeResume(initial.id);
updated.data.metadata.stylesheet = { mode: "semantic", source };
orpcMocks.updateResume.mockResolvedValue(updated);
useResumeStore.getState().initialize(initial);
useResumeStore.getState().updateResumeData((draft) => {
draft.metadata.stylesheet = { mode: "semantic", source };
});
vi.advanceTimersByTime(500);
await flushMicrotasks();
expect(orpcMocks.updateResume).toHaveBeenCalledWith(
{ id: initial.id, data: updated.data },
expect.objectContaining({ signal: expect.any(AbortSignal) }),
);
});
it("saves the latest pending snapshot after an in-flight save resolves", async () => {
const initial = makeResume("resume-in-flight");
const first = withBasicsName(initial, "First Name");
@@ -507,25 +518,13 @@ describe("resume update stream subscription", () => {
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
});
it("refetches canonical stylesheet state for stylesheet SSE events", async () => {
it("applies stylesheet source from the ordinary resume SSE flow", async () => {
const initial = makeResume("resume-stylesheet");
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
routerParamsMock.value = { resumeId: initial.id };
useResumeStore.getState().initialize(initial);
renderHook(() => useBuilderResumeUpdateSubscription());
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as {
onEvent: (event: { mutation: string }) => Promise<void>;
const remote = makeResume("resume-stylesheet");
remote.data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname { color: blue; }\n" },
};
await act(async () => handlers.onEvent({ mutation: "stylesheet" }));
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id);
expect(orpcMocks.getResumeById).not.toHaveBeenCalled();
});
it("refreshes the render-data version after content SSE events", async () => {
const initial = makeResume("resume-content");
const remote = withBasicsName(initial, "Remote");
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
orpcMocks.getResumeById.mockResolvedValue(remote);
routerParamsMock.value = { resumeId: initial.id };
@@ -537,6 +536,7 @@ describe("resume update stream subscription", () => {
};
await act(async () => handlers.onEvent({ mutation: "update" }));
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id, remote.data);
expect(orpcMocks.getResumeById).toHaveBeenCalledWith({ id: initial.id });
expect(useResumeStore.getState().resume?.data.metadata.stylesheet).toEqual(remote.data.metadata.stylesheet);
});
});
@@ -10,7 +10,6 @@ import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
import { refreshStylesheetStore } from "@/features/resume/stylesheet/store";
import { orpc, streamClient } from "@/libs/orpc/client";
export type Resume = {
@@ -26,7 +25,7 @@ export type Resume = {
};
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
type ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
type SaveStatus = "idle" | "saving" | "saved" | "error";
@@ -616,14 +615,9 @@ export function useBuilderResumeUpdateSubscription() {
if (!resumeId) return;
bindRuntimeQueryClient(resumeId, queryClient);
if (event.mutation === "stylesheet") {
await refreshStylesheetStore(resumeId);
return;
}
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
await refreshStylesheetStore(resumeId, resume.data);
if (hasPendingLocalChanges(resumeId)) {
useResumeStore.getState().mergeResumeMetadata(resume);
@@ -1,6 +1,4 @@
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { Template } from "@reactive-resume/schema/templates";
import { useMemo } from "react";
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
@@ -11,22 +9,6 @@ type ResumePdfRenderOptions = {
includeCoverLetterHeader?: boolean;
};
export type ResumePdfPresentation =
| { stylesheet: Pick<SemanticStylesheet, "mode"> & { applied: StylesheetSource } }
| { publicStyleProjection: PublicStyleProjection };
const withAppliedStylesheet = (data: ResumeData, presentation?: ResumePdfPresentation): ResumeData => {
if (!presentation || !("stylesheet" in presentation)) return data;
const { mode, applied } = presentation.stylesheet;
return {
...data,
metadata: {
...data.metadata,
stylesheet: { mode, source: applied, applied },
},
};
};
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
@@ -47,17 +29,13 @@ export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
renderOptions?: ResumePdfRenderOptions,
presentation?: ResumePdfPresentation,
) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
return createPdfBlob({
data: withAppliedStylesheet(data, presentation),
data,
template,
...(renderOptions ? { renderOptions } : {}),
...(presentation && "publicStyleProjection" in presentation
? { publicStyleProjection: presentation.publicStyleProjection }
: {}),
resolveSectionTitle: sectionTitleResolver,
});
};
@@ -3,7 +3,6 @@
import { act, renderHook } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { useResumeExport } from "./use-resume-export";
@@ -14,22 +13,13 @@ const mocks = vi.hoisted(() => ({
toastError: vi.fn(),
}));
vi.mock("./pdf-document", () => ({
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: mocks.createResumePdfBlob,
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor: mocks.downloadWithAnchor,
generateFilename: (name: string, extension: string) => `${name}.${extension}`,
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
useStylesheetStore: (selector: (state: object) => unknown) =>
selector({
resumeId: undefined,
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
}),
}));
vi.mock("sonner", () => ({
toast: {
loading: vi.fn(() => "toast"),
@@ -49,21 +39,16 @@ beforeEach(() => {
});
describe("useResumeExport public PDF", () => {
it("downloads the authorized server blob after one mismatched-projection refetch", async () => {
it("downloads the authorized server blob after public browser rendering rejects", async () => {
const semanticData = structuredClone(sampleResumeData);
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
const projection = await createPublicStyleProjection({ data: semanticData });
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
semanticData.metadata.stylesheet = { mode: "semantic", source };
mocks.createResumePdfBlob.mockRejectedValueOnce(new Error("browser renderer failed"));
const { result } = renderHook(() =>
useResumeExport(
{ name: "Sample", slug: "sample", data: sampleResumeData },
{ name: "Sample", slug: "sample", data: semanticData },
{
publicResumePdf: {
stylesheetMode: "semantic",
styleProjection: mismatchedProjection,
refetchStyleProjection,
publicResume: { username: "amruth", slug: "sample" },
},
},
@@ -72,19 +57,14 @@ describe("useResumeExport public PDF", () => {
await act(() => result.current.onDownloadPDF());
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(semanticData);
expect(mocks.fetch).toHaveBeenCalledTimes(1);
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
const blob = mocks.downloadWithAnchor.mock.calls[0]?.[0] as Blob;
expect(await blob.text()).toBe("server");
});
it("does not download an unstyled PDF when semantic rendering rejects", async () => {
mocks.createResumePdfBlob.mockRejectedValueOnce(
new Error("The semantic stylesheet could not be rendered.", {
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
}),
);
it("does not download a PDF when the renderer rejects", async () => {
mocks.createResumePdfBlob.mockRejectedValueOnce(new Error("PDF renderer failed"));
const { result } = renderHook(() => useResumeExport({ name: "Sample", slug: "sample", data: sampleResumeData }));
await act(() => result.current.onDownloadPDF());
@@ -1,10 +1,8 @@
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import type { PublicResumePdfOptions } from "@/features/resume/public/public-pdf";
import type { ResumePdfPresentation } from "./pdf-document";
import { t } from "@lingui/core/macro";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import { toast } from "sonner";
import { buildDocx } from "@reactive-resume/docx";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
@@ -12,7 +10,6 @@ import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resu
import { buildMarkdown } from "@reactive-resume/resume/markdown";
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
import { resolvePublicResumePdfBlob } from "@/features/resume/public/public-pdf";
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
import { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
import { createResumePdfBlob } from "./pdf-document";
@@ -54,43 +51,12 @@ type DownloadPdfOptions = {
export function useResumeExport(resume: ExportableResume | undefined, exportOptions: UseResumeExportOptions = {}) {
const [isExporting, setIsExporting] = useState(false);
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
const stylesheetResumeId = useStylesheetStore((state) => state.resumeId);
const stylesheetMode = useStylesheetStore((state) => state.mode);
const stylesheetSource = useStylesheetStore((state) => state.source);
const stylesheetApplied = useStylesheetStore((state) => state.applied);
const canonicalStylesheet = useMemo<SemanticStylesheet | undefined>(
() =>
resume?.id && resume.id === stylesheetResumeId
? {
mode: stylesheetMode,
source: stylesheetSource,
applied: stylesheetApplied,
}
: undefined,
[resume?.id, stylesheetApplied, stylesheetMode, stylesheetResumeId, stylesheetSource],
);
const pdfPresentation = useMemo<ResumePdfPresentation | undefined>(
() =>
canonicalStylesheet
? { stylesheet: { mode: canonicalStylesheet.mode, applied: canonicalStylesheet.applied } }
: undefined,
[canonicalStylesheet],
);
const onDownloadJSON = useCallback(() => {
if (!resume) return;
const data = canonicalStylesheet
? {
...resume.data,
metadata: {
...resume.data.metadata,
stylesheet: canonicalStylesheet,
},
}
: resume.data;
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
}, [canonicalStylesheet, resume]);
}, [resume]);
const onDownloadMarkdown = useCallback(
async (target: ResumeExportTarget = "resume") => {
@@ -136,7 +102,6 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
target === "cover-letter"
? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader }
: undefined,
pdfPresentation,
);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
} catch {
@@ -146,7 +111,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
toast.dismiss(toastId);
}
},
[exportOptions.publicResumePdf, pdfPresentation, resume],
[exportOptions.publicResumePdf, resume],
);
const onPrint = useCallback(async () => {
@@ -156,7 +121,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
try {
const blob = exportOptions.publicResumePdf
? await resolvePublicResumePdfBlob({ data: resume.data, ...exportOptions.publicResumePdf })
: await createResumePdfBlob(resume.data, undefined, undefined, pdfPresentation);
: await createResumePdfBlob(resume.data);
const url = URL.createObjectURL(blob);
// ponytail: print the generated PDF via a hidden iframe (reliable in Chromium). If the browser
// blocks iframe printing, fall back to opening the PDF in a new tab so the user can print manually.
@@ -182,7 +147,7 @@ export function useResumeExport(resume: ExportableResume | undefined, exportOpti
setIsExporting(false);
toast.dismiss(toastId);
}
}, [exportOptions.publicResumePdf, pdfPresentation, resume]);
}, [exportOptions.publicResumePdf, resume]);
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
}
@@ -8,14 +8,7 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { ResumePreviewClient } from "./preview.browser";
const previewMock = vi.hoisted(() => ({
builderResumeId: undefined as string | undefined,
builderResumeData: undefined as ResumeData | undefined,
stylesheet: {
resumeId: undefined as string | undefined,
mode: "legacy" as "legacy" | "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
},
toastError: vi.fn(),
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
}));
@@ -53,22 +46,9 @@ vi.mock("sonner", () => ({
vi.mock("../builder/draft", () => ({
useResumeData: () => previewMock.builderResumeData,
useResumeStore: (selector: (state: { resumeId?: string }) => unknown) =>
selector({ resumeId: previewMock.builderResumeId }),
usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }),
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
useStylesheetStore: (
selector: (state: {
resumeId?: string;
mode: "legacy" | "semantic";
source: { languageVersion: number; text: string };
applied: { languageVersion: number; text: string };
}) => unknown,
) => selector(previewMock.stylesheet),
}));
vi.mock("./pdf-canvas", async () => {
const React = await import("react");
const pdfDocument = { numPages: 1 };
@@ -102,14 +82,7 @@ describe("ResumePreviewClient", () => {
});
beforeEach(() => {
previewMock.builderResumeId = undefined;
previewMock.builderResumeData = undefined;
previewMock.stylesheet = {
resumeId: undefined,
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
};
previewMock.toBlob.mockReset();
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
previewMock.toastError.mockReset();
@@ -135,7 +108,7 @@ describe("ResumePreviewClient", () => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, undefined);
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
});
it("keeps the rendered template identity on the active layer while its replacement renders", async () => {
@@ -159,47 +132,39 @@ describe("ResumePreviewClient", () => {
expect(activeLayer?.getAttribute("data-resume-preview-template")).toBe("azurill");
});
it("renders the canonical applied stylesheet and ignores invalid editable source", async () => {
const validApplied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
previewMock.builderResumeId = "resume-1";
previewMock.builderResumeData = sampleResumeData;
previewMock.stylesheet = {
resumeId: "resume-1",
mode: "semantic",
source: { languageVersion: 1, text: "section {" },
applied: validApplied,
it("renders the current stylesheet source from the ordinary builder draft", async () => {
const source = { languageVersion: 1, text: "section {" };
previewMock.builderResumeData = {
...sampleResumeData,
metadata: {
...sampleResumeData.metadata,
stylesheet: { mode: "semantic", source },
},
};
render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(1));
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
stylesheet: { mode: "semantic", applied: validApplied },
});
expect(previewMock.toBlob).toHaveBeenCalledWith(previewMock.builderResumeData);
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
it("keeps the active PDF visible and reports later semantic render diagnostics", async () => {
previewMock.builderResumeId = "resume-1";
it("keeps the active PDF visible and reports a later renderer failure", async () => {
previewMock.builderResumeData = sampleResumeData;
previewMock.stylesheet = {
resumeId: "resume-1",
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
};
const view = render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
previewMock.toBlob.mockRejectedValueOnce(
new Error("The semantic stylesheet could not be rendered.", {
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
}),
);
previewMock.stylesheet.applied = {
languageVersion: 1,
text: "@version 1;\nname { color: #654321; }\n",
previewMock.toBlob.mockRejectedValueOnce(new Error("PDF renderer failed"));
previewMock.builderResumeData = {
...sampleResumeData,
metadata: {
...sampleResumeData.metadata,
stylesheet: {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname { color: #654321; }\n" },
},
},
};
view.rerender(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
@@ -4,13 +4,12 @@ import type { ResolvedResumePreviewProps } from "./preview.shared";
import type { PreviewPageSize } from "./preview.shared.utils";
import { t } from "@lingui/core/macro";
import { AnimatePresence, m } from "motion/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { isRTL } from "@reactive-resume/utils/locale";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
import { usePreviewPausedStore, useResumeData, useResumeStore } from "../builder/draft";
import { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
@@ -102,17 +101,6 @@ export function ResumePreviewClient({
}: ResolvedResumePreviewProps) {
const builderResumeData = useResumeData();
const resumeData = data ?? builderResumeData;
const builderResumeId = useResumeStore((state) => state.resumeId);
const stylesheetResumeId = useStylesheetStore((state) => state.resumeId);
const mode = useStylesheetStore((state) => state.mode);
const applied = useStylesheetStore((state) => state.applied);
const presentation = useMemo(
() =>
data === undefined && builderResumeId !== undefined && stylesheetResumeId === builderResumeId
? { stylesheet: { mode, applied } }
: undefined,
[applied, builderResumeId, data, mode, stylesheetResumeId],
);
const paused = usePreviewPausedStore((state) => state.paused);
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
@@ -133,7 +121,7 @@ export function ResumePreviewClient({
const generatePdfPreview = async () => {
try {
if (cancelled || requestId !== requestIdRef.current) return;
const blob = await createResumePdfBlob(resumeData, undefined, undefined, presentation);
const blob = await createResumePdfBlob(resumeData);
if (!cancelled && requestId === requestIdRef.current) {
const nextPdf = createPreviewPdf(
@@ -162,7 +150,7 @@ export function ResumePreviewClient({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [paused, presentation, resumeData]);
}, [paused, resumeData]);
if (!resumeData) return null;
@@ -2,7 +2,6 @@
import { render, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
const pdfViewerMock = vi.hoisted(() => {
@@ -120,67 +119,25 @@ describe("PdfViewer", () => {
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
it("renders a valid public projection through the shared PDF entrypoint", async () => {
it("renders exposed semantic source through the shared PDF entrypoint", 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 refetchStyleProjection = vi.fn();
semanticData.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
};
render(
<PdfViewer
data={sampleResumeData}
stylesheetMode="semantic"
styleProjection={projection}
refetchStyleProjection={refetchStyleProjection}
publicResume={{ username: "amruth", slug: "sample" }}
/>,
);
render(<PdfViewer data={semanticData} publicResume={{ username: "amruth", slug: "sample" }} />);
await waitFor(() =>
expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
publicStyleProjection: projection,
}),
);
expect(refetchStyleProjection).not.toHaveBeenCalled();
await waitFor(() => expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(semanticData));
expect(pdfViewerMock.fetch).not.toHaveBeenCalled();
});
it("refetches a mismatched projection once before using the authorized PDF fallback", 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 mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
it("uses the authorized PDF fallback after browser generation rejects", async () => {
pdfViewerMock.createResumePdfBlob.mockRejectedValueOnce(new Error("browser renderer failed"));
const view = render(
<PdfViewer
data={sampleResumeData}
stylesheetMode="semantic"
styleProjection={mismatchedProjection}
refetchStyleProjection={refetchStyleProjection}
publicResume={{ username: "amruth", slug: "sample" }}
/>,
);
render(<PdfViewer data={sampleResumeData} publicResume={{ username: "amruth", slug: "sample" }} />);
await waitFor(() => expect(refetchStyleProjection).toHaveBeenCalledTimes(1));
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(1));
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("reason=render-data-hash");
expect(pdfViewerMock.createResumePdfBlob).not.toHaveBeenCalled();
view.rerender(
<PdfViewer
data={sampleResumeData}
stylesheetMode="semantic"
styleProjection={{ ...mismatchedProjection, renderDataHash: "1".repeat(64) }}
refetchStyleProjection={refetchStyleProjection}
publicResume={{ username: "amruth", slug: "sample" }}
/>,
);
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(2));
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
expect(pdfViewerMock.fetch).toHaveBeenCalledWith("/api/resumes/amruth/sample/pdf", { credentials: "include" });
});
});
@@ -1,6 +1,4 @@
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
import { AnnotationMode, GlobalWorkerOptions, getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
import { EventBus, LinkTarget, PDFLinkService, PDFViewer } from "pdfjs-dist/legacy/web/pdf_viewer.mjs";
@@ -17,9 +15,6 @@ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.
type PdfViewerProps = {
className?: string;
data: ResumeData;
stylesheetMode?: SemanticStylesheet["mode"];
styleProjection?: PublicStyleProjection;
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
publicResume?: {
username: string;
slug: string;
@@ -77,21 +72,11 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
}
}
export function PdfViewer({
className,
data,
stylesheetMode,
styleProjection,
refetchStyleProjection,
publicResume,
}: PdfViewerProps) {
export function PdfViewer({ className, data, publicResume }: PdfViewerProps) {
const rootRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const viewerRef = useRef<HTMLDivElement>(null);
const fileRef = useRef<Blob | null>(null);
const projectionRetryRef = useRef<{ data?: ResumeData; publicKey?: string; retried: boolean }>({
retried: false,
});
const [{ error, fileVersion, isReady, viewerHeight }, dispatch] = useReducer(
pdfViewerReducer,
INITIAL_PDF_VIEWER_STATE,
@@ -103,27 +88,8 @@ export function PdfViewer({
fileRef.current = null;
dispatch({ type: "resetForData" });
const createPdf = () => {
if (!stylesheetMode || !publicResume) return createResumePdfBlob(data);
const publicKey = `${publicResume.username}/${publicResume.slug}`;
if (projectionRetryRef.current.data !== data || projectionRetryRef.current.publicKey !== publicKey) {
projectionRetryRef.current = { data, publicKey, retried: false };
}
const retryProjection =
refetchStyleProjection && !projectionRetryRef.current.retried
? () => {
projectionRetryRef.current.retried = true;
return refetchStyleProjection();
}
: undefined;
return resolvePublicResumePdfBlob({
data,
stylesheetMode,
publicResume,
...(styleProjection ? { styleProjection } : {}),
...(retryProjection ? { refetchStyleProjection: retryProjection } : {}),
});
};
const createPdf = () =>
publicResume ? resolvePublicResumePdfBlob({ data, publicResume }) : createResumePdfBlob(data);
void createPdf()
.then((blob) => {
@@ -142,7 +108,7 @@ export function PdfViewer({
return () => {
isCancelled = true;
};
}, [data, publicResume, refetchStyleProjection, styleProjection, stylesheetMode]);
}, [data, publicResume]);
useEffect(() => {
void fileVersion;
@@ -1,5 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { resolvePublicResumePdfBlob } from "./public-pdf";
@@ -15,58 +14,35 @@ vi.mock("@/features/resume/export/pdf-document", () => ({
const publicResume = { username: "amruth", slug: "sample" };
beforeEach(() => {
mocks.createResumePdfBlob.mockClear();
mocks.fetch.mockClear();
mocks.createResumePdfBlob.mockReset();
mocks.createResumePdfBlob.mockResolvedValue(new Blob(["local"], { type: "application/pdf" }));
mocks.fetch.mockReset();
mocks.fetch.mockResolvedValue(new Response(new Blob(["server"], { type: "application/pdf" })));
vi.stubGlobal("fetch", mocks.fetch);
});
describe("resolvePublicResumePdfBlob", () => {
it("keeps legitimate legacy resumes on the local PDF path", async () => {
await resolvePublicResumePdfBlob({
data: sampleResumeData,
stylesheetMode: "legacy",
publicResume,
});
it("renders the exposed stylesheet source directly in the browser", async () => {
const data = structuredClone(sampleResumeData);
data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
};
const blob = await resolvePublicResumePdfBlob({ data, publicResume });
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(data);
expect(mocks.fetch).not.toHaveBeenCalled();
expect(await blob.text()).toBe("local");
});
it("fetches the server PDF only after browser rendering rejects", async () => {
mocks.createResumePdfBlob.mockRejectedValue(new Error("browser renderer failed"));
const blob = await resolvePublicResumePdfBlob({ data: sampleResumeData, publicResume });
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData);
expect(mocks.fetch).not.toHaveBeenCalled();
});
it("uses the authorized server fallback when a semantic projection is unavailable", async () => {
const refetchStyleProjection = vi.fn().mockRejectedValue(new Error("projection unavailable"));
await resolvePublicResumePdfBlob({
data: sampleResumeData,
stylesheetMode: "semantic",
publicResume,
refetchStyleProjection,
});
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("reason=missing-projection");
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
});
it("refetches a mismatched projection once before returning the authorized server blob", async () => {
const semanticData = structuredClone(sampleResumeData);
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
const projection = await createPublicStyleProjection({ data: semanticData });
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
const blob = await resolvePublicResumePdfBlob({
data: sampleResumeData,
stylesheetMode: "semantic",
styleProjection: mismatchedProjection,
publicResume,
refetchStyleProjection,
});
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
expect(mocks.fetch).toHaveBeenCalledTimes(1);
expect(mocks.fetch).toHaveBeenCalledWith("/api/resumes/amruth/sample/pdf", { credentials: "include" });
expect(await blob.text()).toBe("server");
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
});
});
@@ -1,97 +1,28 @@
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import {
getPublicStyleProjectionFingerprints,
PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
SEMANTIC_TREE_VERSION,
validatePublicStyleProjection,
} from "@reactive-resume/pdf/public-projection";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
export type PublicResumePdfOptions = {
stylesheetMode: SemanticStylesheet["mode"];
styleProjection?: PublicStyleProjection;
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
publicResume: {
username: string;
slug: string;
};
};
type ProjectionMismatchReason =
| "format-version"
| "language-version"
| "semantic-tree-version"
| "registry-fingerprint"
| "adapter-fingerprint"
| "render-data-hash"
| "invalid-projection";
const projectionMismatchReason = async (
data: ResumeData,
projection: PublicStyleProjection,
): Promise<ProjectionMismatchReason | null> => {
if (projection.formatVersion !== PUBLIC_STYLE_PROJECTION_FORMAT_VERSION) return "format-version";
if (projection.languageVersion !== 1) return "language-version";
if (projection.semanticTreeVersion !== SEMANTIC_TREE_VERSION) return "semantic-tree-version";
const fingerprints = await getPublicStyleProjectionFingerprints();
if (projection.registryFingerprint !== fingerprints.registryFingerprint) return "registry-fingerprint";
if (projection.adapterFingerprint !== fingerprints.adapterFingerprint) return "adapter-fingerprint";
return (await validatePublicStyleProjection(data, projection)) ? null : "render-data-hash";
};
const fetchPublicResumePdf = async (
publicResume: PublicResumePdfOptions["publicResume"],
reason: ProjectionMismatchReason | "missing-projection",
projection?: PublicStyleProjection,
) => {
const search = new URLSearchParams({
reason,
...(projection
? {
registryFingerprint: projection.registryFingerprint,
adapterFingerprint: projection.adapterFingerprint,
}
: {}),
const fetchPublicResumePdf = async ({ username, slug }: PublicResumePdfOptions["publicResume"]) => {
const response = await fetch(`/api/resumes/${encodeURIComponent(username)}/${encodeURIComponent(slug)}/pdf`, {
credentials: "include",
});
const response = await fetch(
`/api/resumes/${encodeURIComponent(publicResume.username)}/${encodeURIComponent(publicResume.slug)}/pdf?${search}`,
{ credentials: "include" },
);
if (!response.ok) throw new Error(`Public PDF fallback failed with ${response.status}`);
return response.blob();
};
export async function resolvePublicResumePdfBlob({
data,
...options
publicResume,
}: PublicResumePdfOptions & { data: ResumeData }): Promise<Blob> {
if (options.stylesheetMode === "legacy") return createResumePdfBlob(data);
let projection = options.styleProjection;
let refetched = false;
const refetch = async () => {
if (!options.refetchStyleProjection || refetched) return;
refetched = true;
try {
projection = await options.refetchStyleProjection();
} catch {
projection = undefined;
}
};
if (!projection) await refetch();
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
let reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
if (reason) {
await refetch();
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
try {
return await createResumePdfBlob(data);
} catch {
return fetchPublicResumePdf(publicResume);
}
return reason
? fetchPublicResumePdf(options.publicResume, reason, projection)
: createResumePdfBlob(data, undefined, undefined, { publicStyleProjection: projection });
}
@@ -1,6 +1,5 @@
// @vitest-environment happy-dom
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
@@ -12,30 +11,12 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
type PdfViewerProps = {
className?: string;
data: ResumeData;
stylesheetMode?: "legacy" | "semantic";
styleProjection?: PublicStyleProjection;
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
publicResume?: { username: string; slug: string };
};
const publicResumeMock = vi.hoisted(() => ({
onDownloadPDF: vi.fn(),
PdfViewer: vi.fn<(_props: PdfViewerProps) => ReactNode>(() => null),
projection: {
formatVersion: 1,
languageVersion: 1,
semanticTreeVersion: 1,
registryFingerprint: "1".repeat(64),
adapterFingerprint: "2".repeat(64),
renderDataHash: "3".repeat(64),
nodes: { resume: {} },
} as PublicStyleProjection,
refetchProjection: vi.fn(),
projectionResult: {
data: undefined as PublicStyleProjection | undefined,
isError: false,
isPending: false,
},
useResumeExport: vi.fn(),
resume: undefined as
| undefined
@@ -43,61 +24,28 @@ const publicResumeMock = vi.hoisted(() => ({
data: ResumeData;
name: string;
slug: string;
stylesheetMode: "legacy" | "semantic";
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: (options: { query: "resume" | "projection" }) =>
options.query === "resume"
? { data: publicResumeMock.resume }
: { ...publicResumeMock.projectionResult, refetch: publicResumeMock.refetchProjection },
}));
vi.mock("@tanstack/react-query", () => ({ useQuery: () => ({ data: publicResumeMock.resume }) }));
vi.mock("@tanstack/react-router", () => ({
getRouteApi: () => ({
useParams: () => ({ username: "amruth", slug: "sample" }),
}),
getRouteApi: () => ({ useParams: () => ({ username: "amruth", slug: "sample" }) }),
}));
vi.mock("./pdf-viewer", () => ({
PdfViewer: publicResumeMock.PdfViewer,
}));
vi.mock("./pdf-viewer", () => ({ PdfViewer: publicResumeMock.PdfViewer }));
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
getBySlug: { queryOptions: () => ({ query: "resume" }) },
getStyleProjection: { queryOptions: () => ({ query: "projection" }) },
},
},
orpc: { resume: { getBySlug: { queryOptions: () => ({ query: "resume" }) } } },
}));
vi.mock("@/features/resume/export/use-resume-export", () => ({
useResumeExport: publicResumeMock.useResumeExport,
}));
const { PublicResumeRoute } = await import("./public-resume");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
});
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => {
publicResumeMock.resume = {
data: sampleResumeData,
name: "Sample Resume",
slug: "sample",
stylesheetMode: "semantic",
};
publicResumeMock.projectionResult = {
data: publicResumeMock.projection,
isError: false,
isPending: false,
};
publicResumeMock.resume = { data: sampleResumeData, name: "Sample Resume", slug: "sample" };
publicResumeMock.PdfViewer.mockClear();
publicResumeMock.refetchProjection.mockReset();
publicResumeMock.refetchProjection.mockResolvedValue({ data: publicResumeMock.projection });
publicResumeMock.useResumeExport.mockReset();
publicResumeMock.useResumeExport.mockReturnValue({
onDownloadPDF: publicResumeMock.onDownloadPDF,
@@ -116,78 +64,26 @@ const renderPublicResumeRoute = () =>
);
describe("PublicResumeRoute", () => {
it("renders the public resume through the route-local PDF.js viewer", () => {
it("passes exposed source data directly to the browser viewer and export fallback", () => {
renderPublicResumeRoute();
expect(screen.getByTestId("pdf-viewer")).toHaveClass("block", "w-full");
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({ data: sampleResumeData }),
expect.objectContaining({
data: sampleResumeData,
publicResume: { username: "amruth", slug: "sample" },
}),
undefined,
);
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, {
publicResumePdf: expect.objectContaining({
stylesheetMode: "semantic",
styleProjection: publicResumeMock.projection,
publicResume: { username: "amruth", slug: "sample" },
}),
publicResumePdf: { publicResume: { username: "amruth", slug: "sample" } },
});
});
it("routes a missing semantic projection to the shared fallback seam", () => {
publicResumeMock.projectionResult = { data: undefined, isError: true, isPending: false };
renderPublicResumeRoute();
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({
stylesheetMode: "semantic",
styleProjection: undefined,
refetchStyleProjection: expect.any(Function),
publicResume: { username: "amruth", slug: "sample" },
}),
undefined,
);
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(
publicResumeMock.resume,
expect.objectContaining({
publicResumePdf: expect.objectContaining({
stylesheetMode: "semantic",
}),
}),
);
});
it("keeps missing legacy projections on the local rendering path", () => {
if (publicResumeMock.resume) publicResumeMock.resume.stylesheetMode = "legacy";
publicResumeMock.projectionResult = { data: undefined, isError: false, isPending: false };
renderPublicResumeRoute();
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({ stylesheetMode: "legacy", styleProjection: undefined }),
undefined,
);
});
it("loads the public projection and passes it to the shared viewer", () => {
renderPublicResumeRoute();
expect(publicResumeMock.PdfViewer).toHaveBeenCalledWith(
expect.objectContaining({
styleProjection: publicResumeMock.projection,
refetchStyleProjection: expect.any(Function),
publicResume: { username: "amruth", slug: "sample" },
}),
undefined,
);
});
it("lets the public resume page grow to the full PDF length", () => {
renderPublicResumeRoute();
const viewerFrame = screen.getByTestId("pdf-viewer").parentElement;
const page = viewerFrame?.parentElement;
expect(page).not.toHaveClass("min-h-svh", "h-svh", "max-h-svh", "overflow-hidden");
expect(viewerFrame).not.toHaveClass("min-h-0", "flex-1", "overflow-hidden");
});
@@ -3,7 +3,7 @@ import { Trans } from "@lingui/react/macro";
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { useCallback, useMemo } from "react";
import { useMemo } from "react";
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { LoadingScreen } from "@/components/layout/loading-screen";
@@ -17,30 +17,12 @@ export function PublicResumeRoute() {
const { username, slug } = publicResumeRoute.useParams();
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
const projectionQuery = useQuery(
orpc.resume.getStyleProjection.queryOptions({ input: { username, slug }, enabled: resume !== undefined }),
);
const styleProjection =
projectionQuery.data && Object.keys(projectionQuery.data.nodes).length > 0 ? projectionQuery.data : undefined;
const publicResume = useMemo(() => ({ username, slug }), [slug, username]);
const refetchStyleProjection = useCallback(async () => {
const result = await projectionQuery.refetch();
return result.data;
}, [projectionQuery.refetch]);
const { onDownloadPDF, isExporting } = useResumeExport(resume, {
...(resume
? {
publicResumePdf: {
stylesheetMode: resume.stylesheetMode,
publicResume,
refetchStyleProjection,
...(styleProjection ? { styleProjection } : {}),
},
}
: {}),
...(resume ? { publicResumePdf: { publicResume } } : {}),
});
if (!resume || projectionQuery.isPending) return <LoadingScreen />;
if (!resume) return <LoadingScreen />;
const { basics, picture } = resume.data;
@@ -66,14 +48,7 @@ export function PublicResumeRoute() {
</header>
<main className="w-full max-w-5xl bg-white print:max-w-full">
<PdfViewer
data={resume.data}
className="block w-full"
stylesheetMode={resume.stylesheetMode}
styleProjection={styleProjection}
publicResume={publicResume}
refetchStyleProjection={refetchStyleProjection}
/>
<PdfViewer data={resume.data} className="block w-full" publicResume={publicResume} />
</main>
<footer className="flex justify-center print:hidden">
@@ -1,18 +1,30 @@
// @vitest-environment happy-dom
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import type { SemanticCssDiagnostic, StyleProgram } from "@reactive-resume/resume/stylesheet";
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { EditorView } from "@codemirror/view";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
import { LegacyStylesheetBanner } from "./legacy-banner";
import { StylesheetStatus } from "./status";
import { useStylesheetStore } from "./store";
const media = vi.hoisted(() => ({ mobile: false }));
const compileWorker = vi.hoisted(() => ({
program: { languageVersion: 1, rules: [] } as StyleProgram | null,
diagnostics: [] as SemanticCssDiagnostic[],
}));
const builder = vi.hoisted(() => ({
canUndo: false,
canRedo: false,
data: undefined as typeof defaultResumeData | undefined,
isLocked: false,
undo: vi.fn(),
redo: vi.fn(),
}));
vi.mock("usehooks-ts", async (importOriginal) => ({
...(await importOriginal<typeof import("usehooks-ts")>()),
@@ -23,16 +35,52 @@ vi.mock("@/features/theme/provider", () => ({
useTheme: () => ({ theme: "light" }),
}));
const error: SemanticCssDiagnostic = {
code: "SEMANTIC_CSS_UNKNOWN_PROPERTY",
vi.mock("@/features/resume/builder/draft", () => ({
useResumeData: () => builder.data,
useIsResumeLocked: () => builder.isLocked,
useUpdateResumeData: () => (update: (draft: typeof defaultResumeData) => void) => {
if (builder.data) update(builder.data);
},
useResumeStore: (selector: (state: object) => unknown) =>
selector({ canUndo: builder.canUndo, canRedo: builder.canRedo, undo: builder.undo, redo: builder.redo }),
}));
vi.mock("./worker-client", () => ({
createCompileWorkerClient: () => ({
compile: vi.fn(async ({ editGeneration }: { editGeneration: number }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: compileWorker.program,
diagnostics: compileWorker.diagnostics,
colorTokens: [],
})),
destroy: vi.fn(),
}),
}));
const recoverableError: SemanticCssDiagnostic = {
code: "INVALID_VALUE",
severity: "error",
message: "Unknown property",
message: "Invalid value",
range: {
start: { line: 2, column: 3, offset: 17 },
end: { line: 2, column: 9, offset: 23 },
},
};
const fatalError: SemanticCssDiagnostic = {
...recoverableError,
code: "VERSION_MISMATCH",
message: "Version mismatch",
};
const resolutionError: SemanticCssDiagnostic = {
...recoverableError,
code: "UNRESOLVED_VARIABLE",
message: "Undefined variable --missing",
};
const guideName = /read the applying custom styles guide.*opens in new tab/i;
const expectGuideLink = (root: HTMLElement) => {
@@ -47,28 +95,48 @@ beforeAll(() => {
Object.defineProperty(Element.prototype, "getAnimations", { configurable: true, value: () => [] });
});
beforeEach(() => {
media.mobile = false;
builder.data = structuredClone(defaultResumeData);
builder.isLocked = false;
builder.canUndo = false;
builder.canRedo = false;
builder.undo.mockReset();
builder.redo.mockReset();
compileWorker.program = { languageVersion: 1, rules: [] };
compileWorker.diagnostics = [];
});
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
describe("stylesheet editor status", () => {
it("shows that invalid source keeps the last valid preview", () => {
renderWithI18n(<StylesheetStatus mode="semantic" status="error" diagnostics={[error]} />);
it("explains that fatal source falls back to base styles", () => {
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[fatalError]} />);
expect(screen.getByText(/preview and export use the last valid version/i)).toBeInTheDocument();
expect(screen.getByText("Unknown property")).toBeInTheDocument();
expect(screen.getByText(/preview and export fall back to base styles/i)).toBeInTheDocument();
expect(screen.getByText("Version mismatch")).toBeInTheDocument();
});
it("explains that recoverable errors preserve valid styles", () => {
renderWithI18n(<StylesheetStatus mode="semantic" status="idle" diagnostics={[recoverableError]} />);
expect(screen.getByText("Valid with errors")).toBeInTheDocument();
expect(screen.getByText(/preview and export keep valid styles/i)).toBeInTheDocument();
expect(screen.queryByText(/fall back to base styles/i)).not.toBeInTheDocument();
});
it("labels a valid legacy draft as ready to activate", () => {
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[]} />);
expect(screen.getByText("Ready to activate")).toBeInTheDocument();
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
});
it("labels legacy warnings without claiming they are applied", () => {
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...error, severity: "warning" }]} />);
it("labels a legacy draft with warnings as ready to activate", () => {
renderWithI18n(
<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...recoverableError, severity: "warning" }]} />,
);
expect(screen.getByText("Ready to activate with warnings")).toBeInTheDocument();
expect(screen.queryByText("Applied with warnings")).not.toBeInTheDocument();
});
it("disables activation while the converted draft has errors", () => {
@@ -113,7 +181,7 @@ describe("StylesheetCodeEditor", () => {
<StylesheetCodeEditor
value={"@version 1;\nsection { color: red; }\n"}
{...props}
diagnostics={[error]}
diagnostics={[recoverableError]}
theme="dark"
readOnly
/>
@@ -173,18 +241,105 @@ describe("StylesheetEditorShell", () => {
expectGuideLink(container);
});
it("makes the editor and mutation controls read-only while a restore is pending", () => {
media.mobile = false;
useStylesheetStore.setState({
mode: "legacy",
it("has no apply or save action for an already-semantic stylesheet", async () => {
if (!builder.data) throw new Error("Missing resume fixture");
builder.data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
};
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
await screen.findByText("Valid");
expect(screen.queryByRole("button", { name: /activate semantic css/i })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: /save|apply/i })).not.toBeInTheDocument();
});
it("writes semantic edits into the ordinary resume draft immediately", () => {
if (!builder.data) throw new Error("Missing resume fixture");
builder.data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
};
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
const textbox = screen.getByRole("textbox", { name: "Semantic CSS stylesheet" });
const view = EditorView.findFromDOM(textbox);
if (!view) throw new Error("Missing editor view");
act(() => view.dispatch({ changes: { from: view.state.doc.length, insert: "name { color: blue; }\n" } }));
expect(builder.data.metadata.stylesheet.source.text).toBe("@version 1;\nname { color: blue; }\n");
});
it("switches a converted legacy draft through the ordinary resume update", async () => {
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
await waitFor(() => expect(activate).toBeEnabled());
fireEvent.click(activate);
expect(builder.data?.metadata.stylesheet).toEqual({
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
diagnostics: [],
status: "idle",
canUndo: true,
canRedo: true,
restoreLocked: true,
});
});
it("allows a converted legacy draft with recoverable errors to activate", async () => {
compileWorker.diagnostics = [resolutionError];
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
const activate = await screen.findByRole("button", { name: "Activate Semantic CSS" });
await waitFor(() => expect(activate).toBeEnabled());
fireEvent.click(activate);
expect(builder.data?.metadata.stylesheet?.mode).toBe("semantic");
});
it("keeps legacy activation disabled for fatal diagnostics", async () => {
compileWorker.diagnostics = [fatalError];
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
await screen.findByText("Fatal error");
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
});
it("makes editor mutation controls read-only while the resume is locked", () => {
builder.isLocked = true;
builder.canUndo = true;
builder.canRedo = true;
render(
<I18nProvider i18n={i18n}>
@@ -202,19 +357,10 @@ describe("StylesheetEditorShell", () => {
expect(screen.getByRole("button", { name: "Undo stylesheet edit" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Redo stylesheet edit" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Format stylesheet" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Reset to applied stylesheet" })).toBeDisabled();
});
it("moves the only visible editor into a titled mobile sheet", async () => {
media.mobile = true;
useStylesheetStore.setState({
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
diagnostics: [{ ...error, severity: "warning" }],
status: "idle",
restoreLocked: false,
});
const { container } = render(
<I18nProvider i18n={i18n}>
@@ -231,8 +377,7 @@ describe("StylesheetEditorShell", () => {
expect(within(sheet).getByRole("heading", { name: "Semantic CSS stylesheet" })).toBeInTheDocument();
expect(within(sheet).getByRole("button", { name: "Activate Semantic CSS" })).toBeInTheDocument();
expect(within(sheet).getByRole("toolbar", { name: "Stylesheet editor" })).toBeInTheDocument();
expect(within(sheet).getByText("Ready to activate with warnings")).toBeInTheDocument();
expect(within(sheet).getByText("Unknown property")).toBeInTheDocument();
await within(sheet).findByText("Ready to activate");
expectGuideLink(sheet);
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
media.mobile = false;
@@ -1,5 +1,7 @@
import type { Extension } from "@codemirror/state";
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import type { SemanticCssDiagnostic, SemanticNode } 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 { SemanticCssColorToken } from "./color-tokens";
import type { SemanticCssEditorMetadata } from "./protocol";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
@@ -17,11 +19,20 @@ import {
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { BookOpenIcon } from "@phosphor-icons/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useMediaQuery } from "usehooks-ts";
import { convertLegacyStyleRules } from "@reactive-resume/pdf/semantic-legacy";
import {
buildSemanticTree,
getTemplateSemanticManifest,
semanticNodeKeys,
shouldShowResumeHeader,
} from "@reactive-resume/pdf/semantic-tree";
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
import { ColorPicker } from "@/components/input/color-picker";
import { useIsResumeLocked, useResumeData, useResumeStore, useUpdateResumeData } from "@/features/resume/builder/draft";
import { useTheme } from "@/features/theme/provider";
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
@@ -29,8 +40,8 @@ import { enterStylesheetFocusMode } from "./focus-mode";
import { formatEditorDocument } from "./formatter";
import { LegacyStylesheetBanner } from "./legacy-banner";
import { StylesheetStatus } from "./status";
import { useStylesheetStore } from "./store";
import { StylesheetToolbar } from "./toolbar";
import { createCompileWorkerClient } from "./worker-client";
const externalReplacement = Annotation.define<boolean>();
const emptyMetadata: SemanticCssEditorMetadata = {
@@ -314,30 +325,78 @@ type StylesheetEditorShellProps = {
readOnly?: boolean;
};
const pageDimensions = (data: ResumeData) => {
const size = data.metadata.page.format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
return data.metadata.layout.pages.map((_page, index) => ({
pageKey: semanticNodeKeys.page(index + 1),
...size,
}));
};
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
const pages = data.metadata.layout.pages.map((page, index) =>
buildSemanticTree({
data,
template: data.metadata.template,
page,
pageNumber: index + 1,
showHeader: shouldShowResumeHeader(data, index),
}),
);
const semanticTree: SemanticNode = {
key: semanticNodeKeys.resume(),
kind: "resume",
attributes: { template: data.metadata.template },
roles: [],
children: pages.flatMap(({ children }) => children),
};
return {
semanticTree,
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
};
};
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
const { theme } = useTheme();
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const [focusOpen, setFocusOpen] = useState(false);
const [diagnostics, setDiagnostics] = useState<readonly SemanticCssDiagnostic[]>([]);
const [colorTokens, setColorTokens] = useState<readonly SemanticCssColorToken[]>([]);
const [status, setStatus] = useState<"idle" | "compiling" | "error">("compiling");
const [compiler, setCompiler] = useState<ReturnType<typeof createCompileWorkerClient>>();
const restoreDesktopRef = useRef<(() => void) | null>(null);
const mode = useStylesheetStore((state) => state.mode);
const source = useStylesheetStore((state) => state.source.text);
const applied = useStylesheetStore((state) => state.applied.text);
const diagnostics = useStylesheetStore((state) => state.diagnostics);
const colorTokens = useStylesheetStore((state) => state.colorTokens);
const metadata = useStylesheetStore((state) => state.editorMetadata);
const status = useStylesheetStore((state) => state.status);
const restoreLocked = useStylesheetStore((state) => state.restoreLocked);
const canUndo = useStylesheetStore((state) => state.canUndo);
const canRedo = useStylesheetStore((state) => state.canRedo);
const setSourceText = useStylesheetStore((state) => state.setSourceText);
const setFocused = useStylesheetStore((state) => state.setFocused);
const activate = useStylesheetStore((state) => state.activate);
const undo = useStylesheetStore((state) => state.undo);
const redo = useStylesheetStore((state) => state.redo);
const refreshIntelligence = useStylesheetStore((state) => state.refreshIntelligence);
const data = useResumeData();
const updateResumeData = useUpdateResumeData();
const isLocked = useIsResumeLocked();
const canUndo = useResumeStore((state) => state.canUndo);
const canRedo = useResumeStore((state) => state.canRedo);
const undo = useResumeStore((state) => state.undo);
const redo = useResumeStore((state) => state.redo);
const editorViewRef = useRef<EditorView | null>(null);
const hasErrors = status === "error" || diagnostics.some(({ severity }) => severity === "error");
const isChecking = status === "compiling" || status === "preflighting" || status === "saving";
const compileGenerationRef = useRef(0);
useEffect(() => {
const client = createCompileWorkerClient(
() =>
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), {
type: "module",
name: "semantic-css-compiler",
}),
);
setCompiler(client);
return () => client.destroy();
}, []);
const stylesheet = data?.metadata.stylesheet;
const mode = stylesheet?.mode ?? "legacy";
const source = useMemo<StylesheetSource>(
() =>
stylesheet?.source ??
(data ? convertLegacyStyleRules(data).source : { languageVersion: 1, text: "@version 1;\n" }),
[data, stylesheet],
);
const metadata = useMemo(() => (data ? createEditorMetadata(data) : emptyMetadata), [data]);
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
const isChecking = status === "compiling";
const disabled = readOnly || isLocked;
useEffect(
() => () => {
@@ -347,8 +406,59 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
);
useEffect(() => {
refreshIntelligence();
}, [refreshIntelligence]);
if (!compiler || !data) return;
let cancelled = false;
const editGeneration = ++compileGenerationRef.current;
setStatus("compiling");
setColorTokens([]);
const timer = window.setTimeout(() => {
void compiler
.compile({
editGeneration,
source,
semanticTree: metadata.semanticTree,
baseSettings: {
picture: data.picture,
template: data.metadata.template,
design: data.metadata.design,
typography: data.metadata.typography,
page: data.metadata.page,
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
},
pages: pageDimensions(data),
})
.then((result) => {
if (cancelled || result.editGeneration !== compileGenerationRef.current) return;
setDiagnostics(result.diagnostics);
setColorTokens(result.colorTokens ?? []);
setStatus(result.program && !result.diagnostics.some(isFatalStylesheetDiagnostic) ? "idle" : "error");
})
.catch(() => {
if (!cancelled && editGeneration === compileGenerationRef.current) setStatus("error");
});
}, 180);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [compiler, data, metadata, source]);
if (!data) return null;
const setSourceText = (text: string) => {
if (disabled || text === source.text) return;
updateResumeData((draft) => {
draft.metadata.stylesheet = { mode, source: { ...source, text } };
});
};
const activate = () => {
if (disabled || mode === "semantic" || hasFatalErrors || isChecking) return;
updateResumeData((draft) => {
draft.metadata.stylesheet = { mode: "semantic", source };
});
};
const toggleFocus = () => {
if (isMobile) {
@@ -374,15 +484,14 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
const editor = (
<StylesheetCodeEditor
value={source}
value={source.text}
diagnostics={diagnostics}
colorTokens={colorTokens}
metadata={metadata}
theme={theme}
readOnly={readOnly || restoreLocked}
readOnly={disabled}
label={t`Semantic CSS stylesheet`}
onChange={setSourceText}
onFocusChange={setFocused}
onReady={(view) => {
editorViewRef.current = view;
}}
@@ -393,22 +502,21 @@ function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps)
const editorChrome = (
<div className="space-y-3">
{mode === "legacy" && (
<LegacyStylesheetBanner disabled={restoreLocked || hasErrors || isChecking} onActivate={activate} />
<LegacyStylesheetBanner disabled={disabled || hasFatalErrors || isChecking} onActivate={activate} />
)}
<StylesheetToolbar
source={source}
source={source.text}
canUndo={canUndo}
canRedo={canRedo}
focused={focusOpen}
disabled={restoreLocked}
disabled={disabled}
onUndo={undo}
onRedo={redo}
onFormat={() => {
const view = editorViewRef.current;
if (view) void formatEditorDocument(view).catch(() => undefined);
}}
onReset={() => setSourceText(applied)}
onFocusToggle={toggleFocus}
/>
@@ -1,62 +0,0 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { inspectPdfPageCount } from "./pdf-inspection";
const mocks = vi.hoisted(() => ({
workerDestroy: vi.fn(),
getDocument: vi.fn(),
}));
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
PDFWorker: class {
destroy = mocks.workerDestroy;
},
getDocument: mocks.getDocument,
}));
describe("inspectPdfPageCount", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("inspects a copy through a nested worker without detaching the result buffer", async () => {
const destroy = vi.fn();
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
mocks.getDocument.mockReturnValue({ promise: Promise.resolve({ numPages: 3 }), destroy });
const pdf = Uint8Array.of(1, 2, 3, 4).buffer;
await expect(inspectPdfPageCount(pdf, () => nestedWorker)).resolves.toBe(3);
expect(mocks.getDocument).toHaveBeenCalledWith(
expect.objectContaining({ data: expect.any(ArrayBuffer), worker: expect.any(Object) }),
);
const inspectedPdf = mocks.getDocument.mock.calls[0]?.[0].data as ArrayBuffer;
expect(inspectedPdf).not.toBe(pdf);
expect(Array.from(new Uint8Array(inspectedPdf))).toEqual([1, 2, 3, 4]);
expect(Array.from(new Uint8Array(pdf))).toEqual([1, 2, 3, 4]);
expect(destroy).toHaveBeenCalledOnce();
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
});
it("destroys the loading task and nested worker when parsing fails", async () => {
const destroy = vi.fn();
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
mocks.getDocument.mockReturnValue({ promise: Promise.reject(new Error("invalid PDF")), destroy });
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("invalid PDF");
expect(destroy).toHaveBeenCalledOnce();
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
});
it("terminates the nested worker even when loading-task cleanup fails", async () => {
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
mocks.getDocument.mockReturnValue({
promise: Promise.resolve({ numPages: 1 }),
destroy: vi.fn().mockRejectedValue(new Error("cleanup failed")),
});
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("cleanup failed");
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
});
});
@@ -1,39 +0,0 @@
let pdfModule: Promise<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>;
const loadPdfModule = () => (pdfModule ??= import("pdfjs-dist/legacy/build/pdf.mjs"));
const createNestedWorker = () =>
new Worker(new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url), {
type: "module",
name: "semantic-css-pdfjs",
});
export async function initializePdfInspection(): Promise<void> {
await loadPdfModule();
}
export async function inspectPdfPageCount(
pdf: ArrayBuffer,
createWorker: () => Worker = createNestedWorker,
): Promise<number> {
const { PDFWorker, getDocument } = await loadPdfModule();
const nestedWorker = createWorker();
const WorkerWithPort = PDFWorker as unknown as new (options: { port: Worker }) => InstanceType<typeof PDFWorker>;
const worker = new WorkerWithPort({ port: nestedWorker });
let loadingTask: ReturnType<typeof getDocument> | undefined;
try {
// PDF.js transfers its input to the nested worker and detaches the buffer.
// Keep the caller's buffer intact so preflight can return those same bytes.
loadingTask = getDocument({ data: pdf.slice(0), worker });
const document = await loadingTask.promise;
return document.numPages;
} finally {
try {
if (loadingTask) await loadingTask.destroy();
else worker.destroy();
} finally {
nestedWorker.terminate();
}
}
}
@@ -1,96 +0,0 @@
import type { PreflightWorkerRequest } from "./protocol";
import { afterEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
renderPreflightPdf: vi.fn(),
initializePdfInspection: vi.fn(async () => undefined),
inspectPdfPageCount: vi.fn(),
}));
vi.mock("@reactive-resume/pdf/preflight", () => ({
renderPreflightPdf: mocks.renderPreflightPdf,
}));
vi.mock("./pdf-inspection", () => ({
initializePdfInspection: mocks.initializePdfInspection,
inspectPdfPageCount: mocks.inspectPdfPageCount,
}));
describe("stylesheet preflight worker", () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
it("serializes schema errors into a correlated preflight error packet", async () => {
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
const postMessage = vi.fn();
vi.stubGlobal("self", {
postMessage,
addEventListener: vi.fn((_type, listener) => {
handler = listener as typeof handler;
}),
});
const issues = [{ path: ["customSections", 0, "items", 0, "company"] }];
mocks.renderPreflightPdf.mockRejectedValueOnce(
Object.assign(new Error("Invalid resume data"), { name: "ZodError", issues }),
);
vi.resetModules();
await import("./preflight.worker");
await handler?.({
data: {
type: "preflight",
requestId: 7,
editGeneration: 3,
input: {} as never,
limits: {} as never,
},
} as unknown as MessageEvent<PreflightWorkerRequest>);
expect(postMessage).toHaveBeenCalledWith({
type: "preflight_error",
requestId: 7,
editGeneration: 3,
cause: { name: "ZodError", message: "Invalid resume data", issues },
});
});
it("includes a sanitized cause when PDF preflight throws an unexpected error", async () => {
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
const postMessage = vi.fn();
vi.stubGlobal("self", {
postMessage,
addEventListener: vi.fn((_type, listener) => {
handler = listener as typeof handler;
}),
});
mocks.renderPreflightPdf.mockRejectedValueOnce(new Error("Canvas is already closed"));
vi.resetModules();
await import("./preflight.worker");
await handler?.({
data: {
type: "preflight",
requestId: 8,
editGeneration: 4,
input: {} as never,
limits: {} as never,
},
} as unknown as MessageEvent<PreflightWorkerRequest>);
expect(postMessage).toHaveBeenCalledWith(
expect.objectContaining({
type: "preflight_result",
requestId: 8,
editGeneration: 4,
result: expect.objectContaining({
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: expect.stringContaining("Canvas is already closed"),
diagnostics: [],
}),
}),
);
});
});
@@ -1,101 +0,0 @@
/// <reference lib="webworker" />
import type { PdfPreflightFailure } from "@reactive-resume/pdf/preflight";
import type {
PreflightWorkerError,
PreflightWorkerRequest,
PreflightWorkerResponse,
SerializedPreflightCause,
} from "./protocol";
import { Buffer } from "buffer";
import { initializePdfInspection, inspectPdfPageCount } from "./pdf-inspection";
import { getPreflightTransferables } from "./protocol";
Object.assign(globalThis, { Buffer });
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
ok: false,
code,
message,
diagnostics: [],
});
const sanitizeWorkerCause = (cause: unknown): string => {
if (!(cause instanceof Error)) return "The PDF preflight worker failed.";
const detail = cause.message.replace(/\s+/g, " ").trim().slice(0, 200);
if (!detail) return "The PDF preflight worker failed.";
return `The PDF preflight worker failed. (${cause.name}: ${detail})`;
};
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
return;
}
return { name: cause.name, message: cause.message, issues: cause.issues };
};
const initialization = Promise.all([import("@reactive-resume/pdf/preflight"), initializePdfInspection()] as const);
void initialization.then(() => self.postMessage({ type: "preflight_ready" }));
self.addEventListener("message", async ({ data }: MessageEvent<PreflightWorkerRequest>) => {
if (data.type !== "preflight") return;
const [{ renderPreflightPdf }] = await initialization;
let rendered: Awaited<ReturnType<typeof renderPreflightPdf>>;
try {
rendered = await renderPreflightPdf(data.input, data.limits);
} catch (cause) {
const serializedCause = serializeZodCause(cause);
if (serializedCause) {
const response: PreflightWorkerError = {
type: "preflight_error",
requestId: data.requestId,
editGeneration: data.editGeneration,
cause: serializedCause,
};
self.postMessage(response);
return;
}
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", sanitizeWorkerCause(cause));
const response: PreflightWorkerResponse = {
type: "preflight_result",
requestId: data.requestId,
editGeneration: data.editGeneration,
result,
};
self.postMessage(response);
return;
}
let result: PreflightWorkerResponse["result"];
if (!rendered.ok) {
result = rendered;
} else if (rendered.bytes.byteLength > data.limits.maxBytes) {
result = failure("STYLESHEET_PREFLIGHT_BYTE_LIMIT", "The PDF exceeds the preflight byte limit.");
} else {
try {
const pdf = Uint8Array.from(rendered.bytes).buffer;
const pageCount = await inspectPdfPageCount(pdf);
result =
pageCount > data.limits.maxPages
? failure("STYLESHEET_PREFLIGHT_PAGE_LIMIT", "The PDF exceeds the preflight page limit.")
: {
ok: true,
pageCount,
byteCount: pdf.byteLength,
diagnostics: rendered.diagnostics,
pdf,
};
} catch {
result = failure("STYLESHEET_PREFLIGHT_PARSE_FAILED", "The generated PDF could not be inspected.");
}
}
const response: PreflightWorkerResponse = {
type: "preflight_result",
requestId: data.requestId,
editGeneration: data.editGeneration,
result,
};
self.postMessage(response, { transfer: getPreflightTransferables(response) });
});
@@ -1,8 +1,3 @@
import type {
BrowserPdfPreflightResult,
PdfPreflightPageLimits,
StylesheetPreflightInput,
} from "@reactive-resume/pdf/preflight";
import type {
AuthoredPageContext,
BaseSettingsSnapshot,
@@ -39,47 +34,3 @@ export type CompileWorkerResponse = {
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens?: readonly SemanticCssColorToken[];
};
type PreflightLimits = PdfPreflightPageLimits & {
maxPages: number;
maxBytes: number;
};
export type PreflightWorkerInput = {
editGeneration: number;
input: StylesheetPreflightInput;
limits: PreflightLimits;
};
export type PreflightWorkerRequest = PreflightWorkerInput & {
type: "preflight";
requestId: number;
};
export type PreflightWorkerResponse = {
type: "preflight_result";
requestId: number;
editGeneration: number;
result: BrowserPdfPreflightResult;
};
export type SerializedPreflightCause = {
name: string;
message: string;
issues: readonly unknown[];
};
export type PreflightWorkerError = {
type: "preflight_error";
requestId: number;
editGeneration: number;
cause: SerializedPreflightCause;
};
export type PreflightWorkerReady = {
type: "preflight_ready";
};
export function getPreflightTransferables(response: PreflightWorkerResponse): Transferable[] {
return response.result.ok ? [response.result.pdf] : [];
}
@@ -1,155 +0,0 @@
// @vitest-environment happy-dom
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
const mocks = vi.hoisted(() => ({
getState: vi.fn(),
workers: [] as FakeWorker[],
}));
class FakeWorker {
terminated = false;
constructor() {
mocks.workers.push(this);
}
postMessage() {}
addEventListener() {}
removeEventListener() {}
terminate() {
this.terminated = true;
}
}
vi.mock("@/libs/orpc/client", () => ({
orpc: {
resume: {
stylesheet: {
getState: { call: mocks.getState },
mutate: { call: vi.fn() },
},
},
},
}));
const stylesheet = (text: string): SemanticStylesheet => {
const source = { languageVersion: 1, text };
return { mode: "semantic", source, applied: source };
};
describe("stylesheet store reinitialization", () => {
beforeEach(() => {
mocks.workers.length = 0;
vi.stubGlobal("Worker", FakeWorker);
});
afterEach(() => vi.unstubAllGlobals());
it("suspends edits while a delayed restore is pending, then atomically installs the restored state", async () => {
const storeModule = await import("./store");
const cleanup = storeModule.initializeStylesheetStore({
resumeId: "resume-1",
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
resumeData: defaultResumeData,
});
let finishRestore: (() => void) | undefined;
const delayedRestore = new Promise<void>((resolve) => {
finishRestore = resolve;
});
const restore = async () => {
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
await delayedRestore;
return storeModule.replaceStylesheetStoreAfterRestore({
resumeId: "resume-1",
resumeData: defaultResumeData,
initial: { stylesheet: stylesheet("restored"), revision: 9, renderDataVersion: 12 },
token,
});
};
const pendingRestore = restore();
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
const editGeneration = storeModule.useStylesheetStore.getState().editGeneration;
storeModule.useStylesheetStore.getState().setSourceText("edit while restoring");
storeModule.useStylesheetStore.getState().deactivate();
storeModule.useStylesheetStore.getState().undo();
expect(storeModule.useStylesheetStore.getState().source.text).toBe("old");
expect(storeModule.useStylesheetStore.getState().editGeneration).toBe(editGeneration);
finishRestore?.();
const replaced = await pendingRestore;
expect(replaced).toBe(true);
expect(mocks.getState).not.toHaveBeenCalled();
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
resumeId: "resume-1",
source: { text: "restored" },
applied: { text: "restored" },
revision: 9,
renderDataVersion: 12,
restoreLocked: false,
});
storeModule.useStylesheetStore.getState().setSourceText("later edit");
expect(storeModule.useStylesheetStore.getState().source.text).toBe("later edit");
expect(mocks.workers).toHaveLength(4);
expect(mocks.workers.slice(0, 2).every((worker) => worker.terminated)).toBe(true);
cleanup();
expect(mocks.workers.slice(2).every((worker) => worker.terminated)).toBe(true);
expect(storeModule.useStylesheetStore.getState().resumeId).toBeUndefined();
});
it("unlocks interaction after a restore request fails", async () => {
const storeModule = await import("./store");
const cleanup = storeModule.initializeStylesheetStore({
resumeId: "resume-1",
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
resumeData: defaultResumeData,
});
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
expect(storeModule.unlockStylesheetStoreAfterRestore(token)).toBe(true);
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(false);
storeModule.useStylesheetStore.getState().setSourceText("edit after failure");
expect(storeModule.useStylesheetStore.getState().source.text).toBe("edit after failure");
cleanup();
});
it("ignores a stale same-resume restore completion after away-and-back runtime replacement", async () => {
const storeModule = await import("./store");
const cleanupFirst = storeModule.initializeStylesheetStore({
resumeId: "resume-1",
initial: { stylesheet: stylesheet("first"), revision: 1, renderDataVersion: 1 },
resumeData: defaultResumeData,
});
const staleToken = storeModule.lockStylesheetStoreForRestore("resume-1");
cleanupFirst();
const cleanupSecond = storeModule.initializeStylesheetStore({
resumeId: "resume-1",
initial: { stylesheet: stylesheet("second"), revision: 2, renderDataVersion: 2 },
resumeData: defaultResumeData,
});
const replaced = storeModule.replaceStylesheetStoreAfterRestore({
resumeId: "resume-1",
resumeData: defaultResumeData,
initial: { stylesheet: stylesheet("stale restore"), revision: 3, renderDataVersion: 3 },
token: staleToken,
});
expect(replaced).toBe(false);
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
source: { text: "second" },
revision: 2,
renderDataVersion: 2,
});
cleanupSecond();
});
});
@@ -1,50 +1,67 @@
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Trans } from "@lingui/react/macro";
import { WarningCircleIcon, WarningIcon } from "@phosphor-icons/react";
import { isFatalStylesheetDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { Badge } from "@reactive-resume/ui/components/badge";
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
export type StylesheetStatusProps = {
mode: "legacy" | "semantic";
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
status: "idle" | "compiling" | "error";
diagnostics: readonly SemanticCssDiagnostic[];
};
export function StylesheetStatus({ mode, status, diagnostics }: StylesheetStatusProps) {
const errors = diagnostics.filter(({ severity }) => severity === "error");
const warnings = diagnostics.filter(({ severity }) => severity === "warning");
const hasErrors = status === "error" || errors.length > 0;
const isPending = status === "compiling" || status === "preflighting" || status === "saving";
const hasFatalErrors = status === "error" || diagnostics.some(isFatalStylesheetDiagnostic);
const hasRecoverableErrors = !hasFatalErrors && errors.length > 0;
const isPending = status === "compiling";
return (
<div className="space-y-2" aria-live="polite">
{hasErrors ? (
{hasFatalErrors ? (
<Badge variant="destructive">
<WarningCircleIcon data-icon="inline-start" />
<Trans>Error</Trans>
<Trans>Fatal error</Trans>
</Badge>
) : isPending ? (
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</Trans>}</Badge>
) : hasRecoverableErrors ? (
<Badge variant="secondary">
<WarningCircleIcon data-icon="inline-start" />
{mode === "legacy" ? <Trans>Ready to activate with errors</Trans> : <Trans>Valid with errors</Trans>}
</Badge>
) : warnings.length > 0 ? (
<Badge variant="secondary">
<WarningIcon data-icon="inline-start" />
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Applied with warnings</Trans>}
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Valid with warnings</Trans>}
</Badge>
) : (
<Badge variant="secondary">
{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Applied</Trans>}
</Badge>
<Badge variant="secondary">{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Valid</Trans>}</Badge>
)}
{hasErrors && (
{hasFatalErrors && (
<Alert variant="destructive">
<WarningCircleIcon />
<AlertTitle>
<Trans>Stylesheet has errors</Trans>
<Trans>Stylesheet has fatal errors</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview and export use the last valid version.</Trans>
<Trans>Preview and export fall back to base styles.</Trans>
</AlertDescription>
</Alert>
)}
{hasRecoverableErrors && (
<Alert>
<WarningCircleIcon />
<AlertTitle>
<Trans>Some styles were ignored</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview and export keep valid styles and ignore invalid styles.</Trans>
</AlertDescription>
</Alert>
)}
@@ -1,940 +0,0 @@
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createStylesheetStoreRuntime } from "./store";
const source = (text: string): StylesheetSource => ({ languageVersion: 1, text });
const stylesheet = (text: string): SemanticStylesheet => ({
mode: "semantic",
source: source(text),
applied: source(text),
});
const initial = {
stylesheet: stylesheet("generation zero"),
revision: 3,
renderDataVersion: 7,
};
type MutationResult = {
stylesheet: SemanticStylesheet;
revision: number;
renderDataVersion: number;
editGeneration: number;
diagnostics: [];
};
describe("stylesheet store runtime", () => {
beforeEach(() => vi.useFakeTimers());
it("clears compiler-confirmed color tokens synchronously when same-length source text changes", () => {
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
resumeData: defaultResumeData,
debounceMs: 1_000_000,
compile: vi.fn(),
preflight: vi.fn(),
mutate: vi.fn(),
});
runtime.store.setState({ colorTokens: [{ from: 17, to: 20, value: "red" }] });
runtime.store.getState().setSourceText("section { color: var; }");
expect(runtime.store.getState().source.text).toBe("section { color: var; }");
expect(runtime.store.getState().colorTokens).toEqual([]);
});
it("rejects delayed editor intelligence for a canonically replaced source", async () => {
let resolveCompile!: (value: {
type: "compile_result";
requestId: number;
editGeneration: number;
program: { languageVersion: number; rules: [] };
diagnostics: [
{
code: string;
severity: "error";
message: string;
range: {
start: { line: number; column: number; offset: number };
end: { line: number; column: number; offset: number };
};
},
];
colorTokens: [{ from: number; to: number; value: string }];
}) => void;
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
resumeData: defaultResumeData,
compile: () => new Promise((resolve) => (resolveCompile = resolve)),
preflight: vi.fn(),
mutate: vi.fn(),
});
runtime.store.getState().refreshIntelligence();
runtime.rebaseCanonical({
stylesheet: stylesheet("section { color: blue; }"),
revision: 4,
renderDataVersion: 7,
});
resolveCompile({
type: "compile_result",
requestId: 1,
editGeneration: 0,
program: { languageVersion: 1, rules: [] },
diagnostics: [
{
code: "OLD_SOURCE",
severity: "error",
message: "Old source diagnostic",
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 2, offset: 1 },
},
},
],
colorTokens: [{ from: 17, to: 20, value: "red" }],
});
await Promise.resolve();
await Promise.resolve();
expect(runtime.store.getState()).toMatchObject({
source: source("section { color: blue; }"),
diagnostics: [],
colorTokens: [],
});
});
it("consumes stale acknowledgements before saving the replaceable pending edit", async () => {
const resolvers: Array<(value: MutationResult) => void> = [];
const mutate = vi.fn(
(_input: unknown) =>
new Promise<MutationResult>((resolve) => {
resolvers.push((value) => resolve(value));
}),
);
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
}),
mutate,
});
runtime.store.getState().setSourceText("generation one");
await vi.runAllTimersAsync();
runtime.store.getState().setSourceText("generation two");
await vi.runAllTimersAsync();
resolvers[0]?.({
stylesheet: stylesheet("generation one"),
revision: 4,
renderDataVersion: 8,
editGeneration: 1,
diagnostics: [],
});
await vi.runAllTimersAsync();
expect(runtime.store.getState()).toMatchObject({
revision: 4,
renderDataVersion: 8,
source: source("generation two"),
applied: source("generation zero"),
});
expect(mutate).toHaveBeenCalledTimes(2);
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
expectedRevision: 4,
expectedRenderDataVersion: 8,
editGeneration: 2,
});
});
it("rebases conflicts without dropping the focused local draft", async () => {
const mutate = vi
.fn()
.mockRejectedValueOnce({
code: "STYLESHEET_REVISION_CONFLICT",
data: {
state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 },
},
})
.mockResolvedValueOnce({
stylesheet: stylesheet("local unsaved source"),
revision: 9,
renderDataVersion: 11,
editGeneration: 1,
diagnostics: [],
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.getState().setFocused(true);
runtime.store.getState().setSourceText("local unsaved source");
await vi.runAllTimersAsync();
expect(runtime.store.getState()).toMatchObject({
revision: 9,
renderDataVersion: 11,
source: source("local unsaved source"),
});
expect(mutate.mock.calls[1]?.[0]).toMatchObject({ expectedRevision: 8, expectedRenderDataVersion: 11 });
});
it("keeps the newer pending edit when an older request conflicts", async () => {
let rejectFirst!: (error: unknown) => void;
const mutate = vi
.fn()
.mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectFirst = reject;
}),
)
.mockResolvedValueOnce({
stylesheet: stylesheet("newer"),
revision: 9,
renderDataVersion: 11,
editGeneration: 2,
diagnostics: [],
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.getState().setSourceText("older");
await vi.runAllTimersAsync();
runtime.store.getState().setSourceText("newer");
await vi.runAllTimersAsync();
rejectFirst({
code: "STYLESHEET_REVISION_CONFLICT",
data: { state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 } },
});
await vi.runAllTimersAsync();
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
transition: "edit_source",
editGeneration: 2,
source: source("newer"),
});
});
it("invalidates pending preflight eligibility on content changes and keeps versions monotonic", async () => {
let resolveMutation!: (result: MutationResult) => void;
let resolveRepreflight!: (result: {
type: "preflight_result";
requestId: number;
editGeneration: number;
result: {
ok: true;
pageCount: number;
byteCount: number;
diagnostics: [];
pdf: ArrayBuffer;
};
}) => void;
const mutate = vi
.fn()
.mockReturnValueOnce(new Promise<MutationResult>((resolve) => (resolveMutation = resolve)))
.mockResolvedValueOnce({
stylesheet: stylesheet("newer"),
revision: 11,
renderDataVersion: 20,
editGeneration: 2,
diagnostics: [],
});
const preflight = vi
.fn()
.mockImplementationOnce(async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}))
.mockImplementationOnce(async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}))
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRepreflight = resolve;
}),
);
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight,
mutate,
});
runtime.store.getState().setSourceText("older");
await vi.runAllTimersAsync();
runtime.store.getState().setSourceText("newer");
await vi.runAllTimersAsync();
expect(mutate).toHaveBeenCalledTimes(1);
runtime.replaceResumeSnapshot(defaultResumeData, {
stylesheet: stylesheet("remote"),
revision: 10,
renderDataVersion: 20,
});
await vi.runAllTimersAsync();
expect(preflight).toHaveBeenCalledTimes(3);
resolveMutation({
stylesheet: stylesheet("older"),
revision: 4,
renderDataVersion: 7,
editGeneration: 1,
diagnostics: [],
});
await Promise.resolve();
await Promise.resolve();
expect(runtime.store.getState()).toMatchObject({ revision: 10, renderDataVersion: 20 });
expect(mutate).toHaveBeenCalledTimes(1);
resolveRepreflight({
type: "preflight_result",
requestId: 3,
editGeneration: 2,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
});
await vi.runAllTimersAsync();
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
source: source("newer"),
expectedRevision: 10,
expectedRenderDataVersion: 20,
});
});
it("does not requeue an invalidated in-flight candidate after conflict", async () => {
let rejectMutation!: (error: unknown) => void;
let resolveRepreflight!: (result: {
type: "preflight_result";
requestId: number;
editGeneration: number;
result: {
ok: true;
pageCount: number;
byteCount: number;
diagnostics: [];
pdf: ArrayBuffer;
};
}) => void;
const mutate = vi
.fn()
.mockReturnValueOnce(new Promise<MutationResult>((_resolve, reject) => (rejectMutation = reject)))
.mockResolvedValueOnce({
stylesheet: stylesheet("newer"),
revision: 11,
renderDataVersion: 20,
editGeneration: 2,
diagnostics: [],
});
const preflight = vi
.fn()
.mockImplementationOnce(async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}))
.mockImplementationOnce(async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}))
.mockImplementationOnce(
() =>
new Promise((resolve) => {
resolveRepreflight = resolve;
}),
);
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight,
mutate,
});
runtime.store.getState().setSourceText("older");
await vi.runAllTimersAsync();
runtime.store.getState().setSourceText("newer");
await vi.runAllTimersAsync();
runtime.replaceResumeSnapshot(defaultResumeData, {
stylesheet: stylesheet("remote"),
revision: 10,
renderDataVersion: 20,
});
await vi.runAllTimersAsync();
rejectMutation({
code: "STYLESHEET_REVISION_CONFLICT",
data: { state: { stylesheet: stylesheet("remote"), revision: 10, renderDataVersion: 20 } },
});
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
expect(mutate).toHaveBeenCalledTimes(1);
resolveRepreflight({
type: "preflight_result",
requestId: 3,
editGeneration: 2,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
});
await vi.runAllTimersAsync();
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
source: source("newer"),
expectedRevision: 10,
expectedRenderDataVersion: 20,
});
});
it("reconciles a deferred focused canonical source on blur", () => {
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
compile: vi.fn(),
preflight: vi.fn(),
mutate: vi.fn(),
});
runtime.store.getState().setFocused(true);
runtime.rebaseCanonical({
stylesheet: stylesheet("remote"),
revision: 8,
renderDataVersion: 11,
});
expect(runtime.store.getState()).toMatchObject({
source: source("generation zero"),
applied: source("remote"),
revision: 8,
renderDataVersion: 11,
});
runtime.store.getState().setFocused(false);
expect(runtime.store.getState()).toMatchObject({
source: source("remote"),
applied: source("remote"),
});
});
it("persists invalid source while preserving applied and restores stylesheet history separately", async () => {
const mutate = vi
.fn()
.mockResolvedValueOnce({
stylesheet: {
mode: "semantic",
source: source("invalid {"),
applied: source("generation zero"),
},
revision: 4,
renderDataVersion: 7,
editGeneration: 1,
diagnostics: [
{
code: "PARSE_ERROR",
severity: "error",
message: "Invalid",
range: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } },
},
],
})
.mockResolvedValueOnce({
stylesheet: stylesheet("generation zero"),
revision: 5,
renderDataVersion: 7,
editGeneration: 2,
diagnostics: [],
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration, source: candidate }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: candidate.text === "invalid {" ? null : { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.getState().setSourceText("invalid {");
await vi.runAllTimersAsync();
expect(runtime.store.getState()).toMatchObject({
source: source("invalid {"),
applied: source("generation zero"),
});
expect(mutate.mock.calls[0]?.[0]).toMatchObject({ transition: "edit_source", source: source("invalid {") });
runtime.store.getState().undo();
await vi.runAllTimersAsync();
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
transition: "restore_history",
restore: stylesheet("generation zero"),
});
});
it("does not publish historical applied state before restore acknowledgement", async () => {
const mutate = vi.fn(() => new Promise<MutationResult>(() => {}));
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial: { ...initial, stylesheet: stylesheet("current applied") },
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.setState({
source: source("local invalid"),
applied: source("current applied"),
undoStack: [stylesheet("historical")],
canUndo: true,
});
runtime.store.getState().undo();
await vi.runAllTimersAsync();
expect(runtime.store.getState().source).toEqual(source("historical"));
expect(runtime.store.getState().applied).toEqual(source("current applied"));
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ transition: "restore_history", restore: stylesheet("historical") }),
expect.any(AbortSignal),
);
});
it("retries the focused draft against a newer content render-data version", async () => {
const mutate = vi.fn().mockResolvedValue({
stylesheet: stylesheet("local"),
revision: 4,
renderDataVersion: 12,
editGeneration: 1,
diagnostics: [],
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.getState().setFocused(true);
runtime.store.getState().setSourceText("local");
runtime.replaceResumeSnapshot(defaultResumeData, {
stylesheet: stylesheet("remote"),
revision: 9,
renderDataVersion: 12,
});
await vi.runAllTimersAsync();
expect(runtime.store.getState().source).toEqual(source("local"));
expect(mutate).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
expect.any(AbortSignal),
);
});
it("keeps a queued draft when content changes after editor blur", async () => {
const mutate = vi.fn().mockResolvedValue({
stylesheet: stylesheet("local"),
revision: 10,
renderDataVersion: 12,
editGeneration: 1,
diagnostics: [],
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate,
});
runtime.store.getState().setSourceText("local");
runtime.replaceResumeSnapshot(defaultResumeData, {
stylesheet: stylesheet("remote"),
revision: 9,
renderDataVersion: 12,
});
await vi.runAllTimersAsync();
expect(runtime.store.getState().source).toEqual(source("local"));
expect(mutate).toHaveBeenLastCalledWith(
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
expect.any(AbortSignal),
);
});
it("queues activation only after browser preflight succeeds", async () => {
const mutate = vi.fn().mockResolvedValue({
stylesheet: stylesheet("generation zero"),
revision: 4,
renderDataVersion: 7,
editGeneration: 2,
diagnostics: [],
});
const preflight = vi
.fn()
.mockResolvedValueOnce({
type: "preflight_result",
requestId: 1,
editGeneration: 1,
result: {
ok: false,
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
message: "failed",
diagnostics: [],
},
})
.mockResolvedValueOnce({
type: "preflight_result",
requestId: 2,
editGeneration: 2,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
});
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial: { ...initial, stylesheet: { ...initial.stylesheet, mode: "legacy" } },
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight,
mutate,
});
runtime.store.getState().activate();
await vi.runAllTimersAsync();
expect(mutate).not.toHaveBeenCalled();
runtime.store.getState().activate();
await vi.runAllTimersAsync();
expect(mutate).toHaveBeenCalledWith(
expect.objectContaining({ transition: "activate", source: source("generation zero") }),
expect.any(AbortSignal),
);
});
it("does not leave Checking stuck when compile rejects for the current edit", async () => {
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: () => Promise.reject(new Error("Discarded stale stylesheet compiler result.")),
preflight: vi.fn(),
mutate: vi.fn(),
});
runtime.store.getState().setSourceText("edited source");
await vi.runAllTimersAsync();
expect(runtime.store.getState().status).not.toBe("compiling");
expect(runtime.store.getState().status).toBe("error");
});
it("surfaces browser preflight failures as diagnostics when the worker returns an empty list", async () => {
let resolveMutate!: (value: MutationResult & { diagnostics: never[] }) => void;
const mutate = vi.fn(
() =>
new Promise<MutationResult & { diagnostics: never[] }>((resolve) => {
resolveMutate = resolve;
}),
);
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: async ({ editGeneration }) => ({
type: "compile_result",
requestId: editGeneration,
editGeneration,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
}),
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: {
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed.",
diagnostics: [],
},
}),
mutate,
});
runtime.store.getState().setSourceText("@version 1;\nsection { color: teal; }");
await vi.runAllTimersAsync();
expect(runtime.store.getState().diagnostics).toEqual([
expect.objectContaining({
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
severity: "error",
message: "The PDF preflight worker failed.",
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 1, offset: 0 },
},
}),
]);
expect(runtime.store.getState().diagnostics.some(({ severity }) => severity === "error")).toBe(true);
expect(mutate).toHaveBeenCalled();
resolveMutate({
stylesheet: stylesheet("@version 1;\n"),
revision: 4,
renderDataVersion: 7,
editGeneration: 1,
diagnostics: [],
});
await Promise.resolve();
});
it("finishes an edit when refreshIntelligence interleaves through the shared compile client", async () => {
const { createCompileWorkerClient } = await import("./worker-client");
const listeners = new Map<string, Set<EventListener>>();
const fake = {
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn((type: string, listener: EventListener) => {
const bucket = listeners.get(type) ?? new Set();
bucket.add(listener);
listeners.set(type, bucket);
}),
removeEventListener: vi.fn((type: string, listener: EventListener) => {
listeners.get(type)?.delete(listener);
}),
emit(data: unknown) {
for (const listener of listeners.get("message") ?? []) {
(listener as (event: MessageEvent) => void)(new MessageEvent("message", { data }));
}
},
};
const client = createCompileWorkerClient(() => fake);
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 0,
compile: client.compile,
preflight: async ({ editGeneration }) => ({
type: "preflight_result",
requestId: editGeneration,
editGeneration,
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
}),
mutate: async ({ editGeneration }) => ({
stylesheet: stylesheet("edited source"),
revision: 4,
renderDataVersion: 7,
editGeneration,
diagnostics: [],
}),
});
runtime.store.getState().setSourceText("edited source");
await vi.runAllTimersAsync();
expect(runtime.store.getState().status).toBe("compiling");
expect(fake.postMessage).toHaveBeenCalledTimes(1);
// Mobile Design remount / accordion reopen refreshes intelligence while the edit is compiling.
runtime.store.getState().refreshIntelligence();
expect(fake.postMessage).toHaveBeenCalledTimes(2);
fake.emit({
type: "compile_result",
requestId: 1,
editGeneration: 1,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
colorTokens: [],
});
fake.emit({
type: "compile_result",
requestId: 2,
editGeneration: 1,
program: { languageVersion: 1, rules: [] },
diagnostics: [],
colorTokens: [],
});
await vi.runAllTimersAsync();
expect(runtime.store.getState().status).toBe("applied");
expect(runtime.store.getState().applied.text).toBe("edited source");
});
it("terminates both worker clients and clears the store on cleanup", () => {
const destroy = vi.fn();
let mutationSignal: AbortSignal | undefined;
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
compile: vi.fn(),
preflight: vi.fn(),
mutate: vi.fn((_input: unknown, signal: AbortSignal) => {
mutationSignal = signal;
return new Promise<MutationResult>(() => {});
}),
destroy,
});
runtime.store.getState().deactivate();
runtime.destroy();
expect(destroy).toHaveBeenCalledOnce();
expect(mutationSignal?.aborted).toBe(true);
expect(runtime.store.getState().resumeId).toBeUndefined();
});
it("coalesces rapid source edits and bounds stylesheet history", () => {
const runtime = createStylesheetStoreRuntime({
resumeId: "resume-1",
initial,
resumeData: defaultResumeData,
debounceMs: 1_000_000,
compile: vi.fn(),
preflight: vi.fn(),
mutate: vi.fn(),
});
for (let index = 0; index < 10; index++) runtime.store.getState().setSourceText(`rapid ${index}`);
expect(runtime.store.getState().undoStack).toHaveLength(1);
expect(runtime.store.getState().undoStack[0]).toEqual(stylesheet("generation zero"));
for (let index = 0; index < 60; index++) {
vi.advanceTimersByTime(501);
runtime.store.getState().setSourceText(`separate ${index}`);
}
expect(runtime.store.getState().undoStack).toHaveLength(50);
});
});
@@ -1,699 +0,0 @@
import type { SemanticCssDiagnostic, SemanticNode } 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 { StoreApi } from "zustand/vanilla";
import type { SemanticCssColorToken } from "./color-tokens";
import type {
CompileWorkerInput,
CompileWorkerResponse,
PreflightWorkerInput,
PreflightWorkerResponse,
SemanticCssEditorMetadata,
} from "./protocol";
import { create } from "zustand/react";
import { createStore } from "zustand/vanilla";
import {
buildSemanticTree,
getTemplateSemanticManifest,
semanticNodeKeys,
shouldShowResumeHeader,
} from "@reactive-resume/pdf/semantic-tree";
import { orpc } from "@/libs/orpc/client";
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
export type StylesheetCanonicalState = {
stylesheet: SemanticStylesheet;
revision: number;
renderDataVersion: number;
};
type StylesheetMutationResult = StylesheetCanonicalState & {
editGeneration: number;
diagnostics: readonly SemanticCssDiagnostic[];
};
type EditMutation = {
id: string;
expectedRevision: number;
expectedRenderDataVersion: number;
editGeneration: number;
transition: "edit_source";
source: StylesheetSource;
};
type RestoreMutation = {
id: string;
expectedRevision: number;
expectedRenderDataVersion: number;
editGeneration: number;
transition: "restore_history";
restore: SemanticStylesheet;
};
type ActivateMutation = Omit<EditMutation, "transition"> & { transition: "activate" };
type DeactivateMutation = Omit<EditMutation, "transition" | "source"> & { transition: "deactivate" };
type StylesheetMutation = EditMutation | RestoreMutation | ActivateMutation | DeactivateMutation;
type Candidate =
| { generation: number; transition: "edit_source"; source: StylesheetSource }
| { generation: number; transition: "restore_history"; restore: SemanticStylesheet }
| { generation: number; transition: "activate"; source: StylesheetSource }
| { generation: number; transition: "deactivate" };
export type StylesheetStoreState = {
resumeId?: string;
mode: SemanticStylesheet["mode"];
source: StylesheetSource;
applied: StylesheetSource;
revision: number;
renderDataVersion: number;
editGeneration: number;
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens: readonly SemanticCssColorToken[];
editorMetadata: SemanticCssEditorMetadata;
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
restoreLocked: boolean;
focused: boolean;
canUndo: boolean;
canRedo: boolean;
undoStack: SemanticStylesheet[];
redoStack: SemanticStylesheet[];
setSourceText(text: string): void;
setFocused(focused: boolean): void;
activate(): void;
deactivate(): void;
undo(): void;
redo(): void;
refreshIntelligence(): void;
};
type RuntimeDependencies = {
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse>;
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse>;
mutate(input: StylesheetMutation, signal: AbortSignal): Promise<StylesheetMutationResult>;
destroy?(): void;
};
type CreateStylesheetStoreRuntimeOptions = RuntimeDependencies & {
resumeId: string;
initial: StylesheetCanonicalState;
resumeData: ResumeData;
debounceMs?: number;
store?: StoreApi<StylesheetStoreState>;
};
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: "@version 1;\n" });
const emptySemanticTree = (): SemanticNode => ({
key: "resume",
kind: "resume",
attributes: {},
roles: [],
children: [],
});
const HISTORY_COALESCE_MS = 500;
const MAX_HISTORY_ENTRIES = 50;
const preflightFailureDiagnostic = (
result: Extract<PreflightWorkerResponse["result"], { 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 inactiveState = (): Omit<
StylesheetStoreState,
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
> => ({
resumeId: undefined,
mode: "legacy",
source: emptySource(),
applied: emptySource(),
revision: 0,
renderDataVersion: 0,
editGeneration: 0,
diagnostics: [],
colorTokens: [],
editorMetadata: { semanticTree: emptySemanticTree(), templateParts: [] },
status: "idle",
restoreLocked: false,
focused: false,
canUndo: false,
canRedo: false,
undoStack: [],
redoStack: [],
});
const sourceFromText = (source: StylesheetSource, text: string): StylesheetSource => ({ ...source, text });
const sourcesEqual = (left: StylesheetSource, right: StylesheetSource) =>
left.languageVersion === right.languageVersion && left.text === right.text;
const isEditorFocused = () =>
typeof document !== "undefined" && document.activeElement instanceof HTMLElement
? document.activeElement.closest(".cm-editor") !== null
: false;
const currentStylesheet = (state: StylesheetStoreState): SemanticStylesheet => ({
mode: state.mode,
source: structuredClone(state.source),
applied: structuredClone(state.applied),
});
const appendHistory = (stack: SemanticStylesheet[], value: SemanticStylesheet) =>
[...stack, value].slice(-MAX_HISTORY_ENTRIES);
const pageDimensions = (data: ResumeData) => {
const format = data.metadata.page.format;
const size = format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
return data.metadata.layout.pages.map((_page, index) => ({
pageKey: semanticNodeKeys.page(index + 1),
...size,
}));
};
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
const pages = data.metadata.layout.pages.map((page, index) =>
buildSemanticTree({
data,
template: data.metadata.template,
page,
pageNumber: index + 1,
showHeader: shouldShowResumeHeader(data, index),
}),
);
const semanticTree: SemanticNode = {
key: semanticNodeKeys.resume(),
kind: "resume",
attributes: { template: data.metadata.template },
roles: [],
children: pages.flatMap(({ children }) => children),
};
return {
semanticTree,
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
};
};
const compileInput = (
data: ResumeData,
source: StylesheetSource,
editGeneration: number,
semanticTree: SemanticNode,
): CompileWorkerInput => {
return {
editGeneration,
source,
semanticTree,
baseSettings: {
picture: data.picture,
template: data.metadata.template,
design: data.metadata.design,
typography: data.metadata.typography,
page: data.metadata.page,
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
},
pages: pageDimensions(data),
};
};
const conflictState = (error: unknown): StylesheetCanonicalState | undefined => {
if (!error || typeof error !== "object") return;
const value = error as { code?: string; data?: { state?: StylesheetCanonicalState } };
return value.code === "STYLESHEET_REVISION_CONFLICT" ? value.data?.state : undefined;
};
export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRuntimeOptions) {
let resumeData = structuredClone(options.resumeData);
let timer: ReturnType<typeof setTimeout> | undefined;
let inFlight: Candidate | undefined;
let pending: Candidate | undefined;
let latestCandidate: Candidate | undefined;
let deferredCanonical: StylesheetCanonicalState | undefined;
let validationEpoch = 0;
let intelligenceEpoch = 0;
let historyLastEditAt = 0;
let historyCanCoalesce = false;
let destroyed = false;
const abortController = new AbortController();
const debounceMs = options.debounceMs ?? 180;
const initial = options.initial.stylesheet;
let editorMetadata = createEditorMetadata(resumeData);
const store =
options.store ??
createStore<StylesheetStoreState>(() => ({
...inactiveState(),
setSourceText: () => {},
setFocused: () => {},
activate: () => {},
deactivate: () => {},
undo: () => {},
redo: () => {},
refreshIntelligence: () => {},
}));
const patch = (next: Partial<StylesheetStoreState>) => store.setState(next);
const replaceCanonical = (canonical: StylesheetCanonicalState, preserveSource: boolean) => {
const state = store.getState();
const next: Partial<StylesheetStoreState> = {
revision: Math.max(state.revision, canonical.revision),
renderDataVersion: Math.max(state.renderDataVersion, canonical.renderDataVersion),
};
if (canonical.revision >= state.revision) {
next.mode = canonical.stylesheet.mode;
const nextSource = preserveSource ? state.source : canonical.stylesheet.source;
next.source = nextSource;
next.applied = canonical.stylesheet.applied;
if (!sourcesEqual(nextSource, state.source)) {
intelligenceEpoch += 1;
next.colorTokens = [];
}
}
patch(next);
};
const resetHistoryCoalescing = () => {
historyLastEditAt = 0;
historyCanCoalesce = false;
};
const startNext = () => {
if (destroyed || inFlight || !pending) return;
const candidate = pending;
pending = undefined;
inFlight = candidate;
const requestValidationEpoch = validationEpoch;
const state = store.getState();
const common = {
id: options.resumeId,
expectedRevision: state.revision,
expectedRenderDataVersion: state.renderDataVersion,
editGeneration: candidate.generation,
};
let input: StylesheetMutation;
if (candidate.transition === "edit_source" || candidate.transition === "activate") {
input = { ...common, transition: candidate.transition, source: candidate.source };
} else if (candidate.transition === "restore_history") {
input = { ...common, transition: "restore_history", restore: candidate.restore };
} else {
input = { ...common, transition: "deactivate" };
}
patch({ status: "saving" });
void options
.mutate(input, abortController.signal)
.then((result) => {
if (destroyed) return;
const state = store.getState();
const staleStylesheet = result.revision < state.revision;
patch({
revision: Math.max(state.revision, result.revision),
renderDataVersion: Math.max(state.renderDataVersion, result.renderDataVersion),
});
if (result.editGeneration !== store.getState().editGeneration) return;
if (staleStylesheet) return;
const sourceChanged = !sourcesEqual(result.stylesheet.source, state.source);
if (sourceChanged) intelligenceEpoch += 1;
patch({
mode: result.stylesheet.mode,
source: result.stylesheet.source,
applied: result.stylesheet.applied,
diagnostics: result.diagnostics,
colorTokens: sourceChanged ? [] : state.colorTokens,
status: result.diagnostics.some(({ severity }) => severity === "error") ? "error" : "applied",
});
if (latestCandidate?.generation === result.editGeneration) latestCandidate = undefined;
if (deferredCanonical && result.revision >= deferredCanonical.revision) deferredCanonical = undefined;
})
.catch((error: unknown) => {
if (destroyed) return;
const canonical = conflictState(error);
if (!canonical) {
patch({ status: "error" });
return;
}
replaceCanonical(canonical, true);
if (requestValidationEpoch === validationEpoch) pending ??= candidate;
})
.finally(() => {
inFlight = undefined;
startNext();
});
};
const queue = (candidate: Candidate) => {
latestCandidate = candidate;
pending = candidate;
startNext();
};
const processCandidate = async (candidate: Candidate) => {
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
const candidateValidationEpoch = validationEpoch;
if (candidate.transition === "deactivate") {
queue(candidate);
return;
}
const source = candidate.transition === "restore_history" ? candidate.restore.applied : candidate.source;
patch({ status: "compiling" });
let compiled: CompileWorkerResponse;
try {
compiled = await options.compile(
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
);
} catch {
if (candidateValidationEpoch !== validationEpoch) return;
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
patch({ status: "error" });
return;
}
if (candidateValidationEpoch !== validationEpoch) return;
if (destroyed || compiled.editGeneration !== store.getState().editGeneration) return;
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
if (!compiled.program) {
if (candidate.transition === "edit_source") queue(candidate);
else patch({ status: "error" });
return;
}
if (compiled.program) {
patch({ status: "preflighting" });
let preflight: PreflightWorkerResponse;
try {
preflight = await options.preflight({
editGeneration: candidate.generation,
input: { data: resumeData, template: resumeData.metadata.template, stylesheet: source },
limits: {
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
},
});
} catch {
if (candidateValidationEpoch !== validationEpoch) return;
if (candidate.generation !== store.getState().editGeneration) return;
patch({ status: "error" });
if (candidate.transition === "edit_source") queue(candidate);
return;
}
if (candidateValidationEpoch !== validationEpoch) return;
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
if (!preflight.result.ok) {
patch({
diagnostics: [
...compiled.diagnostics,
...preflight.result.diagnostics,
preflightFailureDiagnostic(preflight.result),
],
status: "error",
});
if (candidate.transition !== "edit_source") return;
}
}
queue(candidate);
};
const schedule = (candidate: Candidate) => {
if (timer) clearTimeout(timer);
latestCandidate = candidate;
timer = setTimeout(() => {
timer = undefined;
void processCandidate(candidate);
}, debounceMs);
};
const restore = (target: SemanticStylesheet, opposite: "undoStack" | "redoStack") => {
const state = store.getState();
const stack = opposite === "undoStack" ? state.undoStack : state.redoStack;
const previous = stack.at(-1);
if (!previous) return;
const generation = state.editGeneration + 1;
const other = opposite === "undoStack" ? "redoStack" : "undoStack";
resetHistoryCoalescing();
patch({
source: previous.source,
editGeneration: generation,
colorTokens: [],
[opposite]: stack.slice(0, -1),
[other]: appendHistory(state[other], target),
canUndo: opposite === "redoStack" || stack.length > 1,
canRedo: opposite === "undoStack" || stack.length > 1,
});
schedule({ generation, transition: "restore_history", restore: previous });
};
store.setState({
resumeId: options.resumeId,
mode: initial.mode,
source: structuredClone(initial.source),
applied: structuredClone(initial.applied),
revision: options.initial.revision,
renderDataVersion: options.initial.renderDataVersion,
editGeneration: 0,
diagnostics: [],
colorTokens: [],
editorMetadata,
status: "idle",
restoreLocked: false,
focused: false,
undoStack: [],
redoStack: [],
canUndo: false,
canRedo: false,
setSourceText(text) {
const state = store.getState();
if (state.restoreLocked || text === state.source.text) return;
const generation = state.editGeneration + 1;
const nextSource = sourceFromText(state.source, text);
const now = Date.now();
const undoStack =
historyCanCoalesce && now - historyLastEditAt <= HISTORY_COALESCE_MS
? state.undoStack
: appendHistory(state.undoStack, currentStylesheet(state));
historyLastEditAt = now;
historyCanCoalesce = true;
patch({
source: nextSource,
editGeneration: generation,
colorTokens: [],
undoStack,
redoStack: [],
canUndo: true,
canRedo: false,
});
schedule({ generation, transition: "edit_source", source: nextSource });
},
setFocused(focused) {
patch({ focused });
if (focused || !deferredCanonical) return;
const canonical = deferredCanonical;
deferredCanonical = undefined;
const candidate = latestCandidate;
const hasLocalDraft =
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
replaceCanonical(canonical, hasLocalDraft);
if (hasLocalDraft && candidate) schedule(candidate);
else resetHistoryCoalescing();
},
activate() {
const state = store.getState();
if (state.restoreLocked || state.mode === "semantic") return;
const generation = state.editGeneration + 1;
resetHistoryCoalescing();
patch({
editGeneration: generation,
colorTokens: [],
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
redoStack: [],
canUndo: true,
canRedo: false,
});
schedule({ generation, transition: "activate", source: state.source });
},
deactivate() {
const state = store.getState();
if (state.restoreLocked || state.mode === "legacy") return;
const generation = state.editGeneration + 1;
resetHistoryCoalescing();
patch({
editGeneration: generation,
colorTokens: [],
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
redoStack: [],
canUndo: true,
canRedo: false,
});
queue({ generation, transition: "deactivate" });
},
undo() {
if (store.getState().restoreLocked) return;
restore(currentStylesheet(store.getState()), "undoStack");
},
redo() {
if (store.getState().restoreLocked) return;
restore(currentStylesheet(store.getState()), "redoStack");
},
refreshIntelligence() {
const state = store.getState();
const generation = state.editGeneration;
const source = structuredClone(state.source);
const requestEpoch = ++intelligenceEpoch;
void options
.compile(compileInput(resumeData, source, generation, editorMetadata.semanticTree))
.then((compiled) => {
const current = store.getState();
if (
destroyed ||
requestEpoch !== intelligenceEpoch ||
current.editGeneration !== generation ||
!sourcesEqual(current.source, source)
) {
return;
}
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
});
},
});
return {
store,
replaceResumeSnapshot(data: ResumeData, canonical: StylesheetCanonicalState) {
const candidate = latestCandidate;
resumeData = structuredClone(data);
editorMetadata = createEditorMetadata(resumeData);
patch({ editorMetadata });
const renderDataChanged = canonical.renderDataVersion > store.getState().renderDataVersion;
const preserveSource = store.getState().focused || isEditorFocused() || candidate !== undefined;
replaceCanonical(canonical, preserveSource);
if (renderDataChanged) {
validationEpoch += 1;
pending = undefined;
if (candidate) schedule(candidate);
}
},
rebaseCanonical(canonical: StylesheetCanonicalState) {
const candidate = latestCandidate;
const hasLocalDraft =
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
const focused = store.getState().focused || isEditorFocused();
const sourceChanged = store.getState().source.text !== canonical.stylesheet.source.text;
if (focused && sourceChanged && canonical.revision >= store.getState().revision) deferredCanonical = canonical;
const preserveSource = (focused && sourceChanged) || hasLocalDraft;
replaceCanonical(canonical, preserveSource);
if (hasLocalDraft && candidate) schedule(candidate);
else if (!preserveSource) resetHistoryCoalescing();
},
destroy() {
destroyed = true;
abortController.abort();
if (timer) clearTimeout(timer);
timer = undefined;
pending = undefined;
latestCandidate = undefined;
deferredCanonical = undefined;
options.destroy?.();
store.setState(inactiveState());
},
};
}
export const useStylesheetStore = create<StylesheetStoreState>(() => ({
...inactiveState(),
setSourceText: () => {},
setFocused: () => {},
activate: () => {},
deactivate: () => {},
undo: () => {},
redo: () => {},
refreshIntelligence: () => {},
}));
let activeRuntime: ReturnType<typeof createStylesheetStoreRuntime> | undefined;
declare const stylesheetRuntimeTokenBrand: unique symbol;
export type StylesheetRuntimeToken = Readonly<{ [stylesheetRuntimeTokenBrand]: true }>;
let activeRuntimeToken: StylesheetRuntimeToken | undefined;
const compilerClient = () =>
createCompileWorkerClient(
() =>
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), { type: "module", name: "semantic-css-compiler" }),
);
const preflightClient = () =>
createPreflightWorkerClient(
() =>
new Worker(new URL("./preflight.worker.ts", import.meta.url), { type: "module", name: "semantic-css-preflight" }),
5_000,
);
export function initializeStylesheetStore(input: {
resumeId: string;
initial: StylesheetCanonicalState;
resumeData: ResumeData;
}) {
activeRuntime?.destroy();
const compiler = compilerClient();
const preflight = preflightClient();
preflight.warmup();
const runtime = createStylesheetStoreRuntime({
...input,
store: useStylesheetStore,
compile: compiler.compile,
preflight: preflight.preflight,
mutate: (mutation, signal) => orpc.resume.stylesheet.mutate.call(mutation, { signal }),
destroy: () => {
compiler.destroy();
preflight.destroy();
},
});
activeRuntime = runtime;
activeRuntimeToken = {} as StylesheetRuntimeToken;
return () => {
if (activeRuntime?.store.getState().resumeId !== input.resumeId) return;
activeRuntime.destroy();
activeRuntime = undefined;
activeRuntimeToken = undefined;
};
}
export function lockStylesheetStoreForRestore(resumeId: string): StylesheetRuntimeToken | undefined {
if (!activeRuntime || !activeRuntimeToken) return;
const state = activeRuntime.store.getState();
if (state.resumeId !== resumeId || state.restoreLocked) return;
activeRuntime.store.setState({ restoreLocked: true });
return activeRuntimeToken;
}
export function unlockStylesheetStoreAfterRestore(token: StylesheetRuntimeToken | undefined): boolean {
if (!activeRuntime || !token || activeRuntimeToken !== token) return false;
activeRuntime.store.setState({ restoreLocked: false });
return true;
}
export function replaceStylesheetStoreAfterRestore(input: {
resumeId: string;
initial: StylesheetCanonicalState;
resumeData: ResumeData;
token: StylesheetRuntimeToken | undefined;
}): boolean {
if (
!activeRuntime ||
!input.token ||
activeRuntimeToken !== input.token ||
activeRuntime.store.getState().resumeId !== input.resumeId ||
!activeRuntime.store.getState().restoreLocked
) {
return false;
}
initializeStylesheetStore(input);
return true;
}
export async function refreshStylesheetStore(resumeId: string, resumeData?: ResumeData) {
if (!activeRuntime || activeRuntime.store.getState().resumeId !== resumeId) return;
const canonical = await orpc.resume.stylesheet.getState.call({ id: resumeId });
if (resumeData) activeRuntime.replaceResumeSnapshot(resumeData, canonical);
else activeRuntime.rebaseCanonical(canonical);
}
@@ -0,0 +1,72 @@
// @vitest-environment happy-dom
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
function request(source: string): CompileWorkerRequest {
return {
type: "compile",
requestId: 1,
editGeneration: 1,
source: { languageVersion: 1, text: source },
semanticTree: {
key: "resume",
kind: "resume",
attributes: {},
roles: [],
children: [{ key: "name", kind: "name", attributes: {}, roles: [], children: [] }],
},
baseSettings: {
picture: defaultResumeData.picture,
template: defaultResumeData.metadata.template,
design: defaultResumeData.metadata.design,
typography: defaultResumeData.metadata.typography,
page: defaultResumeData.metadata.page,
layout: { sidebarWidth: defaultResumeData.metadata.layout.sidebarWidth },
},
pages: [{ pageKey: "page-1", width: 595.28, height: 841.89 }],
};
}
describe("stylesheet worker", () => {
let handleMessage: ((event: MessageEvent<CompileWorkerRequest>) => void) | undefined;
const postMessage = vi.fn();
beforeEach(async () => {
handleMessage = undefined;
postMessage.mockReset();
vi.resetModules();
vi.stubGlobal("self", {
addEventListener: vi.fn((type: string, listener: (event: MessageEvent<CompileWorkerRequest>) => void) => {
if (type === "message") handleMessage = listener;
}),
postMessage,
});
await import("./stylesheet.worker");
});
it("returns diagnostics from variable resolution", () => {
handleMessage?.(
new MessageEvent("message", {
data: request("@version 1;\nname { color: var(--missing); }\n"),
}),
);
const response = postMessage.mock.calls[0]?.[0] as CompileWorkerResponse | undefined;
expect(response?.diagnostics).toEqual(
expect.arrayContaining([expect.objectContaining({ code: "UNRESOLVED_VARIABLE", severity: "error" })]),
);
});
it("returns overlapping semantic diagnostics once", () => {
handleMessage?.(
new MessageEvent("message", {
data: request('@version 1;\nsection[type="education"] { color: red; }\n'),
}),
);
const response = postMessage.mock.calls[0]?.[0] as CompileWorkerResponse | undefined;
expect(response?.diagnostics.filter(({ code }) => code === "SELECTOR_NO_MATCH")).toHaveLength(1);
});
});
@@ -1,14 +1,21 @@
/// <reference lib="webworker" />
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { compileStylesheet, resolveStylesheet } from "@reactive-resume/resume/stylesheet";
import { collectCompiledColorTokens } from "./color-tokens";
self.addEventListener("message", ({ data }: MessageEvent<CompileWorkerRequest>) => {
if (data.type !== "compile") return;
const compiled = compileStylesheet(data.source);
const diagnostics = compiled.program
? [...compiled.diagnostics, ...analyzeStylesheet(compiled.program, data.semanticTree)]
? [
...compiled.diagnostics,
...resolveStylesheet(compiled.program, data.semanticTree, {
baseStyles: {},
baseSettings: data.baseSettings,
pages: data.pages,
}).diagnostics,
]
: compiled.diagnostics;
const response: CompileWorkerResponse = {
type: "compile_result",
@@ -1,6 +1,5 @@
import { t } from "@lingui/core/macro";
import {
ArrowCounterClockwiseIcon,
ArrowsInIcon,
ArrowsOutIcon,
ArrowUUpLeftIcon,
@@ -43,7 +42,6 @@ export type StylesheetToolbarProps = {
onUndo(): void;
onRedo(): void;
onFormat(): void;
onReset(): void;
onFocusToggle(): void;
};
@@ -56,7 +54,6 @@ export function StylesheetToolbar({
onUndo,
onRedo,
onFormat,
onReset,
onFocusToggle,
}: StylesheetToolbarProps) {
return (
@@ -73,9 +70,6 @@ export function StylesheetToolbar({
<ToolbarButton label={t`Format stylesheet`} disabled={disabled} onClick={onFormat}>
<MagicWandIcon data-icon="inline-start" />
</ToolbarButton>
<ToolbarButton label={t`Reset to applied stylesheet`} disabled={disabled} onClick={onReset}>
<ArrowCounterClockwiseIcon data-icon="inline-start" />
</ToolbarButton>
<ToolbarButton label={focused ? t`Exit focus mode` : t`Open focus mode`} onClick={onFocusToggle}>
{focused ? <ArrowsInIcon data-icon="inline-start" /> : <ArrowsOutIcon data-icon="inline-start" />}
</ToolbarButton>
@@ -1,6 +1,5 @@
import { describe, expect, it, vi } from "vitest";
import { getPreflightTransferables } from "./protocol";
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
import { createCompileWorkerClient } from "./worker-client";
type Listener = (event: MessageEvent) => void;
@@ -23,15 +22,13 @@ function worker() {
}
},
emitError(event: ErrorEvent) {
for (const listener of listeners.get("error") ?? []) {
listener(event);
}
for (const listener of listeners.get("error") ?? []) listener(event);
},
};
}
describe("stylesheet worker clients", () => {
it("resolves older compiler results so callers can generation-check without aborting", async () => {
describe("stylesheet compiler worker client", () => {
it("resolves every compiler result so the editor can generation-check", async () => {
const fake = worker();
const client = createCompileWorkerClient(() => fake);
const first = client.compile({ editGeneration: 1 } as never);
@@ -54,181 +51,14 @@ describe("stylesheet worker clients", () => {
await expect(pending).rejects.toThrow("Failed to load compiler worker");
});
it("terminates and recreates a timed-out preflight worker", async () => {
vi.useFakeTimers();
const first = worker();
const replacement = worker();
const createWorker = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement);
const client = createPreflightWorkerClient(createWorker, 10);
const timedOut = client.preflight({ editGeneration: 1 } as never);
first.emit({ type: "preflight_ready" });
await vi.advanceTimersByTimeAsync(10);
await expect(timedOut).resolves.toMatchObject({
result: { ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" },
});
expect(first.terminate).toHaveBeenCalledOnce();
const next = client.preflight({ editGeneration: 2 } as never);
replacement.emit({ type: "preflight_ready" });
await vi.advanceTimersByTimeAsync(0);
replacement.emit({
type: "preflight_result",
requestId: 2,
editGeneration: 2,
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
});
await expect(next).resolves.toMatchObject({ requestId: 2 });
vi.useRealTimers();
});
it("warms the preflight worker before a request starts its deadline", () => {
it("terminates the compiler worker and rejects pending work on destroy", async () => {
const fake = worker();
const createWorker = vi.fn(() => fake);
const client = createPreflightWorkerClient(createWorker, 5_000);
client.warmup();
expect(createWorker).toHaveBeenCalledOnce();
expect(fake.addEventListener).toHaveBeenCalledTimes(2);
expect(fake.addEventListener).toHaveBeenCalledWith("message", expect.any(Function));
expect(fake.addEventListener).toHaveBeenCalledWith("error", expect.any(Function));
expect(fake.postMessage).not.toHaveBeenCalled();
});
it("waits for readiness without consuming the request deadline", async () => {
vi.useFakeTimers();
const fake = worker();
const client = createPreflightWorkerClient(() => fake, 5, 20);
const result = client.preflight({ editGeneration: 1 } as never);
await vi.advanceTimersByTimeAsync(5);
expect(fake.terminate).not.toHaveBeenCalled();
expect(fake.postMessage).not.toHaveBeenCalled();
fake.emit({ type: "preflight_ready" });
await vi.advanceTimersByTimeAsync(0);
expect(fake.postMessage).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(5);
await expect(result).resolves.toMatchObject({ result: { code: "STYLESHEET_PREFLIGHT_TIMEOUT" } });
vi.useRealTimers();
});
it("rejects pending preflight work when the worker emits an error event", async () => {
const fake = worker();
const client = createPreflightWorkerClient(() => fake, 5_000);
const pending = client.preflight({ editGeneration: 1 } as never);
const outcome = pending.catch((error: unknown) => error);
fake.emit({ type: "preflight_ready" });
await Promise.resolve();
fake.emitError({ message: "Worker crashed" } as ErrorEvent);
expect(await outcome).toMatchObject({ message: "Worker crashed" });
expect(fake.terminate).toHaveBeenCalled();
});
it("rejects structured resume-data failures without waiting for the timeout", async () => {
vi.useFakeTimers();
const fake = worker();
const client = createPreflightWorkerClient(() => fake, 5_000);
const pending = client.preflight({ editGeneration: 1 } as never);
const outcome = pending.catch((error: unknown) => error);
fake.emit({ type: "preflight_ready" });
await vi.advanceTimersByTimeAsync(0);
fake.emit({
type: "preflight_error",
requestId: 1,
editGeneration: 1,
cause: {
name: "ZodError",
message: "Invalid resume data",
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
},
});
expect(await outcome).toMatchObject({
name: "ZodError",
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
});
await vi.advanceTimersByTimeAsync(5_000);
expect(fake.terminate).not.toHaveBeenCalled();
vi.useRealTimers();
});
it("bounds readiness, recreates once, and rejects after the retry also times out", async () => {
vi.useFakeTimers();
const first = worker();
const replacement = worker();
const client = createPreflightWorkerClient(
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
5,
10,
);
const result = client.preflight({ editGeneration: 1 } as never);
const rejection = expect(result).rejects.toThrow("did not become ready");
await vi.advanceTimersByTimeAsync(10);
expect(first.terminate).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(10);
await rejection;
expect(replacement.terminate).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("does not recreate a warming worker after destroy", async () => {
vi.useFakeTimers();
const fake = worker();
const createWorker = vi.fn(() => fake);
const client = createPreflightWorkerClient(createWorker, 5, 10);
client.warmup();
const client = createCompileWorkerClient(() => fake);
const pending = client.compile({ editGeneration: 1 } as never);
client.destroy();
await vi.runAllTimersAsync();
expect(createWorker).toHaveBeenCalledOnce();
expect(fake.terminate).toHaveBeenCalledOnce();
vi.useRealTimers();
});
it("terminates stale preflight work when a newer request starts", async () => {
const first = worker();
const replacement = worker();
const client = createPreflightWorkerClient(
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
1_000,
);
const stale = client.preflight({ editGeneration: 1 } as never);
first.emit({ type: "preflight_ready" });
await Promise.resolve();
const staleOutcome = stale.catch((error: unknown) => error);
const current = client.preflight({ editGeneration: 2 } as never);
expect(first.terminate).toHaveBeenCalledOnce();
replacement.emit({ type: "preflight_ready" });
await Promise.resolve();
replacement.emit({
type: "preflight_result",
requestId: 2,
editGeneration: 2,
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
});
expect(await staleOutcome).toEqual(expect.objectContaining({ message: expect.stringContaining("stale") }));
await expect(current).resolves.toMatchObject({ requestId: 2 });
});
it("transfers the generated PDF buffer", () => {
const pdf = new ArrayBuffer(4);
expect(
getPreflightTransferables({
type: "preflight_result",
requestId: 1,
editGeneration: 1,
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf },
}),
).toEqual([pdf]);
await expect(pending).rejects.toThrow("terminated");
});
});
@@ -1,13 +1,4 @@
import type {
CompileWorkerInput,
CompileWorkerRequest,
CompileWorkerResponse,
PreflightWorkerError,
PreflightWorkerInput,
PreflightWorkerReady,
PreflightWorkerRequest,
PreflightWorkerResponse,
} from "./protocol";
import type { CompileWorkerInput, CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
type WorkerListener = (event: MessageEvent<unknown>) => void;
type WorkerErrorListener = (event: ErrorEvent) => void;
@@ -67,164 +58,3 @@ export function createCompileWorkerClient(createWorker: () => StylesheetWorker)
},
};
}
const timeoutResult = (request: PreflightWorkerRequest): PreflightWorkerResponse => ({
type: "preflight_result",
requestId: request.requestId,
editGeneration: request.editGeneration,
result: {
ok: false,
code: "STYLESHEET_PREFLIGHT_TIMEOUT",
message: "The PDF preflight exceeded its deadline.",
diagnostics: [],
},
});
export function createPreflightWorkerClient(
createWorker: () => StylesheetWorker,
timeoutMs: number,
readinessTimeoutMs = 10_000,
) {
let worker: StylesheetWorker | undefined;
let requestId = 0;
let ready = false;
let destroyed = false;
let readiness:
| (Pending<StylesheetWorker> & {
promise: Promise<StylesheetWorker>;
timer: ReturnType<typeof setTimeout>;
})
| undefined;
const pending = new Map<number, Pending<PreflightWorkerResponse> & { timer?: ReturnType<typeof setTimeout> }>();
const onMessage: WorkerListener = ({ data }) => {
if ((data as PreflightWorkerReady)?.type === "preflight_ready") {
if (!worker || !readiness) return;
clearTimeout(readiness.timer);
ready = true;
readiness.resolve(worker);
readiness = undefined;
return;
}
const workerError = data as PreflightWorkerError;
if (workerError?.type === "preflight_error") {
const request = pending.get(workerError.requestId);
if (!request) return;
if (request.timer) clearTimeout(request.timer);
pending.delete(workerError.requestId);
request.reject(
Object.assign(new Error(workerError.cause.message), {
name: workerError.cause.name,
issues: workerError.cause.issues,
}),
);
return;
}
const response = data as PreflightWorkerResponse;
if (response?.type !== "preflight_result") return;
const request = pending.get(response.requestId);
if (!request) return;
if (request.timer) clearTimeout(request.timer);
pending.delete(response.requestId);
request.resolve(response);
};
const failPending = (error: Error) => {
for (const request of pending.values()) {
if (request.timer) clearTimeout(request.timer);
request.reject(error);
}
pending.clear();
};
const onError: WorkerErrorListener = (event) => {
const error = new Error(event.message || "Stylesheet preflight worker failed.");
terminate();
failPending(error);
};
const terminate = () => {
if (!worker) return;
worker.removeEventListener("message", onMessage);
worker.removeEventListener("error", onError);
worker.terminate();
worker = undefined;
ready = false;
if (readiness) {
clearTimeout(readiness.timer);
readiness.reject(new Error("Stylesheet preflight worker did not become ready."));
readiness = undefined;
}
};
const getReadyWorker = () => {
if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated."));
if (worker && ready) return Promise.resolve(worker);
if (readiness) return readiness.promise;
worker = createWorker();
worker.addEventListener("message", onMessage);
worker.addEventListener("error", onError);
let resolve!: (value: StylesheetWorker) => void;
let reject!: (error: Error) => void;
const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => {
resolve = resolvePromise;
reject = rejectPromise;
});
const timer = setTimeout(() => terminate(), readinessTimeoutMs);
readiness = { promise, resolve, reject, timer };
return promise;
};
const waitUntilReady = async () => {
try {
return await getReadyWorker();
} catch {
return await getReadyWorker();
}
};
return {
warmup() {
void waitUntilReady().catch(() => {});
},
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse> {
if (pending.size > 0) {
terminate();
for (const stale of pending.values()) {
if (stale.timer) clearTimeout(stale.timer);
stale.reject(new Error("Discarded stale stylesheet preflight result."));
}
pending.clear();
}
const request: PreflightWorkerRequest = { ...input, type: "preflight", requestId: ++requestId };
return new Promise((resolve, reject) => {
pending.set(request.requestId, { resolve, reject });
void waitUntilReady()
.then((readyWorker) => {
const current = pending.get(request.requestId);
if (!current) return;
current.timer = setTimeout(() => {
pending.delete(request.requestId);
terminate();
resolve(timeoutResult(request));
}, timeoutMs);
readyWorker.postMessage(request);
})
.catch((error: unknown) => {
const current = pending.get(request.requestId);
if (!current) return;
pending.delete(request.requestId);
reject(error instanceof Error ? error : new Error("Stylesheet preflight worker failed to start."));
});
});
},
destroy() {
destroyed = true;
terminate();
for (const request of pending.values()) {
if (request.timer) clearTimeout(request.timer);
request.reject(new Error("Stylesheet preflight worker was terminated."));
}
pending.clear();
},
};
}
@@ -17,11 +17,6 @@ import {
DropdownMenuTrigger,
} from "@reactive-resume/ui/components/dropdown-menu";
import { useResumeStore } from "@/features/resume/builder/draft";
import {
lockStylesheetStoreForRestore,
replaceStylesheetStoreAfterRestore,
unlockStylesheetStoreAfterRestore,
} from "@/features/resume/stylesheet/store";
import { useConfirm } from "@/hooks/use-confirm";
import { getResumeErrorMessage } from "@/libs/error-message";
import { formatRelativeTime } from "@/libs/locale";
@@ -53,26 +48,13 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
if (!confirmed) return;
const token = lockStylesheetStoreForRestore(resumeId);
if (!token) return;
try {
const restored = await restoreVersion({ resumeId, versionId });
const applied = replaceStylesheetStoreAfterRestore({
resumeId,
resumeData: restored.resume.data,
initial: restored.stylesheetState,
token,
});
if (!applied) {
unlockStylesheetStoreAfterRestore(token);
return;
}
replaceResumeFromServer(restored.resume as Resume);
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored.resume);
replaceResumeFromServer(restored as Resume);
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
toast.success(t`Your resume has been restored to the selected version.`);
} catch (error) {
unlockStylesheetStoreAfterRestore(token);
toast.error(getResumeErrorMessage(error));
}
};
@@ -1,5 +1,6 @@
// @vitest-environment happy-dom
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
@@ -17,16 +18,8 @@ const resumeMock = vi.hoisted(() => ({
id: string;
name: string;
slug: string;
data: typeof defaultResumeData;
data: ResumeData;
},
stylesheet: {
resumeId: "r1" as string | undefined,
mode: "semantic" as "legacy" | "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
revision: 42,
renderDataVersion: 7,
},
}));
type SectionBaseProps = {
@@ -50,9 +43,6 @@ vi.mock("@/libs/resume/section-title-locale", () => ({
vi.mock("@/features/resume/builder/draft", () => ({
useResume: () => resumeMock.resume,
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
useStylesheetStore: (selector: (state: typeof resumeMock.stylesheet) => unknown) => selector(resumeMock.stylesheet),
}));
const { ExportSectionBuilder } = await import("./export");
@@ -61,15 +51,12 @@ beforeAll(() => {
});
beforeEach(() => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
resumeMock.stylesheet = {
resumeId: "r1",
const data = structuredClone(defaultResumeData);
data.metadata.stylesheet = {
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
revision: 42,
renderDataVersion: 7,
};
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data };
});
afterEach(() => {
@@ -115,7 +102,7 @@ describe("ExportSectionBuilder", () => {
expect(filename).toBe("My Resume.md");
});
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
it("downloads the current stylesheet source in JSON", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
@@ -127,13 +114,7 @@ describe("ExportSectionBuilder", () => {
expect((blob as Blob).type).toBe("application/json");
expect(filename).toBe("My Resume.json");
const exported = JSON.parse(await (blob as Blob).text());
expect(exported.metadata.stylesheet).toEqual({
mode: "semantic",
source: resumeMock.stylesheet.source,
applied: resumeMock.stylesheet.applied,
});
expect(JSON.stringify(exported)).not.toContain("revision");
expect(JSON.stringify(exported)).not.toContain("renderDataVersion");
expect(exported.metadata.stylesheet).toEqual(resumeMock.resume?.data.metadata.stylesheet);
});
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
@@ -155,12 +136,7 @@ describe("ExportSectionBuilder", () => {
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
stylesheet: {
mode: "semantic",
applied: resumeMock.stylesheet.applied,
},
});
expect(createResumePdfBlob).toHaveBeenCalledWith(resumeMock.resume?.data, undefined, undefined);
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
});
@@ -1,10 +1,9 @@
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { useMediaQuery } from "usehooks-ts";
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
import { initializeStylesheetStore, useStylesheetStore } from "@/features/resume/stylesheet/store";
import { orpc } from "@/libs/orpc/client";
import { createNoindexFollowMeta } from "@/libs/seo";
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
@@ -21,9 +20,6 @@ export const Route = createFileRoute("/builder/$resumeId")({
const [layout, resume] = await Promise.all([
getBuilderLayout(),
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
context.queryClient.ensureQueryData(
orpc.resume.stylesheet.getState.queryOptions({ input: { id: params.resumeId } }),
),
]);
return { layout, name: resume.name };
@@ -40,17 +36,11 @@ function RouteComponent() {
const { resumeId } = Route.useParams();
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
const { data: stylesheet } = useSuspenseQuery(
orpc.resume.stylesheet.getState.queryOptions({ input: { id: resumeId } }),
);
const initializeResumeStore = useResumeStore((state) => state.initialize);
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
const isReady = useResumeStore((state) => state.isReady);
const initializedResumeId = useResumeStore((state) => state.resumeId);
const isInitialized = isReady && initializedResumeId === resumeId;
const isStylesheetInitialized = useStylesheetStore((state) => state.resumeId === resumeId);
const stylesheetInitialization = useRef({ resume, stylesheet });
stylesheetInitialization.current = { resume, stylesheet };
useResumeCleanup();
useBuilderResumeUpdateSubscription();
@@ -60,16 +50,6 @@ function RouteComponent() {
initializeResumeStore(resume);
}, [initializeResumeStore, isInitialized, resume]);
useEffect(() => {
if (!isInitialized) return;
const initial = stylesheetInitialization.current;
return initializeStylesheetStore({
resumeId,
initial: initial.stylesheet,
resumeData: initial.resume.data,
});
}, [isInitialized, resumeId]);
useEffect(() => {
mergeResumeMetadata(resume);
}, [
@@ -85,7 +65,7 @@ function RouteComponent() {
resume,
]);
if (!isInitialized || !isStylesheetInitialized) return null;
if (!isInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}