feat: add semantic CSS stylesheets (#3274)

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
Amruth Pillai
2026-07-30 12:39:15 +02:00
committed by GitHub
co-authored by Cursor Agent
parent 4ac19f81b3
commit d2ffbf9618
320 changed files with 78393 additions and 2915 deletions
@@ -6,7 +6,12 @@ import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { useBuilderResumeUpdateSubscription, useResumeStore, useResumeUpdateSubscription } from "./draft";
import {
isEditableElementFocused,
useBuilderResumeUpdateSubscription,
useResumeStore,
useResumeUpdateSubscription,
} from "./draft";
const orpcMocks = vi.hoisted(() => ({
getResumeById: vi.fn(),
@@ -30,6 +35,10 @@ const toastMocks = vi.hoisted(() => ({
error: vi.fn(() => "sync-error-toast"),
}));
const stylesheetMocks = vi.hoisted(() => ({
refresh: vi.fn(),
}));
vi.mock("@orpc/client", () => ({
consumeEventIterator: consumeEventIteratorMock,
}));
@@ -72,6 +81,10 @@ vi.mock("sonner", () => ({
toast: toastMocks,
}));
vi.mock("@/features/resume/stylesheet/store", () => ({
refreshStylesheetStore: stylesheetMocks.refresh,
}));
function cloneResumeData(data: ResumeData): ResumeData {
return structuredClone(data);
}
@@ -122,6 +135,7 @@ describe("builder resume autosave", () => {
i18n.loadAndActivate({ locale: "en-US", messages: {} });
toastMocks.dismiss.mockClear();
toastMocks.error.mockClear();
stylesheetMocks.refresh.mockReset();
useResumeStore.getState().reset();
});
@@ -251,6 +265,21 @@ describe("builder resume autosave", () => {
});
});
describe("editable focus detection", () => {
it("treats CodeMirror descendants as editable", () => {
const editor = document.createElement("div");
editor.className = "cm-editor";
const content = document.createElement("div");
content.tabIndex = 0;
editor.append(content);
document.body.append(editor);
content.focus();
expect(isEditableElementFocused()).toBe(true);
editor.remove();
});
});
describe("builder resume undo/redo", () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -477,4 +506,37 @@ describe("resume update stream subscription", () => {
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
});
it("refetches canonical stylesheet state for stylesheet SSE events", 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>;
};
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 };
useResumeStore.getState().initialize(initial);
renderHook(() => useBuilderResumeUpdateSubscription());
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as {
onEvent: (event: { mutation: string }) => Promise<void>;
};
await act(async () => handlers.onEvent({ mutation: "update" }));
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id, remote.data);
});
});
@@ -10,6 +10,7 @@ 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 = {
@@ -25,7 +26,7 @@ export type Resume = {
};
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
type ResumeUpdateMutation = "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
type ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
type SaveStatus = "idle" | "saving" | "saved" | "error";
@@ -114,7 +115,8 @@ export function isEditableElementFocused(): boolean {
element.tagName === "INPUT" ||
element.tagName === "TEXTAREA" ||
element.tagName === "SELECT" ||
element.isContentEditable
element.isContentEditable ||
element.closest(".cm-editor") !== null
);
}
@@ -614,9 +616,14 @@ 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,4 +1,6 @@
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";
@@ -9,6 +11,22 @@ 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);
@@ -29,13 +47,17 @@ export const createResumePdfBlob = async (
data: ResumeData,
template?: Template,
renderOptions?: ResumePdfRenderOptions,
presentation?: ResumePdfPresentation,
) => {
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
return createPdfBlob({
data,
data: withAppliedStylesheet(data, presentation),
template,
...(renderOptions ? { renderOptions } : {}),
...(presentation && "publicStyleProjection" in presentation
? { publicStyleProjection: presentation.publicStyleProjection }
: {}),
resolveSectionTitle: sectionTitleResolver,
});
};
@@ -0,0 +1,95 @@
// @vitest-environment happy-dom
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";
const mocks = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["local"], { type: "application/pdf" })),
downloadWithAnchor: vi.fn(),
fetch: vi.fn(async (_input: string | URL) => new Response(new Blob(["server"], { type: "application/pdf" }))),
toastError: vi.fn(),
}));
vi.mock("./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"),
error: mocks.toastError,
dismiss: vi.fn(),
},
}));
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
beforeEach(() => {
mocks.createResumePdfBlob.mockClear();
mocks.downloadWithAnchor.mockClear();
mocks.fetch.mockClear();
mocks.toastError.mockClear();
vi.stubGlobal("fetch", mocks.fetch);
});
describe("useResumeExport public PDF", () => {
it("downloads the authorized server blob after one mismatched-projection refetch", 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 { result } = renderHook(() =>
useResumeExport(
{ name: "Sample", slug: "sample", data: sampleResumeData },
{
publicResumePdf: {
stylesheetMode: "semantic",
styleProjection: mismatchedProjection,
refetchStyleProjection,
publicResume: { username: "amruth", slug: "sample" },
},
},
),
);
await act(() => result.current.onDownloadPDF());
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
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" }],
}),
);
const { result } = renderHook(() => useResumeExport({ name: "Sample", slug: "sample", data: sampleResumeData }));
await act(() => result.current.onDownloadPDF());
expect(mocks.downloadWithAnchor).not.toHaveBeenCalled();
expect(mocks.toastError).toHaveBeenCalledTimes(1);
});
});
@@ -1,13 +1,18 @@
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, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { toast } from "sonner";
import { buildDocx } from "@reactive-resume/docx";
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
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";
@@ -24,11 +29,16 @@ const createSectionTitleResolver = async (data: ResumeData) => {
// ponytail: loosened from Resume to Pick so public-resume (where name may be "" for non-owners) can reuse
type ExportableResume = {
id?: string;
name: string;
slug: string;
data: ResumeData;
};
type UseResumeExportOptions = {
publicResumePdf?: PublicResumePdfOptions;
};
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
@@ -41,15 +51,46 @@ type DownloadPdfOptions = {
* Single source of truth for resume export (PDF / DOCX / JSON / Print). Previously duplicated verbatim
* between the builder dock and the right-panel Export section (#17).
*/
export function useResumeExport(resume: ExportableResume | undefined) {
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 blob = new Blob([JSON.stringify(resume.data, null, 2)], { type: "application/json" });
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" });
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
}, [resume]);
}, [canonicalStylesheet, resume]);
const onDownloadMarkdown = useCallback(
async (target: ResumeExportTarget = "resume") => {
@@ -80,18 +121,23 @@ export function useResumeExport(resume: ExportableResume | undefined) {
);
const onDownloadPDF = useCallback(
async (target: ResumeExportTarget = "resume", options?: DownloadPdfOptions) => {
async (target: ResumeExportTarget = "resume", downloadOptions?: DownloadPdfOptions) => {
if (!resume) return;
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
setIsExporting(true);
try {
const data = getResumeExportData(resume.data, target);
const blob = await createResumePdfBlob(
data,
undefined,
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
);
const data = exportOptions.publicResumePdf ? resume.data : getResumeExportData(resume.data, target);
const blob = exportOptions.publicResumePdf
? await resolvePublicResumePdfBlob({ data, ...exportOptions.publicResumePdf })
: await createResumePdfBlob(
data,
undefined,
target === "cover-letter"
? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader }
: undefined,
pdfPresentation,
);
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
} catch {
toast.error(t`There was a problem while generating the PDF, please try again.`);
@@ -100,7 +146,7 @@ export function useResumeExport(resume: ExportableResume | undefined) {
toast.dismiss(toastId);
}
},
[resume],
[exportOptions.publicResumePdf, pdfPresentation, resume],
);
const onPrint = useCallback(async () => {
@@ -108,7 +154,9 @@ export function useResumeExport(resume: ExportableResume | undefined) {
const toastId = toast.loading(t`Preparing your resume for printing...`);
setIsExporting(true);
try {
const blob = await createResumePdfBlob(resume.data);
const blob = exportOptions.publicResumePdf
? await resolvePublicResumePdfBlob({ data: resume.data, ...exportOptions.publicResumePdf })
: await createResumePdfBlob(resume.data, undefined, undefined, pdfPresentation);
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.
@@ -134,7 +182,7 @@ export function useResumeExport(resume: ExportableResume | undefined) {
setIsExporting(false);
toast.dismiss(toastId);
}
}, [resume]);
}, [exportOptions.publicResumePdf, pdfPresentation, resume]);
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
}
@@ -8,7 +8,15 @@ 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" })),
}));
@@ -39,11 +47,28 @@ vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: previewMock.toBlob,
}));
vi.mock("sonner", () => ({
toast: { error: previewMock.toastError },
}));
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 };
@@ -52,7 +77,7 @@ vi.mock("./pdf-canvas", async () => {
PdfCanvasDocument: ({ children, onLoadSuccess }: PdfCanvasDocumentProps) => {
React.useEffect(() => {
onLoadSuccess(pdfDocument);
}, [onLoadSuccess]);
}, []);
return React.createElement(React.Fragment, null, children(pdfDocument));
},
@@ -60,7 +85,7 @@ vi.mock("./pdf-canvas", async () => {
React.useEffect(() => {
onLoadSuccess(pageNumber, { height: 200, width: 100 });
onRenderSuccess?.();
}, [onLoadSuccess, onRenderSuccess, pageNumber]);
}, [pageNumber]);
return React.createElement(
"div",
@@ -77,9 +102,17 @@ 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();
});
it("renders a loading placeholder for each builder layout page while the PDF is generated", () => {
@@ -102,6 +135,76 @@ describe("ResumePreviewClient", () => {
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
});
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, undefined);
});
it("keeps the rendered template identity on the active layer while its replacement renders", async () => {
const view = render(
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
const page = await screen.findByRole("img", { name: "Resume page 1 of 1" });
const activeLayer = page.closest('[aria-hidden="false"]');
expect(activeLayer?.getAttribute("data-resume-preview-template")).toBe("azurill");
previewMock.toBlob.mockImplementationOnce(() => new Promise<Blob>(() => {}));
const glalieData: ResumeData = {
...sampleResumeData,
metadata: { ...sampleResumeData.metadata, template: "glalie" },
};
view.rerender(
<ResumePreviewClient data={glalieData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
);
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(2));
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,
};
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).toHaveBeenCalledTimes(1);
});
it("keeps the active PDF visible and reports later semantic render diagnostics", async () => {
previewMock.builderResumeId = "resume-1";
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",
};
view.rerender(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(2));
await waitFor(() => expect(previewMock.toastError).toHaveBeenCalledTimes(1));
expect(screen.getByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
});
});
@@ -1,12 +1,16 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { CSSProperties } from "react";
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, useRef, useState } from "react";
import { useEffect, useMemo, 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 { usePreviewPausedStore, useResumeData } from "../builder/draft";
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
import { usePreviewPausedStore, useResumeData, useResumeStore } from "../builder/draft";
import { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
import { ResumePreviewLoader } from "./preview.shared";
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
@@ -19,18 +23,20 @@ type PreviewPdf = {
pageSizes: Record<number, PreviewPageSize>;
phase: "active" | "exiting" | "staged";
renderedPages: number[];
template: Template;
};
const UPDATE_DEBOUNCE_MS = 100;
const CROSSFADE_DURATION_MS = 180;
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean): PreviewPdf => ({
const createPreviewPdf = (file: Blob, id: number, hasExistingPreview: boolean, template: Template): PreviewPdf => ({
file,
id,
numPages: 0,
pageSizes: {},
phase: hasExistingPreview ? "staged" : "active",
renderedPages: [],
template,
});
const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => {
@@ -96,6 +102,17 @@ 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[]>([]);
@@ -116,15 +133,25 @@ export function ResumePreviewClient({
const generatePdfPreview = async () => {
try {
if (cancelled || requestId !== requestIdRef.current) return;
const blob = await createResumePdfBlob(resumeData);
const blob = await createResumePdfBlob(resumeData, undefined, undefined, presentation);
if (!cancelled && requestId === requestIdRef.current) {
const nextPdf = createPreviewPdf(blob, pdfIdRef.current++, hasPreviewRef.current);
const nextPdf = createPreviewPdf(
blob,
pdfIdRef.current++,
hasPreviewRef.current,
resumeData.metadata.template,
);
hasPreviewRef.current = true;
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
}
} catch {}
} catch {
if (cancelled || requestId !== requestIdRef.current) return;
toast.error(t`The resume preview could not be updated. The last valid preview is still shown.`, {
id: "resume-preview-render-error",
});
}
};
const timeoutId = window.setTimeout(() => {
@@ -135,7 +162,7 @@ export function ResumePreviewClient({
cancelled = true;
window.clearTimeout(timeoutId);
};
}, [resumeData, paused]);
}, [paused, presentation, resumeData]);
if (!resumeData) return null;
@@ -166,6 +193,7 @@ export function ResumePreviewClient({
<m.div
key={visiblePdf.id}
aria-hidden={visiblePdf.phase !== "active"}
data-resume-preview-template={visiblePdf.template}
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
@@ -2,6 +2,7 @@
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(() => {
@@ -18,6 +19,9 @@ const pdfViewerMock = vi.hoisted(() => {
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
getDocument: vi.fn(() => loadingTask),
fetch: vi.fn(
async (_input: string | URL) => new Response(new Blob(["%PDF-fallback"], { type: "application/pdf" })),
),
instances: [] as Array<{
abortSignal?: AbortSignal;
setDocument: ReturnType<typeof vi.fn>;
@@ -92,6 +96,8 @@ beforeEach(() => {
pdfViewerMock.createResumePdfBlob.mockClear();
pdfViewerMock.getDocument.mockClear();
pdfViewerMock.loadingTask.destroy.mockClear();
pdfViewerMock.fetch.mockClear();
vi.stubGlobal("fetch", pdfViewerMock.fetch);
});
describe("PdfViewer", () => {
@@ -113,4 +119,68 @@ describe("PdfViewer", () => {
expect(viewer.setDocument).toHaveBeenCalledWith(null);
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
});
it("renders a valid public projection 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();
render(
<PdfViewer
data={sampleResumeData}
stylesheetMode="semantic"
styleProjection={projection}
refetchStyleProjection={refetchStyleProjection}
publicResume={{ username: "amruth", slug: "sample" }}
/>,
);
await waitFor(() =>
expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
publicStyleProjection: projection,
}),
);
expect(refetchStyleProjection).not.toHaveBeenCalled();
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);
const view = render(
<PdfViewer
data={sampleResumeData}
stylesheetMode="semantic"
styleProjection={mismatchedProjection}
refetchStyleProjection={refetchStyleProjection}
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);
});
});
@@ -1,4 +1,6 @@
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";
@@ -6,6 +8,7 @@ import { useEffect, useReducer, useRef } from "react";
import { Spinner } from "@reactive-resume/ui/components/spinner";
import { cn } from "@reactive-resume/utils/style";
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
import { resolvePublicResumePdfBlob } from "./public-pdf";
import "pdfjs-dist/legacy/web/pdf_viewer.css";
import "./pdf-viewer.css";
@@ -14,6 +17,13 @@ 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;
};
};
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
@@ -67,11 +77,21 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
}
}
export function PdfViewer({ className, data }: PdfViewerProps) {
export function PdfViewer({
className,
data,
stylesheetMode,
styleProjection,
refetchStyleProjection,
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,
@@ -83,7 +103,29 @@ export function PdfViewer({ className, data }: PdfViewerProps) {
fileRef.current = null;
dispatch({ type: "resetForData" });
void createResumePdfBlob(data)
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 } : {}),
});
};
void createPdf()
.then((blob) => {
if (isCancelled) return;
@@ -100,7 +142,7 @@ export function PdfViewer({ className, data }: PdfViewerProps) {
return () => {
isCancelled = true;
};
}, [data]);
}, [data, publicResume, refetchStyleProjection, styleProjection, stylesheetMode]);
useEffect(() => {
void fileVersion;
@@ -0,0 +1,72 @@
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";
const mocks = vi.hoisted(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["local"], { type: "application/pdf" })),
fetch: vi.fn(async (_input: string | URL) => new Response(new Blob(["server"], { type: "application/pdf" }))),
}));
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: mocks.createResumePdfBlob,
}));
const publicResume = { username: "amruth", slug: "sample" };
beforeEach(() => {
mocks.createResumePdfBlob.mockClear();
mocks.fetch.mockClear();
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,
});
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(await blob.text()).toBe("server");
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,97 @@
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 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
}: 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);
}
return reason
? fetchPublicResumePdf(options.publicResume, reason, projection)
: createResumePdfBlob(data, undefined, undefined, { publicStyleProjection: projection });
}
@@ -1,5 +1,6 @@
// @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";
@@ -11,24 +12,46 @@ 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(() => ({
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
downloadWithAnchor: vi.fn(),
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
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
| {
data: ResumeData;
name: string;
slug: string;
stylesheetMode: "legacy" | "semantic";
},
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: publicResumeMock.resume }),
useQuery: (options: { query: "resume" | "projection" }) =>
options.query === "resume"
? { data: publicResumeMock.resume }
: { ...publicResumeMock.projectionResult, refetch: publicResumeMock.refetchProjection },
}));
vi.mock("@tanstack/react-router", () => ({
@@ -37,21 +60,21 @@ vi.mock("@tanstack/react-router", () => ({
}),
}));
vi.mock("@reactive-resume/utils/file", () => ({
downloadWithAnchor: publicResumeMock.downloadWithAnchor,
generateFilename: publicResumeMock.generateFilename,
}));
vi.mock("./pdf-viewer", () => ({
PdfViewer: publicResumeMock.PdfViewer,
}));
vi.mock("@/libs/orpc/client", () => ({
orpc: { resume: { getBySlug: { queryOptions: () => ({}) } } },
orpc: {
resume: {
getBySlug: { queryOptions: () => ({ query: "resume" }) },
getStyleProjection: { queryOptions: () => ({ query: "projection" }) },
},
},
}));
vi.mock("@/features/resume/export/pdf-document", () => ({
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
vi.mock("@/features/resume/export/use-resume-export", () => ({
useResumeExport: publicResumeMock.useResumeExport,
}));
const { PublicResumeRoute } = await import("./public-resume");
@@ -65,8 +88,21 @@ beforeEach(() => {
data: sampleResumeData,
name: "Sample Resume",
slug: "sample",
stylesheetMode: "semantic",
};
publicResumeMock.projectionResult = {
data: publicResumeMock.projection,
isError: false,
isPending: false,
};
publicResumeMock.PdfViewer.mockClear();
publicResumeMock.refetchProjection.mockReset();
publicResumeMock.refetchProjection.mockResolvedValue({ data: publicResumeMock.projection });
publicResumeMock.useResumeExport.mockReset();
publicResumeMock.useResumeExport.mockReturnValue({
onDownloadPDF: publicResumeMock.onDownloadPDF,
isExporting: false,
});
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
<div className={className} data-testid="pdf-viewer" />
));
@@ -88,6 +124,62 @@ describe("PublicResumeRoute", () => {
expect.objectContaining({ data: sampleResumeData }),
undefined,
);
expect(publicResumeMock.useResumeExport).toHaveBeenCalledWith(publicResumeMock.resume, {
publicResumePdf: expect.objectContaining({
stylesheetMode: "semantic",
styleProjection: publicResumeMock.projection,
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", () => {
@@ -3,6 +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 { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
import { Button } from "@reactive-resume/ui/components/button";
import { LoadingScreen } from "@/components/layout/loading-screen";
@@ -16,9 +17,30 @@ export function PublicResumeRoute() {
const { username, slug } = publicResumeRoute.useParams();
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
const { onDownloadPDF, isExporting } = useResumeExport(resume);
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 } : {}),
},
}
: {}),
});
if (!resume) return <LoadingScreen />;
if (!resume || projectionQuery.isPending) return <LoadingScreen />;
const { basics, picture } = resume.data;
@@ -44,7 +66,14 @@ 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" />
<PdfViewer
data={resume.data}
className="block w-full"
stylesheetMode={resume.stylesheetMode}
styleProjection={styleProjection}
publicResume={publicResume}
refetchStyleProjection={refetchStyleProjection}
/>
</main>
<footer className="flex justify-center print:hidden">
@@ -0,0 +1,37 @@
import type { StyleProgram } from "@reactive-resume/resume/stylesheet";
import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet";
export type SemanticCssColorToken = {
from: number;
to: number;
value: string;
};
const colorValue =
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|currentcolor|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
const isColorProperty = (property: string) =>
PROPERTY_REGISTRY_V1[property] !== undefined &&
(PROPERTY_REGISTRY_V1[property]?.category === "color" || property.endsWith("-color"));
export function collectCompiledColorTokens(
source: string,
program: StyleProgram | null,
): readonly SemanticCssColorToken[] {
if (!program) return [];
const tokens = new Map<string, SemanticCssColorToken>();
for (const rule of program.rules) {
for (const declaration of rule.declarations) {
if (!isColorProperty(declaration.property) || !colorValue.test(declaration.value)) continue;
const declarationSource = source.slice(declaration.range.start.offset, declaration.range.end.offset);
const valueOffset = declarationSource.indexOf(declaration.value, declarationSource.indexOf(":") + 1);
if (valueOffset < 0) continue;
const from = declaration.range.start.offset + valueOffset;
const token = { from, to: from + declaration.value.length, value: declaration.value };
tokens.set(`${token.from}:${token.to}`, token);
}
}
return [...tokens.values()].sort((left, right) => left.from - right.from);
}
@@ -0,0 +1,255 @@
// @vitest-environment happy-dom
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
import { afterEach, describe, expect, it, vi } from "vitest";
import { Transaction } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
import { collectCompiledColorTokens } from "./color-tokens";
import {
compositionAwareDocumentListener,
copySourceToClipboard,
createSemanticCssEditorExtensions,
getSemanticCssCompletionLabels,
getSemanticCssHoverDocumentation,
mapCompilerDiagnostics,
} from "./editor-extensions";
const semanticTree: SemanticNode = {
key: "resume",
kind: "resume",
attributes: { template: "onyx" },
roles: [],
children: [
{
key: "section",
kind: "section",
id: "section-experience",
attributes: { type: "experience", placement: "main" },
roles: [],
children: [
{
key: "item",
kind: "item",
id: "item-current",
attributes: {},
roles: [],
children: [
{
key: "field",
kind: "field",
attributes: { name: "company" },
roles: ["primary-text"],
children: [],
},
],
},
],
},
],
};
const metadata = {
semanticTree,
templateParts: ["timeline-line", "timeline-marker"],
} as const;
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
const views: EditorView[] = [];
afterEach(() => {
for (const view of views.splice(0)) view.destroy();
});
describe("Semantic CSS editor extensions", () => {
it("uses only Semantic CSS registries and the current resume for completion", async () => {
const selectorLabels = await getSemanticCssCompletionLabels("", 0, metadata);
const propertyLabels = await getSemanticCssCompletionLabels("section {\n\tco", 13, metadata);
const variableSource = "resume { --brand-accent: #f00; color: var(--br";
const variableLabels = await getSemanticCssCompletionLabels(variableSource, variableSource.length, metadata);
const systemLabels = await getSemanticCssCompletionLabels("--resume-", 5, metadata);
const directiveLabels = await getSemanticCssCompletionLabels("@", 1, metadata);
expect(selectorLabels).toEqual(
expect.arrayContaining([
"section",
"#section-experience",
"#item-current",
'[name="company"]',
'[role~="primary-text"]',
'template-part[name="timeline-marker"]',
]),
);
expect(propertyLabels).toContain("color");
expect(propertyLabels).toContain("-resume-fixed");
expect(propertyLabels).not.toContain("cursor");
expect(propertyLabels).not.toContain("font-family");
expect(variableLabels).toEqual(expect.arrayContaining(["--brand-accent", "--resume-primary-color"]));
expect(systemLabels).toEqual(expect.arrayContaining(["--resume-primary-color", "--resume-sidebar-width"]));
expect(systemLabels).not.toContain("--resume-font-family");
expect(directiveLabels).toEqual(expect.arrayContaining(["@media", "@version 1;"]));
});
it("offers only the current property's registered compiler vocabulary", () => {
const displaySource = "section { display: f";
const borderStyleSource = "section { border-style: d";
const fontSizeSource = "section { font-size: 1";
const displayLabels = getSemanticCssCompletionLabels(displaySource, displaySource.length, metadata);
const borderStyleLabels = getSemanticCssCompletionLabels(borderStyleSource, borderStyleSource.length, metadata);
const fontSizeLabels = getSemanticCssCompletionLabels(fontSizeSource, fontSizeSource.length, metadata);
expect(displayLabels).toEqual(expect.arrayContaining(["flex", "none", "inherit"]));
expect(displayLabels).not.toEqual(expect.arrayContaining(["portrait", "dashed", "pt"]));
expect(borderStyleLabels).toEqual(expect.arrayContaining(["dashed", "dotted", "solid"]));
expect(borderStyleLabels).not.toContain("double");
expect(fontSizeLabels).toEqual(expect.arrayContaining(["pt", "rem"]));
expect(fontSizeLabels).not.toEqual(expect.arrayContaining(["none", "normal", "max-content"]));
});
it.each(borderShorthands)("offers complete %s shorthand values instead of bare units", (property) => {
const source = `section { ${property}: `;
const labels = getSemanticCssCompletionLabels(source, source.length, metadata);
expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"]));
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"]));
});
it("escapes dynamic IDs and attribute values before inserting selectors", () => {
const unsafeMetadata = {
semanticTree: {
...semanticTree,
children: [
{
key: "unsafe",
kind: "field",
id: "123 current#item",
attributes: { name: 'company"lead\n' },
roles: [],
children: [],
},
],
},
templateParts: ['timeline"marker\n'],
} as const;
const labels = getSemanticCssCompletionLabels("", 0, unsafeMetadata);
expect(labels).toEqual(
expect.arrayContaining([
"#\\31 23\\ current\\#item",
'[name="company\\"lead\\a "]',
'template-part[name="timeline\\"marker\\a "]',
]),
);
expect(labels).not.toContain("#123 current#item");
expect(labels).not.toContain('[name="company"lead\n"]');
});
it("builds hover text from the same registries", () => {
expect(getSemanticCssHoverDocumentation("section", metadata)).toMatch(
/semantic element.*placement.*featured-summary/i,
);
const colorDocumentation = getSemanticCssHoverDocumentation("color", metadata);
expect(colorDocumentation).toMatch(/property.*inherited.*field/i);
expect(colorDocumentation?.match(/section-heading/g)).toHaveLength(1);
expect(getSemanticCssHoverDocumentation("--resume-primary-color", metadata)).toMatch(
/read-only.*builder primary color/i,
);
expect(getSemanticCssHoverDocumentation("#section-experience", metadata)).toMatch(/current resume.*section/i);
expect(getSemanticCssHoverDocumentation('template-part[name="timeline-line"]', metadata)).toMatch(
/current template part/i,
);
});
it("maps compiler offsets and only decorates compiler-confirmed color values", () => {
const source = "@version 1;\nsection { color: #ff0000; background-color: rgb(0 0 0); }\n";
const compiled = compileStylesheet({ languageVersion: 1, text: source });
expect(compiled.program).not.toBeNull();
const tokens = collectCompiledColorTokens(source, compiled.program);
expect(tokens).toEqual([
{ from: source.indexOf("#ff0000"), to: source.indexOf("#ff0000") + 7, value: "#ff0000" },
{
from: source.indexOf("rgb(0 0 0)"),
to: source.indexOf("rgb(0 0 0)") + "rgb(0 0 0)".length,
value: "rgb(0 0 0)",
},
]);
const diagnostic: SemanticCssDiagnostic = {
code: "INVALID_VALUE",
severity: "error",
message: "Bad value",
range: {
start: { line: 1, column: 1, offset: 2 },
end: { line: 1, column: 30, offset: 99 },
},
};
expect(mapCompilerDiagnostics(10, [diagnostic])).toEqual([
expect.objectContaining({ from: 2, to: 10, severity: "error", message: "Bad value" }),
]);
const selected = vi.fn();
const view = new EditorView({
doc: source,
extensions: createSemanticCssEditorExtensions({
metadata,
diagnostics: [],
colorTokens: tokens,
onColorSelect: selected,
}),
});
views.push(view);
const swatches = view.dom.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
expect(swatches).toHaveLength(2);
swatches[0]?.click();
expect(selected).toHaveBeenCalledWith(tokens[0], expect.any(DOMRect));
});
it("preserves exact clipboard text and emits one change for an IME composition", async () => {
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
const source = "@version 1;\n/* exact spacing */\n";
await copySourceToClipboard(source);
expect(writeText).toHaveBeenCalledWith(source);
const onChange = vi.fn();
const view = new EditorView({
doc: "",
extensions: compositionAwareDocumentListener(onChange),
});
views.push(view);
view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" }));
view.dispatch({
changes: { from: 0, insert: "セク" },
annotations: Transaction.userEvent.of("input.type.compose"),
});
view.dispatch({
changes: { from: 0, to: 2, insert: "セクション" },
annotations: Transaction.userEvent.of("input.type.compose"),
});
view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "セクション" }));
await Promise.resolve();
expect(view.state.doc.toString()).toBe("セクション");
expect(onChange).toHaveBeenCalledOnce();
expect(onChange).toHaveBeenCalledWith("セクション");
});
it("opens the built-in search and replace panel", () => {
const view = new EditorView({
doc: "section { color: red; }",
extensions: createSemanticCssEditorExtensions({
metadata,
diagnostics: [],
colorTokens: [],
onColorSelect: vi.fn(),
}),
});
views.push(view);
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "f" }));
expect(view.dom.querySelector("[name=search]")).not.toBeNull();
expect(view.dom.querySelector("[name=replace]")).not.toBeNull();
});
});
@@ -0,0 +1,333 @@
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
import type { Diagnostic } from "@codemirror/lint";
import type { EditorState, Extension } from "@codemirror/state";
import type { DecorationSet, EditorView as EditorViewType, ViewUpdate } from "@codemirror/view";
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet/registry";
import type { SemanticCssColorToken } from "./color-tokens";
import type { SemanticCssEditorMetadata } from "./protocol";
import { autocompletion } from "@codemirror/autocomplete";
import { linter, lintGutter } from "@codemirror/lint";
import { search, searchKeymap } from "@codemirror/search";
import { Decoration, EditorView, hoverTooltip, keymap, ViewPlugin, WidgetType } from "@codemirror/view";
import {
escapeCssIdentifier,
escapeCssString,
PROPERTY_REGISTRY_V1,
SEMANTIC_NODE_KINDS,
SEMANTIC_REGISTRY_V1,
SYSTEM_VARIABLE_REGISTRY_V1,
} from "@reactive-resume/resume/stylesheet/registry";
export type SemanticCssColorSelection = (token: SemanticCssColorToken, rect: DOMRect) => void;
const directives = ["@media", "@version 1;"] as const;
function walk(root: SemanticNode): SemanticNode[] {
const nodes: SemanticNode[] = [];
const stack = [root];
while (stack.length > 0) {
const node = stack.pop();
if (!node) continue;
nodes.push(node);
stack.push(...node.children);
}
return nodes;
}
function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function selectorLabels(metadata: SemanticCssEditorMetadata): string[] {
const nodes = walk(metadata.semanticTree);
const attributes = unique([
"id",
"role",
...Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ attributes }) => attributes),
]);
const roles = unique(Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ roles }) => roles));
return unique([
...SEMANTIC_NODE_KINDS,
"*",
...nodes.flatMap((node) => (node.id ? [`#${escapeCssIdentifier(node.id)}`] : [])),
...attributes.map((attribute) => `[${escapeCssIdentifier(attribute)}]`),
...nodes.flatMap((node) =>
Object.entries(node.attributes).map(
([name, value]) => `[${escapeCssIdentifier(name)}=${escapeCssString(value)}]`,
),
),
...roles.map((role) => `[role~=${escapeCssString(role)}]`),
...metadata.templateParts.map((name) => `template-part[name=${escapeCssString(name)}]`),
":root",
":first-child",
":last-child",
":only-child",
":nth-child()",
":nth-of-type()",
":is()",
":where()",
":not()",
]);
}
function userVariables(source: string): string[] {
return unique([...source.matchAll(/(--(?!resume-)[-_a-zA-Z0-9]+)\s*:/g)].map((match) => match[1] as string));
}
function completionKind(source: string, position: number): "directive" | "property" | "selector" | "system" | "value" {
const before = source.slice(0, position);
if (/--resume-[-\w]*$/.test(before)) return "system";
if (/@[-\w]*$/.test(before)) return "directive";
const open = before.lastIndexOf("{");
const close = before.lastIndexOf("}");
if (open <= close) return "selector";
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
return declaration.includes(":") ? "value" : "property";
}
function declarationProperty(source: string, position: number): string | undefined {
const before = source.slice(0, position);
const open = before.lastIndexOf("{");
const close = before.lastIndexOf("}");
if (open <= close) return;
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
const colon = declaration.indexOf(":");
if (colon < 0) return;
const property = declaration.slice(0, colon).trim().toLowerCase();
return property || undefined;
}
function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] {
switch (completionKind(source, position)) {
case "directive":
return [...directives];
case "property":
return Object.keys(PROPERTY_REGISTRY_V1);
case "selector":
return selectorLabels(metadata);
case "system":
return Object.keys(SYSTEM_VARIABLE_REGISTRY_V1);
case "value": {
const property = declarationProperty(source, position);
const definition = property ? PROPERTY_REGISTRY_V1[property] : undefined;
return unique([
...(definition?.values ?? []),
...(definition?.units ?? []),
...userVariables(source),
...Object.keys(SYSTEM_VARIABLE_REGISTRY_V1),
]);
}
}
}
export function getSemanticCssCompletionLabels(
source: string,
position: number,
metadata: SemanticCssEditorMetadata,
): readonly string[] {
return completionLabels(source, position, metadata);
}
export function getSemanticCssHoverDocumentation(
label: string,
metadata: SemanticCssEditorMetadata,
): string | undefined {
const semantic = SEMANTIC_REGISTRY_V1[label as keyof typeof SEMANTIC_REGISTRY_V1];
if (semantic) {
return `Semantic element ${label}. Attributes: ${semantic.attributes.join(", ") || "none"}. Roles: ${semantic.roles.join(", ") || "none"}.`;
}
const property = PROPERTY_REGISTRY_V1[label];
if (property) {
return `Semantic CSS ${property.category} property ${label}. ${property.inheritable ? "Inherited" : "Not inherited"}. Applies to: ${property.appliesTo.join(", ")}.`;
}
const systemVariable = SYSTEM_VARIABLE_REGISTRY_V1[label as keyof typeof SYSTEM_VARIABLE_REGISTRY_V1];
if (systemVariable) return `Read-only Semantic CSS system variable. ${systemVariable.description}`;
const normalized = label.startsWith("#") ? label.slice(1) : label;
const currentNode = walk(metadata.semanticTree).find((node) => node.id === normalized);
if (currentNode) return `Current resume ${currentNode.kind} ID.`;
const part = label.match(/^template-part\[name="(.+)"\]$/)?.[1] ?? label;
if (metadata.templateParts.includes(part)) return `Current template part ${part}.`;
return;
}
export function mapCompilerDiagnostics(
docLength: number,
diagnostics: readonly SemanticCssDiagnostic[],
): readonly Diagnostic[] {
return diagnostics.map(({ message, severity, range, code }) => ({
from: Math.max(0, Math.min(docLength, range.start.offset)),
to: Math.max(0, Math.min(docLength, Math.max(range.start.offset, range.end.offset))),
severity,
message,
source: code,
}));
}
export function compositionAwareDocumentListener(
onChange: (source: string) => void,
ignore?: (update: ViewUpdate) => boolean,
): Extension {
let composing = false;
return [
EditorView.domEventHandlers({
compositionstart: () => {
composing = true;
return false;
},
compositionend: (_event, view) => {
composing = false;
queueMicrotask(() => onChange(view.state.doc.toString()));
return false;
},
}),
EditorView.updateListener.of((update) => {
if (update.docChanged && !composing && !ignore?.(update)) onChange(update.state.doc.toString());
}),
];
}
function completionSource(metadata: SemanticCssEditorMetadata): CompletionSource {
return (context: CompletionContext): CompletionResult | null => {
const source = context.state.doc.toString();
const labels = completionLabels(source, context.pos, metadata);
const word = context.matchBefore(/(?:--|[-@#])?[-_a-zA-Z0-9]*$/);
if (!context.explicit && (!word || word.from === word.to)) return null;
const options: Completion[] = labels.map((label) => ({
label,
type: label.startsWith("@") ? "keyword" : label.startsWith("#") || label.includes("[") ? "text" : "property",
}));
return { from: word?.from ?? context.pos, options, validFor: /[-_@#a-zA-Z0-9]*/ };
};
}
function tokenAt(state: EditorState, position: number): { from: number; to: number; label: string } | undefined {
const line = state.doc.lineAt(position);
const before = line.text.slice(0, position - line.from).match(/(?:--|[#@])?[-_a-zA-Z0-9]+$/)?.[0] ?? "";
const after = line.text.slice(position - line.from).match(/^[-_a-zA-Z0-9]+/)?.[0] ?? "";
if (!before && !after) return;
const from = position - before.length;
return { from, to: position + after.length, label: `${before}${after}` };
}
function hoverExtension(metadata: SemanticCssEditorMetadata): Extension {
return hoverTooltip((view, position) => {
const token = tokenAt(view.state, position);
if (!token) return null;
const documentation = getSemanticCssHoverDocumentation(token.label, metadata);
if (!documentation) return null;
return {
pos: token.from,
end: token.to,
above: true,
create() {
const dom = document.createElement("div");
dom.className = "cm-semantic-css-hover";
dom.textContent = documentation;
return { dom };
},
};
});
}
class ColorSwatch extends WidgetType {
constructor(
private readonly token: SemanticCssColorToken,
private readonly onSelect: SemanticCssColorSelection,
) {
super();
}
eq(other: ColorSwatch): boolean {
return (
other.token.from === this.token.from && other.token.to === this.token.to && other.token.value === this.token.value
);
}
toDOM(): HTMLElement {
const button = document.createElement("button");
button.type = "button";
button.className = "semantic-css-color-swatch";
button.title = `Edit color ${this.token.value}`;
button.setAttribute("aria-label", button.title);
button.style.backgroundColor = this.token.value;
button.addEventListener("click", () => this.onSelect(this.token, button.getBoundingClientRect()));
return button;
}
ignoreEvent(): boolean {
return false;
}
}
function colorDecorations(
view: EditorViewType,
tokens: readonly SemanticCssColorToken[],
onSelect: SemanticCssColorSelection,
): DecorationSet {
const ranges = tokens
.filter(
(token) =>
token.from >= 0 &&
token.to <= view.state.doc.length &&
view.visibleRanges.some(({ from, to }) => token.to >= from && token.from <= to),
)
.map((token) => Decoration.widget({ widget: new ColorSwatch(token, onSelect), side: 1 }).range(token.to));
return Decoration.set(ranges, true);
}
function colorExtension(tokens: readonly SemanticCssColorToken[], onSelect: SemanticCssColorSelection): Extension {
return [
EditorView.baseTheme({
".semantic-css-color-swatch": {
display: "inline-block",
width: "0.75rem",
height: "0.75rem",
marginInline: "0.25rem",
padding: "0",
verticalAlign: "middle",
border: "1px solid currentColor",
borderRadius: "9999px",
cursor: "pointer",
},
}),
ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorViewType) {
this.decorations = colorDecorations(view, tokens, onSelect);
}
update(update: ViewUpdate) {
if (update.docChanged || update.viewportChanged) {
this.decorations = colorDecorations(update.view, tokens, onSelect);
}
}
},
{ decorations: (plugin) => plugin.decorations },
),
];
}
export function createSemanticCssEditorExtensions(input: {
metadata: SemanticCssEditorMetadata;
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens: readonly SemanticCssColorToken[];
onColorSelect: SemanticCssColorSelection;
}): Extension {
return [
autocompletion({ override: [completionSource(input.metadata)] }),
hoverExtension(input.metadata),
search({ top: true }),
keymap.of(searchKeymap),
lintGutter(),
linter((view) => mapCompilerDiagnostics(view.state.doc.length, input.diagnostics), { delay: 0 }),
colorExtension(input.colorTokens, input.onColorSelect),
];
}
export async function copySourceToClipboard(source: string): Promise<void> {
await navigator.clipboard.writeText(source);
}
export type { SemanticCssEditorMetadata };
@@ -0,0 +1,240 @@
// @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 { EditorView } from "@codemirror/view";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
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 }));
vi.mock("usehooks-ts", async (importOriginal) => ({
...(await importOriginal<typeof import("usehooks-ts")>()),
useMediaQuery: () => media.mobile,
}));
vi.mock("@/features/theme/provider", () => ({
useTheme: () => ({ theme: "light" }),
}));
const error: SemanticCssDiagnostic = {
code: "SEMANTIC_CSS_UNKNOWN_PROPERTY",
severity: "error",
message: "Unknown property",
range: {
start: { line: 2, column: 3, offset: 17 },
end: { line: 2, column: 9, offset: 23 },
},
};
const guideName = /read the applying custom styles guide.*opens in new tab/i;
const expectGuideLink = (root: HTMLElement) => {
const link = within(root).getByRole("link", { name: guideName });
expect(link).toHaveAttribute("href", "https://docs.rxresu.me/applying-custom-styles");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
};
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
Object.defineProperty(Element.prototype, "getAnimations", { configurable: true, value: () => [] });
});
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]} />);
expect(screen.getByText(/preview and export use the last valid version/i)).toBeInTheDocument();
expect(screen.getByText("Unknown property")).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" }]} />);
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", () => {
renderWithI18n(<LegacyStylesheetBanner disabled onActivate={vi.fn()} />);
expect(screen.getByRole("button", { name: /activate semantic css/i })).toBeDisabled();
});
});
describe("StylesheetCodeEditor", () => {
it("owns one LTR EditorView and ignores externally replaced documents", () => {
const onChange = vi.fn();
const destroy = vi.spyOn(EditorView.prototype, "destroy");
const props = {
diagnostics: [] as const,
theme: "light" as const,
onChange,
onUndo: vi.fn(),
onRedo: vi.fn(),
};
const { container, rerender, unmount } = render(
<div style={{ height: 200 }}>
<StylesheetCodeEditor value="@version 1;\n" {...props} />
</div>,
);
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
expect(container.querySelector(".cm-editor")).toHaveAttribute("dir", "ltr");
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute("dir", "ltr");
rerender(
<div style={{ height: 200 }}>
<StylesheetCodeEditor value={"@version 1;\nsection { color: red; }\n"} {...props} />
</div>,
);
expect(onChange).not.toHaveBeenCalled();
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveTextContent("color: red");
rerender(
<div style={{ height: 200 }}>
<StylesheetCodeEditor
value={"@version 1;\nsection { color: red; }\n"}
{...props}
diagnostics={[error]}
theme="dark"
readOnly
/>
</div>,
);
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
"contenteditable",
"false",
);
expect(container.querySelector(".cm-gutter-lint")).toBeInTheDocument();
unmount();
expect(destroy).toHaveBeenCalledOnce();
destroy.mockRestore();
});
it("reuses one React color picker for compiler-confirmed swatches", async () => {
const source = "section { color: #f00; background-color: #fff; }";
const first = source.indexOf("#f00");
const second = source.indexOf("#fff");
const { container } = render(
<StylesheetCodeEditor
value={source}
diagnostics={[]}
colorTokens={[
{ from: first, to: first + 4, value: "#f00" },
{ from: second, to: second + 4, value: "#fff" },
]}
theme="light"
onChange={vi.fn()}
onUndo={vi.fn()}
onRedo={vi.fn()}
/>,
);
const swatches = container.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
expect(swatches).toHaveLength(2);
swatches[0]?.click();
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
swatches[1]?.click();
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
});
});
describe("StylesheetEditorShell", () => {
it("links desktop editor help to the Semantic CSS language reference", () => {
media.mobile = false;
const { container } = render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
expectGuideLink(container);
});
it("makes the editor and mutation controls read-only while a restore is pending", () => {
media.mobile = false;
useStylesheetStore.setState({
mode: "legacy",
source: { languageVersion: 1, text: "@version 1;\n" },
applied: { languageVersion: 1, text: "@version 1;\n" },
diagnostics: [],
status: "idle",
canUndo: true,
canRedo: true,
restoreLocked: true,
});
render(
<I18nProvider i18n={i18n}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
"contenteditable",
"false",
);
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
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}>
<TooltipProvider>
<StylesheetEditorShell />
</TooltipProvider>
</I18nProvider>,
);
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
fireEvent.click(screen.getByRole("button", { name: "Open focus mode" }));
const sheet = await screen.findByRole("dialog");
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();
expectGuideLink(sheet);
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
media.mobile = false;
});
});
@@ -0,0 +1,455 @@
import type { Extension } from "@codemirror/state";
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import type { SemanticCssColorToken } from "./color-tokens";
import type { SemanticCssEditorMetadata } from "./protocol";
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
import { css } from "@codemirror/lang-css";
import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { Annotation, Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
import {
drawSelection,
EditorView,
highlightActiveLine,
highlightSpecialChars,
keymap,
lineNumbers,
} from "@codemirror/view";
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 { useMediaQuery } from "usehooks-ts";
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 { useTheme } from "@/features/theme/provider";
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
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";
const externalReplacement = Annotation.define<boolean>();
const emptyMetadata: SemanticCssEditorMetadata = {
semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
templateParts: [],
};
type EditorCompartments = {
theme: Compartment;
readOnly: Compartment;
intelligence: Compartment;
};
const editorTheme = (dark: boolean): Extension =>
EditorView.theme(
{
"&": {
height: "100%",
backgroundColor: "var(--background)",
color: "var(--foreground)",
direction: "ltr",
},
".cm-scroller": {
overflow: "auto",
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
lineHeight: "1.5",
},
".cm-content": { minHeight: "100%", padding: "0.75rem 0" },
".cm-gutters": {
backgroundColor: "var(--muted)",
borderRight: "1px solid var(--border)",
},
".cm-activeLine, .cm-activeLineGutter": {
backgroundColor: "var(--accent)",
},
"&.cm-focused": { outline: "none" },
},
{ dark },
);
const readOnlyExtensions = (readOnly: boolean): Extension => [
EditorState.readOnly.of(readOnly),
EditorView.editable.of(!readOnly),
];
export type StylesheetCodeEditorProps = {
value: string;
diagnostics: readonly SemanticCssDiagnostic[];
colorTokens?: readonly SemanticCssColorToken[];
metadata?: SemanticCssEditorMetadata;
theme: "light" | "dark";
readOnly?: boolean;
label?: string;
onChange(value: string): void;
onFocusChange?(focused: boolean): void;
onReady?(view: EditorView | null): void;
onUndo(): void;
onRedo(): void;
};
export function StylesheetCodeEditor({
value,
diagnostics,
colorTokens = [],
metadata = emptyMetadata,
theme,
readOnly = false,
label = "Semantic CSS stylesheet",
onChange,
onFocusChange,
onReady,
onUndo,
onRedo,
}: StylesheetCodeEditorProps) {
const hostRef = useRef<HTMLDivElement | null>(null);
const viewRef = useRef<EditorView | null>(null);
const colorTriggerRef = useRef<HTMLButtonElement | null>(null);
const openColorPickerRef = useRef(false);
const compartmentsRef = useRef<EditorCompartments | null>(null);
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
const onChangeRef = useRef(onChange);
const onFocusChangeRef = useRef(onFocusChange);
const onReadyRef = useRef(onReady);
const onUndoRef = useRef(onUndo);
const onRedoRef = useRef(onRedo);
const [selectedColor, setSelectedColor] = useState<{
token: SemanticCssColorToken;
left: number;
top: number;
} | null>(null);
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
const hostRect = hostRef.current?.getBoundingClientRect();
if (!hostRect) return;
openColorPickerRef.current = true;
setSelectedColor({ token, left: rect.left - hostRect.left, top: rect.top - hostRect.top });
}, []);
onChangeRef.current = onChange;
onFocusChangeRef.current = onFocusChange;
onReadyRef.current = onReady;
onUndoRef.current = onUndo;
onRedoRef.current = onRedo;
useEffect(() => {
const parent = hostRef.current;
if (!parent) return;
const initial = initialPropsRef.current;
const compartments: EditorCompartments = {
theme: new Compartment(),
readOnly: new Compartment(),
intelligence: new Compartment(),
};
compartmentsRef.current = compartments;
const view = new EditorView({
parent,
doc: initial.value,
extensions: [
lineNumbers(),
highlightSpecialChars(),
drawSelection(),
highlightActiveLine(),
css(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
EditorView.editorAttributes.of({ dir: "ltr" }),
EditorView.contentAttributes.of({ "aria-label": initial.label, dir: "ltr", spellcheck: "false" }),
Prec.high(
keymap.of([
{
key: "Mod-z",
run: () => {
onUndoRef.current();
return true;
},
},
{
key: "Mod-Shift-z",
run: () => {
onRedoRef.current();
return true;
},
},
{
key: "Mod-y",
run: () => {
onRedoRef.current();
return true;
},
},
]),
),
keymap.of([indentWithTab, ...defaultKeymap]),
EditorView.domEventHandlers({
focus: () => {
onFocusChangeRef.current?.(true);
},
blur: () => {
onFocusChangeRef.current?.(false);
},
}),
compositionAwareDocumentListener(
(source) => onChangeRef.current(source),
(update) => update.transactions.some((transaction) => transaction.annotation(externalReplacement)),
),
compartments.theme.of(editorTheme(initial.theme === "dark")),
compartments.readOnly.of(readOnlyExtensions(initial.readOnly)),
compartments.intelligence.of(
createSemanticCssEditorExtensions({
metadata: initial.metadata,
diagnostics: initial.diagnostics,
colorTokens: initial.colorTokens,
onColorSelect: selectColor,
}),
),
],
});
viewRef.current = view;
onReadyRef.current?.(view);
return () => {
onReadyRef.current?.(null);
view.destroy();
viewRef.current = null;
compartmentsRef.current = null;
};
}, [selectColor]);
useEffect(() => {
const view = viewRef.current;
const compartments = compartmentsRef.current;
if (!view || !compartments) return;
view.dispatch({ effects: compartments.theme.reconfigure(editorTheme(theme === "dark")) });
}, [theme]);
useEffect(() => {
const view = viewRef.current;
const compartments = compartmentsRef.current;
if (!view || !compartments) return;
view.dispatch({ effects: compartments.readOnly.reconfigure(readOnlyExtensions(readOnly)) });
}, [readOnly]);
useEffect(() => {
const view = viewRef.current;
const compartments = compartmentsRef.current;
if (!view || !compartments) return;
view.dispatch({
effects: compartments.intelligence.reconfigure(
createSemanticCssEditorExtensions({
metadata,
diagnostics,
colorTokens,
onColorSelect: selectColor,
}),
),
});
}, [colorTokens, diagnostics, metadata, selectColor]);
useEffect(() => {
const view = viewRef.current;
if (!view || view.state.doc.toString() === value) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: value },
annotations: externalReplacement.of(true),
});
}, [value]);
useEffect(() => {
if (!selectedColor || !openColorPickerRef.current) return;
openColorPickerRef.current = false;
queueMicrotask(() => colorTriggerRef.current?.click());
}, [selectedColor]);
const updateColor = (value: string) => {
const view = viewRef.current;
if (!view || !selectedColor) return;
const { from, to } = selectedColor.token;
view.dispatch({
changes: { from, to, insert: value },
annotations: Transaction.userEvent.of("input"),
});
setSelectedColor((current) =>
current
? {
...current,
token: { from, to: from + value.length, value },
}
: null,
);
};
return (
<div ref={hostRef} className="relative h-full overflow-hidden rounded-md border text-xs" dir="ltr">
{selectedColor && (
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
<ColorPicker
value={selectedColor.token.value}
onChange={updateColor}
trigger={
<PopoverTrigger
render={
<button
ref={colorTriggerRef}
data-semantic-css-color-picker-trigger=""
type="button"
title={t`Edit color ${selectedColor.token.value}`}
aria-label={t`Edit color ${selectedColor.token.value}`}
className="pointer-events-auto size-3 rounded-full border border-foreground/40"
style={{ backgroundColor: selectedColor.token.value }}
/>
}
/>
}
/>
</div>
)}
</div>
);
}
export type StylesheetEditorShellProps = {
readOnly?: boolean;
};
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
const { theme } = useTheme();
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
const [focusOpen, setFocusOpen] = useState(false);
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 editorViewRef = useRef<EditorView | null>(null);
const hasErrors = status === "error" || diagnostics.some(({ severity }) => severity === "error");
const isChecking = status === "compiling" || status === "preflighting" || status === "saving";
useEffect(
() => () => {
restoreDesktopRef.current?.();
},
[],
);
useEffect(() => {
refreshIntelligence();
}, [refreshIntelligence]);
const toggleFocus = () => {
if (isMobile) {
setFocusOpen((open) => !open);
return;
}
if (restoreDesktopRef.current) {
restoreDesktopRef.current();
restoreDesktopRef.current = null;
setFocusOpen(false);
return;
}
const { rightSidebar, layout, setLayout } = useBuilderSidebarStore.getState();
restoreDesktopRef.current = enterStylesheetFocusMode({
rightPanel: rightSidebar,
currentLayout: layout,
setLayout,
});
setFocusOpen(true);
};
const editor = (
<StylesheetCodeEditor
value={source}
diagnostics={diagnostics}
colorTokens={colorTokens}
metadata={metadata}
theme={theme}
readOnly={readOnly || restoreLocked}
label={t`Semantic CSS stylesheet`}
onChange={setSourceText}
onFocusChange={setFocused}
onReady={(view) => {
editorViewRef.current = view;
}}
onUndo={undo}
onRedo={redo}
/>
);
const editorChrome = (
<div className="space-y-3">
{mode === "legacy" && (
<LegacyStylesheetBanner disabled={restoreLocked || hasErrors || isChecking} onActivate={activate} />
)}
<StylesheetToolbar
source={source}
canUndo={canUndo}
canRedo={canRedo}
focused={focusOpen}
disabled={restoreLocked}
onUndo={undo}
onRedo={redo}
onFormat={() => {
const view = editorViewRef.current;
if (view) void formatEditorDocument(view).catch(() => undefined);
}}
onReset={() => setSourceText(applied)}
onFocusToggle={toggleFocus}
/>
<p className="flex items-center gap-1.5 text-muted-foreground text-xs">
<BookOpenIcon aria-hidden="true" className="shrink-0" />
<span>
<Trans>Not sure what to write?</Trans>{" "}
<a
className="text-primary underline underline-offset-4"
href="https://docs.rxresu.me/applying-custom-styles"
target="_blank"
rel="noopener noreferrer"
>
<Trans>Read the Applying Custom Styles guide.</Trans>
<span className="sr-only">
{" "}
(<Trans>opens in new tab</Trans>)
</span>
</a>
</span>
</p>
<div className={focusOpen ? (isMobile ? "h-[55svh]" : "h-[calc(100svh-14rem)]") : "h-72"}>{editor}</div>
<StylesheetStatus mode={mode} status={status} diagnostics={diagnostics} />
</div>
);
return (
<div>
{!(isMobile && focusOpen) && editorChrome}
<Sheet open={isMobile && focusOpen} onOpenChange={setFocusOpen}>
<SheetContent side="right" className="w-full max-w-full gap-3 overflow-hidden p-4 sm:max-w-full">
<SheetTitle>
<Trans>Semantic CSS stylesheet</Trans>
</SheetTitle>
<div className="min-h-0 flex-1 overflow-y-auto">{isMobile && focusOpen ? editorChrome : null}</div>
</SheetContent>
</Sheet>
</div>
);
}
export default StylesheetEditorShell;
@@ -0,0 +1,18 @@
import { describe, expect, it, vi } from "vitest";
import { enterStylesheetFocusMode } from "./focus-mode";
describe("stylesheet focus mode", () => {
it("resizes and restores the desktop right panel", () => {
const currentLayout = { left: 22, artboard: 56, right: 22 };
const resize = vi.fn();
const setLayout = vi.fn();
const rightPanel = { current: { resize } };
const restore = enterStylesheetFocusMode({ rightPanel, currentLayout, setLayout });
expect(resize).toHaveBeenCalledWith("45%");
restore();
expect(resize).toHaveBeenLastCalledWith("22%");
expect(setLayout).toHaveBeenCalledWith(currentLayout);
});
});
@@ -0,0 +1,24 @@
import type { BuilderLayout } from "@/routes/builder/$resumeId/-store/sidebar";
type FocusPanel = {
current: { resize(size: string): void } | null;
};
export type StylesheetFocusModeInput = {
rightPanel: FocusPanel | null;
currentLayout: BuilderLayout;
setLayout(layout: BuilderLayout): void;
};
export function enterStylesheetFocusMode({
rightPanel,
currentLayout,
setLayout,
}: StylesheetFocusModeInput): () => void {
rightPanel?.current?.resize("45%");
return () => {
rightPanel?.current?.resize(`${currentLayout.right}%`);
setLayout(currentLayout);
};
}
@@ -0,0 +1,54 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from "vitest";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { formatEditorDocument, formatSemanticCss } from "./formatter";
const views: EditorView[] = [];
afterEach(() => {
for (const view of views.splice(0)) view.destroy();
});
describe("Semantic CSS formatter", () => {
it("preserves comments and translates the cursor", async () => {
const result = await formatSemanticCss("/* keep */ section{color:red}", 18);
expect(result.formatted).toContain("/* keep */");
expect(result.formatted).toContain("section {");
expect(result.cursorOffset).toBeGreaterThan(0);
});
it("applies an explicit format as one editor transaction", async () => {
const transactions = vi.fn();
const view = new EditorView({
state: EditorState.create({
doc: "/* keep */ section{color:red}",
selection: { anchor: 18 },
extensions: EditorView.updateListener.of((update) => {
if (update.docChanged) transactions(update.transactions);
}),
}),
});
views.push(view);
await formatEditorDocument(view);
expect(view.state.doc.toString()).toContain("section {");
expect(transactions).toHaveBeenCalledOnce();
expect(transactions.mock.calls[0]?.[0]).toHaveLength(1);
expect(view.state.selection.main.head).toBeGreaterThan(0);
});
it("leaves malformed source untouched when formatting fails", async () => {
const dispatch = vi.spyOn(EditorView.prototype, "dispatch");
const view = new EditorView({ doc: "section {" });
views.push(view);
await expect(formatEditorDocument(view)).rejects.toThrow(/css|syntax|unexpected/i);
expect(view.state.doc.toString()).toBe("section {");
expect(dispatch).not.toHaveBeenCalled();
dispatch.mockRestore();
});
});
@@ -0,0 +1,33 @@
import type { EditorView } from "@codemirror/view";
import { EditorSelection, Transaction } from "@codemirror/state";
export type FormattedSemanticCss = {
formatted: string;
cursorOffset: number;
};
export async function formatSemanticCss(source: string, cursorOffset: number): Promise<FormattedSemanticCss> {
const [{ formatWithCursor }, { default: postcss }] = await Promise.all([
import("prettier/standalone"),
import("prettier/plugins/postcss"),
]);
return formatWithCursor(source, {
parser: "css",
plugins: [postcss],
cursorOffset,
useTabs: true,
tabWidth: 4,
printWidth: 120,
});
}
export async function formatEditorDocument(view: EditorView): Promise<void> {
const source = view.state.doc.toString();
const result = await formatSemanticCss(source, view.state.selection.main.head);
if (view.state.doc.toString() !== source) return;
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: result.formatted },
selection: EditorSelection.cursor(Math.min(result.cursorOffset, result.formatted.length)),
annotations: Transaction.userEvent.of("input.format"),
});
}
@@ -0,0 +1,43 @@
import { Trans } from "@lingui/react/macro";
import { ArrowRightIcon, InfoIcon } from "@phosphor-icons/react";
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
import { Button } from "@reactive-resume/ui/components/button";
export type LegacyStylesheetBannerProps = {
disabled: boolean;
onActivate(): void;
};
export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStylesheetBannerProps) {
return (
<Alert>
<InfoIcon />
<AlertTitle>
<Trans>Converted stylesheet draft</Trans>
</AlertTitle>
<AlertDescription className="space-y-3">
<p>
<Trans>Your legacy styles remain active until you explicitly activate this Semantic CSS draft.</Trans>
</p>
<Button type="button" size="sm" disabled={disabled} onClick={onActivate}>
<Trans>Activate Semantic CSS</Trans>
<ArrowRightIcon data-icon="inline-end" />
</Button>
</AlertDescription>
</Alert>
);
}
export function SemanticStylesheetReadOnlyNotice() {
return (
<Alert>
<InfoIcon />
<AlertTitle>
<Trans>Semantic styles remain active</Trans>
</AlertTitle>
<AlertDescription>
<Trans>This instance does not currently allow Semantic CSS editing.</Trans>
</AlertDescription>
</Alert>
);
}
@@ -0,0 +1,62 @@
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();
});
});
@@ -0,0 +1,39 @@
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();
}
}
}
@@ -0,0 +1,58 @@
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 },
});
});
});
@@ -0,0 +1,94 @@
/// <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 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", "The PDF preflight worker failed.");
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) });
});
@@ -0,0 +1,85 @@
import type {
BrowserPdfPreflightResult,
PdfPreflightPageLimits,
StylesheetPreflightInput,
} from "@reactive-resume/pdf/preflight";
import type {
AuthoredPageContext,
BaseSettingsSnapshot,
SemanticCssDiagnostic,
SemanticNode,
StyleProgram,
} from "@reactive-resume/resume/stylesheet";
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
import type { SemanticCssColorToken } from "./color-tokens";
export type SemanticCssEditorMetadata = {
semanticTree: SemanticNode;
templateParts: readonly string[];
};
export type CompileWorkerInput = {
editGeneration: number;
source: StylesheetSource;
semanticTree: SemanticNode;
baseSettings: BaseSettingsSnapshot;
pages: readonly AuthoredPageContext[];
};
export type CompileWorkerRequest = CompileWorkerInput & {
type: "compile";
requestId: number;
};
export type CompileWorkerResponse = {
type: "compile_result";
requestId: number;
editGeneration: number;
program: StyleProgram | null;
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] : [];
}
@@ -0,0 +1,155 @@
// @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();
});
});
@@ -0,0 +1,70 @@
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
import { Trans } from "@lingui/react/macro";
import { WarningCircleIcon, WarningIcon } from "@phosphor-icons/react";
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";
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";
return (
<div className="space-y-2" aria-live="polite">
{hasErrors ? (
<Badge variant="destructive">
<WarningCircleIcon data-icon="inline-start" />
<Trans>Error</Trans>
</Badge>
) : isPending ? (
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</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>}
</Badge>
) : (
<Badge variant="secondary">
{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Applied</Trans>}
</Badge>
)}
{hasErrors && (
<Alert variant="destructive">
<WarningCircleIcon />
<AlertTitle>
<Trans>Stylesheet has errors</Trans>
</AlertTitle>
<AlertDescription>
<Trans>Preview and export use the last valid version.</Trans>
</AlertDescription>
</Alert>
)}
{diagnostics.length > 0 && (
<ScrollArea className="max-h-32 rounded-md border">
<ul className="space-y-2 p-3 text-xs">
{diagnostics.map((diagnostic) => (
<li key={`${diagnostic.code}-${diagnostic.range.start.offset}`} className="space-y-0.5">
<p className="font-medium">{diagnostic.message}</p>
<p className="text-muted-foreground">
<Trans>
Line {diagnostic.range.start.line}, column {diagnostic.range.start.column}
</Trans>
</p>
</li>
))}
</ul>
</ScrollArea>
)}
</div>
);
}
@@ -0,0 +1,789 @@
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("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);
});
});
@@ -0,0 +1,677 @@
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 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 {
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], 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,22 @@
/// <reference lib="webworker" />
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
import { analyzeStylesheet, compileStylesheet } 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;
const response: CompileWorkerResponse = {
type: "compile_result",
requestId: data.requestId,
editGeneration: data.editGeneration,
program: compiled.program,
diagnostics,
colorTokens: collectCompiledColorTokens(data.source.text, compiled.program),
};
self.postMessage(response);
});
@@ -0,0 +1,84 @@
import { t } from "@lingui/core/macro";
import {
ArrowCounterClockwiseIcon,
ArrowsInIcon,
ArrowsOutIcon,
ArrowUUpLeftIcon,
ArrowUUpRightIcon,
CopyIcon,
MagicWandIcon,
} from "@phosphor-icons/react";
import { Button } from "@reactive-resume/ui/components/button";
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
import { copySourceToClipboard } from "./editor-extensions";
type ToolbarButtonProps = {
label: string;
disabled?: boolean;
onClick(): void;
children: React.ReactNode;
};
function ToolbarButton({ label, disabled, onClick, children }: ToolbarButtonProps) {
return (
<Tooltip>
<TooltipTrigger
render={
<Button type="button" size="icon-sm" variant="ghost" aria-label={label} disabled={disabled} onClick={onClick}>
{children}
</Button>
}
/>
<TooltipContent>{label}</TooltipContent>
</Tooltip>
);
}
export type StylesheetToolbarProps = {
source: string;
canUndo: boolean;
canRedo: boolean;
focused: boolean;
disabled?: boolean;
onUndo(): void;
onRedo(): void;
onFormat(): void;
onReset(): void;
onFocusToggle(): void;
};
export function StylesheetToolbar({
source,
canUndo,
canRedo,
focused,
disabled = false,
onUndo,
onRedo,
onFormat,
onReset,
onFocusToggle,
}: StylesheetToolbarProps) {
return (
<div className="flex flex-wrap items-center gap-1" role="toolbar" aria-label={t`Stylesheet editor`}>
<ToolbarButton label={t`Undo stylesheet edit`} disabled={disabled || !canUndo} onClick={onUndo}>
<ArrowUUpLeftIcon data-icon="inline-start" />
</ToolbarButton>
<ToolbarButton label={t`Redo stylesheet edit`} disabled={disabled || !canRedo} onClick={onRedo}>
<ArrowUUpRightIcon data-icon="inline-start" />
</ToolbarButton>
<ToolbarButton label={t`Copy stylesheet`} onClick={() => void copySourceToClipboard(source)}>
<CopyIcon data-icon="inline-start" />
</ToolbarButton>
<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>
</div>
);
}
@@ -0,0 +1,195 @@
import { describe, expect, it, vi } from "vitest";
import { getPreflightTransferables } from "./protocol";
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
type Listener = (event: MessageEvent) => void;
function worker() {
const listeners = new Set<Listener>();
return {
postMessage: vi.fn(),
terminate: vi.fn(),
addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)),
removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)),
emit(data: unknown) {
for (const listener of listeners) listener(new MessageEvent("message", { data }));
},
};
}
describe("stylesheet worker clients", () => {
it("rejects stale compiler results by request id", async () => {
const fake = worker();
const client = createCompileWorkerClient(() => fake);
const first = client.compile({ editGeneration: 1 } as never);
const second = client.compile({ editGeneration: 2 } as never);
fake.emit({ type: "compile_result", requestId: 1, editGeneration: 1, program: null, diagnostics: [] });
fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] });
await expect(first).rejects.toThrow("stale");
await expect(second).resolves.toMatchObject({ requestId: 2 });
});
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", () => {
const fake = worker();
const createWorker = vi.fn(() => fake);
const client = createPreflightWorkerClient(createWorker, 5_000);
client.warmup();
expect(createWorker).toHaveBeenCalledOnce();
expect(fake.addEventListener).toHaveBeenCalledOnce();
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 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();
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]);
});
});
@@ -0,0 +1,207 @@
import type {
CompileWorkerInput,
CompileWorkerRequest,
CompileWorkerResponse,
PreflightWorkerError,
PreflightWorkerInput,
PreflightWorkerReady,
PreflightWorkerRequest,
PreflightWorkerResponse,
} from "./protocol";
type WorkerListener = (event: MessageEvent<unknown>) => void;
export type StylesheetWorker = {
postMessage(message: unknown, transfer?: Transferable[]): void;
terminate(): void;
addEventListener(type: "message", listener: WorkerListener): void;
removeEventListener(type: "message", listener: WorkerListener): void;
};
type Pending<T> = {
resolve(value: T): void;
reject(error: Error): void;
};
export function createCompileWorkerClient(createWorker: () => StylesheetWorker) {
const worker = createWorker();
const pending = new Map<number, Pending<CompileWorkerResponse>>();
let latestRequestId = 0;
const onMessage: WorkerListener = ({ data }) => {
const response = data as CompileWorkerResponse;
if (response?.type !== "compile_result") return;
const request = pending.get(response.requestId);
if (!request) return;
pending.delete(response.requestId);
if (response.requestId !== latestRequestId) {
request.reject(new Error("Discarded stale stylesheet compiler result."));
return;
}
request.resolve(response);
};
worker.addEventListener("message", onMessage);
return {
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse> {
const requestId = ++latestRequestId;
const request: CompileWorkerRequest = { ...input, type: "compile", requestId };
return new Promise((resolve, reject) => {
pending.set(requestId, { resolve, reject });
worker.postMessage(request);
});
},
destroy() {
worker.removeEventListener("message", onMessage);
worker.terminate();
for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated."));
pending.clear();
},
};
}
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 terminate = () => {
if (!worker) return;
worker.removeEventListener("message", onMessage);
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);
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,6 +17,11 @@ 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";
@@ -39,7 +44,7 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
enabled: open,
});
const { mutate: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
const handleRestore = async (versionId: string) => {
const confirmed = await confirm(t`Restore this version?`, {
@@ -48,18 +53,28 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
if (!confirmed) return;
restoreVersion(
{ resumeId, versionId },
{
onSuccess: (restored) => {
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.`);
},
onError: (error) => toast.error(getResumeErrorMessage(error)),
},
);
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);
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));
}
};
return (
@@ -1,4 +1,5 @@
import type { RightSidebarSection } from "@/libs/resume/section";
import { useRouteContext } from "@tanstack/react-router";
import { Fragment, useCallback, useRef } from "react";
import { match } from "ts-pattern";
import { Button } from "@reactive-resume/ui/components/button";
@@ -22,13 +23,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
import { TemplateSectionBuilder } from "./sections/template";
import { TypographySectionBuilder } from "./sections/typography";
function getSectionComponent(type: RightSidebarSection) {
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) {
return match(type)
.with("template", () => <TemplateSectionBuilder />)
.with("layout", () => <LayoutSectionBuilder />)
.with("typography", () => <TypographySectionBuilder />)
.with("design", () => <DesignSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder />)
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />)
.with("page", () => <PageSectionBuilder />)
.with("notes", () => <NotesSectionBuilder />)
.with("sharing", () => <SharingSectionBuilder />)
@@ -41,6 +42,8 @@ function getSectionComponent(type: RightSidebarSection) {
export function BuilderSidebarRight() {
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
const context = useRouteContext({ strict: false });
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
return (
<>
@@ -53,7 +56,7 @@ export function BuilderSidebarRight() {
<div className="space-y-4 p-4">
{rightSidebarSections.map((section) => (
<Fragment key={section}>
{getSectionComponent(section)}
{getSectionComponent(section, semanticCssAuthoring)}
<Separator />
</Fragment>
))}
@@ -40,8 +40,13 @@ vi.mock("@/features/resume/builder/draft", () => ({
useUpdateResumeData: () => updateResumeData,
}));
vi.mock("@/features/resume/stylesheet/editor", () => ({
default: () => <div data-testid="semantic-css-editor-shell">Semantic CSS editor</div>,
}));
const { CustomStylesSectionBuilder } = await import("./custom-styles");
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
beforeAll(() => {
i18n.loadAndActivate({ locale: "en", messages: {} });
@@ -49,12 +54,13 @@ beforeAll(() => {
beforeEach(() => {
updateResumeData.mockClear();
useStylesheetStore.setState({ mode: "legacy" });
});
const renderCustomStyles = () =>
const renderCustomStyles = (authoringEnabled = false) =>
render(
<I18nProvider i18n={i18n}>
<CustomStylesSectionBuilder />
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} />
</I18nProvider>,
);
@@ -75,6 +81,21 @@ describe("CustomStylesSectionBuilder", () => {
});
});
it("loads the Semantic CSS shell only when authoring is enabled", async () => {
renderCustomStyles(true);
expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
});
it("shows a read-only notice for active Semantic CSS when authoring is disabled", () => {
useStylesheetStore.setState({ mode: "semantic" });
renderCustomStyles();
expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument();
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
});
it("renders structured style rule controls", async () => {
renderCustomStyles();
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
import type { ComboboxOption } from "@/components/ui/combobox";
import { Trans } from "@lingui/react/macro";
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
import { Button } from "@reactive-resume/ui/components/button";
import { Input } from "@reactive-resume/ui/components/input";
@@ -20,9 +20,14 @@ import { cn } from "@reactive-resume/utils/style";
import { ColorPicker } from "@/components/input/color-picker";
import { Combobox } from "@/components/ui/combobox";
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
import { SemanticStylesheetReadOnlyNotice } from "@/features/resume/stylesheet/legacy-banner";
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
import { getSectionTitle } from "@/libs/resume/section";
import { useSectionStore } from "../../../-store/section";
import { SectionBase } from "../shared/section-base";
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
type TargetScope = StyleRuleTarget["scope"];
type StyleSlotOption = {
@@ -100,15 +105,36 @@ const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-
const compactSpacingInputClassName =
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
export function CustomStylesSectionBuilder() {
export type CustomStylesSectionBuilderProps = {
authoringEnabled?: boolean;
};
export function CustomStylesSectionBuilder({ authoringEnabled = false }: CustomStylesSectionBuilderProps) {
const mode = useStylesheetStore((state) => state.mode);
const collapsed = useSectionStore((state) => state.sections.styles?.collapsed ?? false);
return (
<SectionBase type="styles" className="space-y-4">
<CustomStylesSectionForm />
{authoringEnabled ? (
collapsed ? null : (
<Suspense
fallback={
<div role="status" className="h-72 animate-pulse rounded-md bg-muted" aria-label="Loading editor" />
}
>
<StylesheetEditorShell />
</Suspense>
)
) : mode === "semantic" ? (
<SemanticStylesheetReadOnlyNotice />
) : (
<LegacyCustomStylesSectionForm />
)}
</SectionBase>
);
}
function CustomStylesSectionForm() {
function LegacyCustomStylesSectionForm() {
const resume = useCurrentResume();
const data = resume.data;
const updateResumeData = useUpdateResumeData();
@@ -19,6 +19,14 @@ const resumeMock = vi.hoisted(() => ({
slug: string;
data: typeof defaultResumeData;
},
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 = {
@@ -42,6 +50,9 @@ 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");
@@ -51,6 +62,14 @@ beforeAll(() => {
beforeEach(() => {
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
resumeMock.stylesheet = {
resumeId: "r1",
mode: "semantic",
source: { languageVersion: 1, text: "@version 1;\nname {" },
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
revision: 42,
renderDataVersion: 7,
};
});
afterEach(() => {
@@ -96,7 +115,7 @@ describe("ExportSectionBuilder", () => {
expect(filename).toBe("My Resume.md");
});
it("downloads a JSON blob when the JSON button is clicked", () => {
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
renderExport();
openDialog();
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
@@ -107,6 +126,14 @@ describe("ExportSectionBuilder", () => {
expect(blob).toBeInstanceOf(Blob);
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");
});
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
@@ -128,6 +155,12 @@ describe("ExportSectionBuilder", () => {
await Promise.resolve();
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
stylesheet: {
mode: "semantic",
applied: resumeMock.stylesheet.applied,
},
});
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
});
@@ -1,9 +1,10 @@
import type { BuilderLayout } from "./-store/sidebar";
import { useSuspenseQuery } from "@tanstack/react-query";
import { createFileRoute, redirect } from "@tanstack/react-router";
import { useEffect } from "react";
import { useEffect, useRef } 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";
@@ -20,6 +21,9 @@ 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 };
@@ -36,11 +40,17 @@ 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();
@@ -50,6 +60,16 @@ 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);
}, [
@@ -65,7 +85,7 @@ function RouteComponent() {
resume,
]);
if (!isInitialized) return null;
if (!isInitialized || !isStylesheetInitialized) return null;
return <BuilderLayoutShell initialLayout={initialLayout} />;
}