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
View File
@@ -7,6 +7,7 @@
"dev": "tsx watch src/index.ts",
"build": "tsdown",
"start": "node dist/index.mjs",
"docs:gen": "tsx src/openapi/generate-spec.ts",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
@@ -36,6 +37,7 @@
"@better-auth/infra": "^0.3.7",
"@better-auth/oauth-provider": "^1.6.25",
"@better-auth/passkey": "^1.6.25",
"@bramus/specificity": "^2.4.2",
"@hono/node-server": "^2.0.12",
"@modelcontextprotocol/sdk": "^1.30.0",
"@orpc/client": "^1.14.12",
@@ -50,6 +52,7 @@
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/mcp": "workspace:*",
"@reactive-resume/pdf": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@sindresorhus/slugify": "^3.0.0",
@@ -58,7 +61,9 @@
"ai": "^7.0.37",
"bcrypt": "^6.0.0",
"better-auth": "1.6.25",
"canonicalize": "^3.0.0",
"cjk-regex": "^3.4.0",
"css-tree": "^3.2.1",
"deepmerge-ts": "^7.1.5",
"drizzle-orm": "1.0.0-rc.4",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
@@ -69,6 +74,7 @@
"node-html-parser": "^9.0.0",
"nodemailer": "^9.0.3",
"ollama-ai-provider-v2": "^4.0.1",
"pdfjs-dist": "^6.2.108",
"pg": "^8.22.0",
"phosphor-icons-react-pdf": "^0.1.3",
"react": "^19.2.8",
+56
View File
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
handleUpload: vi.fn(),
handleMcp: vi.fn(),
handleResumePdfDownload: vi.fn(),
handlePublicResumePdf: vi.fn(),
handleMcpServerCard: vi.fn(),
handleOAuthAuthorizationServer: vi.fn(),
handleOAuthProtectedResource: vi.fn(),
@@ -69,6 +70,15 @@ vi.mock("./resume-pdf", () => ({
handleResumePdfDownload: mocks.handleResumePdfDownload,
}));
vi.mock("./public-resume-pdf", () => ({
handlePublicResumePdf: mocks.handlePublicResumePdf,
}));
const transportEnv = (remoteAddress: string) =>
({
incoming: { socket: { remoteAddress } },
}) as never;
beforeEach(() => {
vi.clearAllMocks();
mocks.handleAuth.mockResolvedValue(new Response("auth"));
@@ -79,6 +89,7 @@ beforeEach(() => {
mocks.handleUpload.mockResolvedValue(new Response("upload"));
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
@@ -117,6 +128,51 @@ describe("createApp", () => {
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
const { createApp } = await import("./app");
const app = createApp();
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
headers: { "x-forwarded-for": "198.51.100.1" },
});
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf?reason=render-data-hash", {
headers: { "x-forwarded-for": "198.51.100.2" },
});
const env = transportEnv("203.0.113.9");
const response = await app.fetch(first, env);
await app.fetch(rotated, env);
await expect(response.text()).resolves.toBe("public-pdf");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
expect(mocks.handleWebApp).not.toHaveBeenCalled();
});
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
const { createApp } = await import("./app");
const app = createApp();
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.1" },
});
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
headers: { "cf-connecting-ip": "198.51.100.2" },
});
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume/style-projection");
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownRpcRequest);
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
await app.fetch(unknownOpenApiRequest);
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
});
it.each([
["GET", "/robots.txt", "robots", mocks.handleRobots],
["HEAD", "/robots.txt", "", mocks.handleRobots],
+25 -6
View File
@@ -1,3 +1,7 @@
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
import type { Context } from "hono";
import { isIP } from "node:net";
import { getConnInfo } from "@hono/node-server/conninfo";
import { Hono } from "hono";
import { handleMcp } from "../mcp/handler";
import { handleOpenApi } from "../openapi/handler";
@@ -15,18 +19,33 @@ import { handleUpload } from "../static/uploads";
import { handleWebApp, serveWebDistStatic } from "../static/web";
import { handleAuth, handleOAuth } from "./auth";
import { handleHealth } from "./health";
import { handlePublicResumePdf } from "./public-resume-pdf";
import { handleResumePdfDownload } from "./resume-pdf";
export function createApp() {
const app = new Hono();
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
try {
const address = getConnInfo(context).remote.address?.trim();
return address && isIP(address) ? address : "unknown";
} catch {
return "unknown";
}
};
export function createApp() {
const app = new Hono<ServerEnvironment>();
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
app.get("/api/health", () => handleHealth());
app.get("/api/resumes/:username/:slug/pdf", (c) =>
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
);
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
createPublicResumePdf: vi.fn(),
}));
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
createPublicResumePdf: mocks.createPublicResumePdf,
PUBLIC_RESUME_PDF_MISMATCH_REASONS: [
"missing-projection",
"format-version",
"language-version",
"semantic-tree-version",
"registry-fingerprint",
"adapter-fingerprint",
"render-data-hash",
"invalid-projection",
],
}));
const { handlePublicResumePdf } = await import("./public-resume-pdf");
const trustedClient = "203.0.113.9";
describe("handlePublicResumePdf", () => {
beforeEach(() => vi.clearAllMocks());
it("returns the authorized fallback PDF with strict mismatch metadata and cache policy", async () => {
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
mocks.createPublicResumePdf.mockResolvedValueOnce({
body,
filename: "Ada_Lovelace.pdf",
});
const registry = "0".repeat(64);
const adapter = "1".repeat(64);
const request = new Request(
`https://example.com/api/resumes/jane/resume/pdf?reason=render-data-hash&registryFingerprint=${registry}&adapterFingerprint=${adapter}`,
{ headers: { "x-forwarded-for": "203.0.113.7" } },
);
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("application/pdf");
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(await response.text()).toBe("%PDF");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
username: "jane",
slug: "resume",
requestHeaders: request.headers,
trustedClient,
mismatchReason: "render-data-hash",
clientRegistryFingerprint: registry,
clientAdapterFingerprint: adapter,
});
});
it("defaults a missing mismatch reason and keeps password/private responses uncacheable", async () => {
mocks.createPublicResumePdf.mockResolvedValueOnce({
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
filename: "resume.pdf",
});
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith(
expect.objectContaining({ mismatchReason: "missing-projection" }),
);
});
it.each([
[{ code: "BAD_REQUEST" }, 400],
[{ code: "NEED_PASSWORD" }, 401],
[{ code: "NOT_FOUND" }, 404],
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
])("maps controlled API errors without caching the response", async (error, status) => {
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
const response = await handlePublicResumePdf(
new Request("https://example.com/api/resumes/jane/resume/pdf"),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(status);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
});
it.each(["?reason=private-source", "?registryFingerprint=unsafe", "?adapterFingerprint=unsafe"])(
"rejects invalid fallback metadata before the API service",
async (search) => {
const response = await handlePublicResumePdf(
new Request(`https://example.com/api/resumes/jane/resume/pdf${search}`),
"jane",
"resume",
trustedClient,
);
expect(response.status).toBe(400);
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
expect(mocks.createPublicResumePdf).not.toHaveBeenCalled();
},
);
});
+72
View File
@@ -0,0 +1,72 @@
import type { PublicResumePdfMismatchReason } from "@reactive-resume/api/features/resume/public-pdf";
import {
createPublicResumePdf,
PUBLIC_RESUME_PDF_MISMATCH_REASONS,
} from "@reactive-resume/api/features/resume/public-pdf";
const noStoreResponse = (body: string, status: number) =>
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
const errorStatus = (error: unknown): number => {
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
if (code === "BAD_REQUEST") return 400;
if (code === "NEED_PASSWORD") return 401;
if (code === "NOT_FOUND") return 404;
if (code === "RATE_LIMIT_EXCEEDED") return 429;
return 500;
};
const fingerprint = (value: string | null): string | undefined => {
if (value === null) return;
return /^[a-f0-9]{64}$/.test(value) ? value : undefined;
};
export async function handlePublicResumePdf(
request: Request,
username: string,
slug: string,
trustedClient = "unknown",
): Promise<Response> {
const searchParams = new URL(request.url).searchParams;
const rawReason = searchParams.get("reason") ?? "missing-projection";
if (!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(rawReason as PublicResumePdfMismatchReason)) {
return noStoreResponse("Invalid fallback metadata", 400);
}
const rawRegistryFingerprint = searchParams.get("registryFingerprint");
const rawAdapterFingerprint = searchParams.get("adapterFingerprint");
const clientRegistryFingerprint = fingerprint(rawRegistryFingerprint);
const clientAdapterFingerprint = fingerprint(rawAdapterFingerprint);
if (
(rawRegistryFingerprint !== null && clientRegistryFingerprint === undefined) ||
(rawAdapterFingerprint !== null && clientAdapterFingerprint === undefined)
) {
return noStoreResponse("Invalid fallback metadata", 400);
}
try {
const result = await createPublicResumePdf({
username,
slug,
requestHeaders: request.headers,
trustedClient,
mismatchReason: rawReason as PublicResumePdfMismatchReason,
...(clientRegistryFingerprint ? { clientRegistryFingerprint } : {}),
...(clientAdapterFingerprint ? { clientAdapterFingerprint } : {}),
});
return new Response(result.body, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
"Cache-Control": "private, no-store",
"X-Content-Type-Options": "nosniff",
},
});
} catch (error) {
const status = errorStatus(error);
return noStoreResponse(
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
status,
);
}
}
@@ -0,0 +1,27 @@
import { mkdtemp, readFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, it } from "vitest";
import { generateOpenApiDocumentation } from "./generate-spec";
const temporaryDirectories: string[] = [];
const committedSpec = new URL("../../../../docs/spec.json", import.meta.url);
afterEach(async () => {
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});
it("keeps the committed OpenAPI specification synchronized without rewriting it", async () => {
const directory = await mkdtemp(join(tmpdir(), "openapi-documentation-"));
temporaryDirectories.push(directory);
const firstTarget = join(directory, "first.json");
const secondTarget = join(directory, "second.json");
const before = await readFile(committedSpec, "utf8");
await generateOpenApiDocumentation(firstTarget);
await generateOpenApiDocumentation(secondTarget);
expect(await readFile(firstTarget, "utf8")).toBe(before);
expect(await readFile(secondTarget, "utf8")).toBe(before);
expect(await readFile(committedSpec, "utf8")).toBe(before);
});
+20
View File
@@ -0,0 +1,20 @@
import { readFile, writeFile } from "node:fs/promises";
import { fileURLToPath } from "node:url";
export async function generateOpenApiDocumentation(
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
) {
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
version: string;
};
process.env.APP_URL ??= "https://rxresu.me";
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
const { generateOpenApiSpec } = await import("./generator");
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
}
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
await generateOpenApiDocumentation(process.argv[2]);
}
+147
View File
@@ -0,0 +1,147 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
type GeneratedSpecView = {
components?: { schemas?: Record<string, unknown> };
paths?: Record<
string,
Record<
string,
{
requestBody?: {
content?: Record<string, { schema?: unknown }>;
};
}
>
>;
};
async function generateSpec() {
process.env.APP_URL ??= "https://rxresu.me";
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_test";
process.env.AUTH_SECRET ??= "openapi-generator-test-process-only";
const { generateOpenApiSpec } = await import("./generator");
return generateOpenApiSpec({
appUrl: "https://rxresu.me",
version: "9.8.7",
});
}
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
}
function containsImpossibleSchema(value: unknown): boolean {
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
if (typeof value !== "object" || value === null) return false;
const object = value as Record<string, unknown>;
const negated = object.not;
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
return true;
}
return Object.values(object).some(containsImpossibleSchema);
}
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
const impossibleRequests: string[] = [];
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
for (const [method, operation] of Object.entries(operations)) {
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
if (containsImpossibleSchema(content.schema)) {
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
}
}
}
}
return impossibleRequests;
}
describe("generateOpenApiSpec", () => {
it("uses caller-provided application URL and version", async () => {
const spec = await generateSpec();
expect(spec.info).toMatchObject({
title: "Reactive Resume",
version: "9.8.7",
});
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
expect(spec.externalDocs).toEqual({
url: "https://docs.rxresu.me",
description: "Reactive Resume Documentation",
});
}, 15_000);
it("uses the canonical input-side ResumeData schema in update requests", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
});
});
it("publishes the custom-section type and item correlation", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
it("does not publish impossible request schemas", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
});
it("checks every request body media type for impossible schemas", () => {
const spec: GeneratedSpecView = {
paths: {
"/documents": {
post: {
requestBody: {
content: {
"application/json": { schema: { type: "object" } },
"multipart/form-data": { schema: { not: {} } },
},
},
},
},
},
};
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
});
it("documents imported data as an accepted ResumeData input", async () => {
const spec = (await generateSpec()) as GeneratedSpecView;
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
});
});
+80
View File
@@ -0,0 +1,80 @@
import { OpenAPIGenerator } from "@orpc/openapi";
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
import router from "@reactive-resume/api/routers";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
export const openAPIRouter = {
...router,
resume: {
...router.resume,
downloadPdf: downloadResumePdfProcedure,
},
};
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
if (importResumeInputSchema) {
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
type: "object",
properties: {
data: { $ref: "#/components/schemas/ResumeData" },
},
required: ["data"],
});
}
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [
new ZodToJsonSchemaConverter({
interceptors: [
({ options, next }) => {
const [required, schema] = next();
const impossible =
Object.keys(schema).length === 1 &&
typeof schema.not === "object" &&
schema.not !== null &&
Object.keys(schema.not).length === 0;
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
},
],
}),
],
});
type GenerateOpenApiSpecOptions = {
appUrl: string;
version: string;
};
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
return await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${appUrl}/api/openapi` }],
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
commonSchemas: {
ResumeData: { schema: resumeDataSchema, strategy: "input" },
},
components: {
securitySchemes: {
apiKey: {
type: "apiKey",
name: "x-api-key",
in: "header",
description: "The API key to authenticate requests.",
},
},
},
security: [{ apiKey: [] }],
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
});
}
+4 -46
View File
@@ -1,24 +1,13 @@
import { SmartCoercionPlugin } from "@orpc/json-schema";
import { OpenAPIGenerator } from "@orpc/openapi";
import { OpenAPIHandler } from "@orpc/openapi/fetch";
import { onError } from "@orpc/server";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
import router from "@reactive-resume/api/routers";
import { env } from "@reactive-resume/env/server";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { appVersion } from "../app-version";
import { mergeResponseHeaders } from "../http/headers";
import { getRequestLocale } from "../rpc/locale";
const openAPIRouter = {
...router,
resume: {
...router.resume,
downloadPdf: downloadResumePdfProcedure,
},
};
import { generateOpenApiSpec, openAPIRouter } from "./generator";
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
plugins: [
@@ -36,46 +25,15 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
],
});
const openAPIGenerator = new OpenAPIGenerator({
schemaConverters: [new ZodToJsonSchemaConverter()],
});
export async function handleOpenApi(request: Request) {
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
const spec = await openAPIGenerator.generate(openAPIRouter, {
info: {
title: "Reactive Resume",
version: appVersion,
description: "Reactive Resume API",
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
},
servers: [{ url: `${env.APP_URL}/api/openapi` }],
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
commonSchemas: {
ResumeData: { schema: resumeDataSchema },
},
components: {
securitySchemes: {
apiKey: {
type: "apiKey",
name: "x-api-key",
in: "header",
description: "The API key to authenticate requests.",
},
},
},
security: [{ apiKey: [] }],
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
});
return Response.json(spec);
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
}
const resHeaders = new Headers();
const { response } = await openAPIHandler.handle(request, {
prefix: "/api/openapi",
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
+9 -2
View File
@@ -3,6 +3,7 @@ import { RPCHandler } from "@orpc/server/fetch";
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
import router from "@reactive-resume/api/routers";
import { mergeResponseHeaders } from "../http/headers";
import { stylesheetPreflightRunner } from "../services/stylesheet-preflight";
import { getRequestLocale } from "./locale";
const rpcHandler = new RPCHandler(router, {
@@ -14,11 +15,17 @@ const rpcHandler = new RPCHandler(router, {
],
});
export async function handleRpc(request: Request) {
export async function handleRpc(request: Request, trustedClient = "unknown") {
const resHeaders = new Headers();
const { response } = await rpcHandler.handle(request, {
prefix: "/api/rpc",
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
context: {
locale: getRequestLocale(request),
reqHeaders: request.headers,
resHeaders,
trustedClient,
stylesheetPreflightRunner,
},
});
if (!response) return new Response("NOT_FOUND", { status: 404 });
@@ -0,0 +1,248 @@
import { describe, expect, it } from "vitest";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { createStylesheetPreflightRunner, STYLESHEET_PREFLIGHT_LIMITS } from "./stylesheet-preflight";
const validStylesheet = {
languageVersion: 1,
text: "@version 1;",
} as const;
const input = {
data: defaultResumeData,
template: defaultResumeData.metadata.template,
stylesheet: validStylesheet,
} as const;
const memoryExhaustionWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
const retained = [];
while (true) {
const batch = Array.from({ length: 100_000 }, (_, index) => ({ batch: retained.length, index }));
retained.push(batch);
}
`)}`,
);
const failedWorker = new URL(
`data:text/javascript,${encodeURIComponent('throw new Error("sensitive worker details");')}`,
);
const delayedSuccessfulWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort, workerData } from "node:worker_threads";
parentPort.postMessage({ type: "ready" });
setTimeout(() => {
parentPort.postMessage({
ok: true,
pageCount: 1,
byteCount: Number(workerData.input.data.basics.name),
diagnostics: [],
});
}, 300);
`)}`,
);
const delayedReadyWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
setTimeout(() => {
parentPort.postMessage({ type: "ready" });
setTimeout(() => {
parentPort.postMessage({
ok: true,
pageCount: 1,
byteCount: 1,
diagnostics: [],
});
}, 10);
}, 50);
`)}`,
);
const neverCompletesWorker = new URL(
`data:text/javascript,${encodeURIComponent(`
import { parentPort } from "node:worker_threads";
parentPort.postMessage({ type: "ready" });
setInterval(() => {}, 1_000);
`)}`,
);
const synchronousFailureWorker = new URL("https://example.com/stylesheet-preflight.mjs");
const numberedInput = (number: number) => ({
...input,
data: {
...input.data,
basics: {
...input.data.basics,
name: String(number),
},
},
});
const invalidInput = () => {
const data = structuredClone(defaultResumeData);
data.customSections = [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
} as never,
];
return { ...input, data };
};
describe("stylesheet PDF preflight worker", () => {
it("keeps the production resource policy fixed and immutable", () => {
expect(STYLESHEET_PREFLIGHT_LIMITS).toEqual({
timeoutMs: 5_000,
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
maxOldGenerationMb: 256,
maxConcurrentWorkers: 1,
maxQueuedRequests: 32,
});
expect(Object.isFrozen(STYLESHEET_PREFLIGHT_LIMITS)).toBe(true);
});
it("accepts a bounded candidate render in an isolated worker", async () => {
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
const result = await runner.run(input);
expect(result).toEqual(
expect.objectContaining({
ok: true,
pageCount: 1,
byteCount: expect.any(Number),
}),
);
if (!result.ok) throw new Error(`Expected successful preflight, received ${result.code}.`);
expect(result.byteCount).toBeGreaterThan(0);
expect(runner.activeWorkerCount).toBe(0);
}, 45_000);
it("preserves structured resume-data failures across the worker boundary", async () => {
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
const result = runner.run(invalidInput());
await expect(result).rejects.toMatchObject({
name: "ZodError",
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
});
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
}, 20_000);
it("terminates a worker when the render exceeds its deadline", async () => {
const runner = createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker);
const result = await runner.run(input);
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" }));
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
it("starts the authored render deadline after the worker runtime is ready", async () => {
const runner = createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker);
await expect(runner.run(input)).resolves.toEqual(expect.objectContaining({ ok: true, pageCount: 1 }));
expect(runner.activeWorkerCount).toBe(0);
});
it("returns deterministic output byte and page limit codes", async () => {
const byteRunner = createStylesheetPreflightRunner({ maxBytes: 16 });
const pageRunner = createStylesheetPreflightRunner({ maxPages: 0 });
await expect(byteRunner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT" }),
);
await expect(pageRunner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT" }),
);
expect(byteRunner.activeWorkerCount).toBe(0);
expect(pageRunner.activeWorkerCount).toBe(0);
}, 30_000);
it("maps worker heap exhaustion to a controlled memory-limit result", async () => {
const runner = createStylesheetPreflightRunner(
{ maxOldGenerationMb: 8, timeoutMs: 10_000 },
memoryExhaustionWorker,
);
const result = await runner.run(input);
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_MEMORY_LIMIT" }));
expect(runner.activeWorkerCount).toBe(0);
}, 15_000);
it("does not expose internal errors from a failed worker", async () => {
const runner = createStylesheetPreflightRunner({}, failedWorker);
const result = await runner.run(input);
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed.",
diagnostics: [],
});
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
const runner = createStylesheetPreflightRunner(
{
timeoutMs: 500,
maxConcurrentWorkers: 1,
maxQueuedRequests: 2,
},
delayedSuccessfulWorker,
);
const completionOrder: number[] = [];
const accepted = [1, 2, 3].map((number) =>
runner.run(numberedInput(number)).then((result) => {
if (result.ok) completionOrder.push(result.byteCount);
return result;
}),
);
const rejected = runner.run(numberedInput(4));
expect(runner.activeWorkerCount).toBe(1);
expect(runner.queuedPreflightCount).toBe(2);
await expect(rejected).resolves.toEqual(
expect.objectContaining({
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight queue is full.",
}),
);
const results = await Promise.all(accepted);
expect(results.every((result) => result.ok)).toBe(true);
expect(completionOrder).toEqual([1, 2, 3]);
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
}, 5_000);
it("does not leak a slot when the worker constructor throws synchronously", async () => {
const runner = createStylesheetPreflightRunner({}, synchronousFailureWorker);
await expect(runner.run(input)).resolves.toEqual(
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED" }),
);
expect(runner.activeWorkerCount).toBe(0);
expect(runner.queuedPreflightCount).toBe(0);
});
});
@@ -0,0 +1,225 @@
import type {
PdfPreflightFailure,
PdfPreflightResult,
StylesheetPreflightInput,
StylesheetPreflightRunner,
} from "@reactive-resume/pdf/server";
import { fileURLToPath } from "node:url";
import { Worker } from "node:worker_threads";
import { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
export { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
type StylesheetPreflightLimits = {
timeoutMs: number;
maxPages: number;
maxBytes: number;
maxPageWidthPt: number;
maxPageHeightPt: number;
maxPageAreaPt2: number;
maxOldGenerationMb: number;
maxConcurrentWorkers: number;
maxQueuedRequests: number;
};
const SOURCE_WORKER_LOADER_HEAP_MB = 256;
const SOURCE_WORKER_STARTUP_TIMEOUT_MS = 30_000;
const WORKER_STARTUP_TIMEOUT_MS = 15_000;
type SerializedPreflightCause = {
name: string;
message: string;
issues: readonly unknown[];
};
type StylesheetPreflightWorkerMessage =
| PdfPreflightResult
| { type: "ready" }
| { type: "preflight_error"; cause: SerializedPreflightCause };
export type NodeStylesheetPreflightRunner = StylesheetPreflightRunner & {
readonly activeWorkerCount: number;
readonly queuedPreflightCount: number;
};
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
ok: false,
code,
message,
diagnostics: [],
});
const workerFailure = (error: Error): PdfPreflightFailure => {
const code = (error as NodeJS.ErrnoException).code;
return code === "ERR_WORKER_OUT_OF_MEMORY" || /heap out of memory/i.test(error.message)
? failure("STYLESHEET_PREFLIGHT_MEMORY_LIMIT", "The PDF preflight worker exceeded its memory limit.")
: failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
};
const sourceWorkerExecArgv = () => {
const importIndex = process.execArgv.findIndex(
(argument, index, arguments_) =>
(argument === "--import" && arguments_[index + 1]?.includes("tsx")) ||
(argument.startsWith("--import=") && argument.includes("tsx")),
);
const inherited = process.execArgv[importIndex];
if (inherited?.startsWith("--import=")) return [inherited];
if (inherited === "--import") return [inherited, process.execArgv[importIndex + 1] as string];
return ["--import", import.meta.resolve("tsx")];
};
const workerLocation = () => {
const source = import.meta.url.endsWith(".ts");
return {
source,
url: source
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
...(source
? {
execArgv: sourceWorkerExecArgv(),
env: {
...process.env,
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
},
}
: {}),
};
};
export function createStylesheetPreflightRunner(
overrides: Partial<StylesheetPreflightLimits> = {},
testWorkerUrl?: URL,
): NodeStylesheetPreflightRunner {
const limits = Object.freeze({ ...STYLESHEET_PREFLIGHT_LIMITS, ...overrides });
let activeWorkerCount = 0;
// ponytail: Keep admission process-local and bounded; upgrade to a distributed/pooled queue only for multi-process coordination.
const queue: Array<{
input: StylesheetPreflightInput;
resolve: (result: PdfPreflightResult) => void;
reject: (cause: unknown) => void;
}> = [];
const runWorker = (
input: StylesheetPreflightInput,
resolve: (result: PdfPreflightResult) => void,
reject: (cause: unknown) => void,
): boolean => {
// The URL seam is internal to the server package and keeps worker failure tests independent from the PDF renderer.
const location = testWorkerUrl ? { source: false, url: testWorkerUrl } : workerLocation();
let worker: Worker;
try {
worker = new Worker(location.url, {
name: "stylesheet-preflight",
workerData: { input, limits },
resourceLimits: {
// The source-only tsx compiler heap is outside the production render budget.
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
},
...("execArgv" in location ? { execArgv: location.execArgv } : {}),
...("env" in location ? { env: location.env } : {}),
});
} catch (error) {
resolve(workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")));
return false;
}
activeWorkerCount += 1;
let settled = false;
let renderTimer: ReturnType<typeof setTimeout> | undefined;
let startupTimer: ReturnType<typeof setTimeout> | undefined;
const cleanup = () => {
if (renderTimer) clearTimeout(renderTimer);
if (startupTimer) clearTimeout(startupTimer);
worker.off("message", onMessage);
worker.off("error", onError);
worker.off("exit", onExit);
activeWorkerCount -= 1;
};
const finish = async (result: PdfPreflightResult) => {
if (settled) return;
settled = true;
await worker.terminate().catch(() => undefined);
cleanup();
resolve(result);
drainQueue();
};
const fail = async (cause: SerializedPreflightCause) => {
if (settled) return;
settled = true;
await worker.terminate().catch(() => undefined);
cleanup();
reject(Object.assign(new Error(cause.message), { name: cause.name, issues: cause.issues }));
drainQueue();
};
const onMessage = (message: StylesheetPreflightWorkerMessage) => {
if ("type" in message) {
if (message.type === "preflight_error") {
void fail(message.cause);
return;
}
if (startupTimer) clearTimeout(startupTimer);
renderTimer = setTimeout(() => {
void finish(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline."));
}, limits.timeoutMs);
return;
}
void finish(message);
};
const onError = (error: Error) => {
void finish(workerFailure(error));
};
const onExit = () => {
if (!settled) {
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
}
};
worker.on("message", onMessage);
worker.once("error", onError);
worker.once("exit", onExit);
startupTimer = setTimeout(
() => {
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
},
location.source ? SOURCE_WORKER_STARTUP_TIMEOUT_MS : WORKER_STARTUP_TIMEOUT_MS,
);
return true;
};
function drainQueue() {
while (activeWorkerCount < limits.maxConcurrentWorkers && queue.length > 0) {
const next = queue.shift();
if (!next) return;
runWorker(next.input, next.resolve, next.reject);
}
}
return {
get activeWorkerCount() {
return activeWorkerCount;
},
get queuedPreflightCount() {
return queue.length;
},
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult> {
return new Promise<PdfPreflightResult>((resolve, reject) => {
if (activeWorkerCount < limits.maxConcurrentWorkers) {
runWorker(input, resolve, reject);
return;
}
if (queue.length >= limits.maxQueuedRequests) {
resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight queue is full."));
return;
}
queue.push({ input, resolve, reject });
});
},
};
}
export const stylesheetPreflightRunner = createStylesheetPreflightRunner();
+29
View File
@@ -0,0 +1,29 @@
import { describe, expect, it } from "vitest";
import z from "zod";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { handleSchemaJson } from "./schema";
describe("handleSchemaJson", () => {
it("publishes the custom-section type and item correlation", async () => {
const response = handleSchemaJson();
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
const mismatched = {
...defaultResumeData,
customSections: [
{
id: "custom-experience",
type: "experience",
title: "Experience",
icon: "",
columns: 1,
hidden: false,
keepTogether: false,
startOnNewPage: false,
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
},
],
};
expect(schema.safeParse(mismatched).success).toBe(false);
});
});
+2 -5
View File
@@ -1,11 +1,8 @@
import z from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
import { appVersion } from "../app-version";
export function handleSchemaJson() {
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
return Response.json(resumeDataJSONSchema, {
return Response.json(createResumeDataJsonSchema(), {
status: 200,
headers: {
"Content-Type": "application/schema+json; charset=utf-8",
@@ -0,0 +1,74 @@
import type { PdfPreflightPageLimits, PdfPreflightResult, RenderPreflightPdfResult } from "@reactive-resume/pdf/server";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
maxPages: number;
maxBytes: number;
};
type PdfLoadingTask = {
promise: PromiseLike<{ numPages: number }>;
destroy(): Promise<void>;
};
type LoadPdf = (options: { data: Uint8Array }) => PdfLoadingTask;
export async function inspectPreflightPdf(
rendered: Extract<RenderPreflightPdfResult, { ok: true }>,
limits: StylesheetPreflightInspectionLimits,
loadPdf: LoadPdf = getDocument,
): Promise<PdfPreflightResult> {
if (rendered.bytes.byteLength > limits.maxBytes) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT",
message: "The rendered PDF exceeds the preflight byte limit.",
diagnostics: rendered.diagnostics,
};
}
const byteCount = rendered.bytes.byteLength;
let loadingTask: PdfLoadingTask;
try {
loadingTask = loadPdf({ data: rendered.bytes });
} catch {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: rendered.diagnostics,
};
}
try {
let document: { numPages: number };
try {
document = await loadingTask.promise;
} catch {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: rendered.diagnostics,
};
}
if (document.numPages > limits.maxPages) {
return {
ok: false,
code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT",
message: "The rendered PDF exceeds the preflight page limit.",
diagnostics: rendered.diagnostics,
};
}
return {
ok: true,
pageCount: document.numPages,
byteCount,
diagnostics: rendered.diagnostics,
};
} finally {
await loadingTask.destroy().catch(() => undefined);
}
}
@@ -0,0 +1,58 @@
import { describe, expect, it, vi } from "vitest";
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
const warning = {
code: "EXTREME_VALUE",
severity: "warning",
message: "The authored value is unusually large.",
range: {
start: { line: 1, column: 1, offset: 0 },
end: { line: 1, column: 10, offset: 9 },
},
} as const;
const rendered = {
ok: true as const,
bytes: new TextEncoder().encode("%PDF-1.7"),
diagnostics: [warning],
};
const limits = {
maxPages: 20,
maxBytes: 10_000_000,
maxPageWidthPt: 2_000,
maxPageHeightPt: 20_000,
maxPageAreaPt2: 20_000_000,
};
describe("stylesheet preflight PDF inspection", () => {
it("preserves compiler warnings and hides parser exceptions", async () => {
const destroy = vi.fn(async () => undefined);
const result = await inspectPreflightPdf(rendered, limits, () => ({
promise: Promise.reject(new Error("sensitive parser details")),
destroy,
}));
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: [warning],
});
expect(destroy).toHaveBeenCalledOnce();
});
it("preserves compiler warnings when PDF.js fails before returning a loading task", async () => {
const result = await inspectPreflightPdf(rendered, limits, () => {
throw new Error("sensitive parser setup details");
});
expect(result).toEqual({
ok: false,
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
message: "PDF inspection failed.",
diagnostics: [warning],
});
});
});
@@ -0,0 +1,60 @@
import type { PdfPreflightPageLimits, PdfPreflightResult, StylesheetPreflightInput } from "@reactive-resume/pdf/server";
import { parentPort, workerData } from "node:worker_threads";
import * as React from "react";
import { renderPreflightPdf } from "@reactive-resume/pdf/server";
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
(globalThis as typeof globalThis & { React: typeof React }).React = React;
type StylesheetPreflightWorkerLimits = PdfPreflightPageLimits & {
maxPages: number;
maxBytes: number;
};
type StylesheetPreflightWorkerData = {
input: StylesheetPreflightInput;
limits: StylesheetPreflightWorkerLimits;
};
type SerializedPreflightCause = {
name: string;
message: string;
issues: readonly unknown[];
};
const send = (result: PdfPreflightResult) => {
parentPort?.postMessage(result);
};
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 };
};
parentPort?.postMessage({ type: "ready" });
async function run(): Promise<PdfPreflightResult> {
const { input, limits } = workerData as StylesheetPreflightWorkerData;
const rendered = await renderPreflightPdf(input, limits);
return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered;
}
if (parentPort) {
void run()
.then(send)
.catch((cause: unknown) => {
const serializedCause = serializeZodCause(cause);
if (serializedCause) {
parentPort?.postMessage({ type: "preflight_error", cause: serializedCause });
return;
}
send({
ok: false,
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
message: "The PDF preflight worker failed.",
diagnostics: [],
});
});
}
+4 -1
View File
@@ -33,7 +33,10 @@ const promptAssetsPlugin: TsdownPlugin = {
};
export default defineConfig({
entry: { index: "src/index.ts" },
entry: {
index: "src/index.ts",
"stylesheet-preflight-worker": "src/workers/stylesheet-preflight.ts",
},
format: "esm",
platform: "node",
target: "node24",
+6 -1
View File
@@ -1,4 +1,9 @@
{
"extends": ["//"],
"tags": ["app:server", "runtime:server", "role:adapter"]
"tags": ["app:server", "runtime:server", "role:adapter"],
"tasks": {
"test:ci": {
"cache": false
}
}
}
+104
View File
@@ -158,6 +158,10 @@ msgstr "Account menu"
msgid "Actions"
msgstr "Actions"
#: src/features/resume/stylesheet/legacy-banner.tsx
msgid "Activate Semantic CSS"
msgstr "Activate Semantic CSS"
#: src/routes/builder/$resumeId/-components/dock.tsx
msgid "Actual size (100%)"
msgstr "Actual size (100%)"
@@ -477,6 +481,7 @@ msgid "Applications sent per week (last 8 weeks)"
msgstr "Applications sent per week (last 8 weeks)"
#: src/features/applications/components/table-view.tsx
#: src/features/resume/stylesheet/status.tsx
msgid "Applied"
msgstr "Applied"
@@ -488,6 +493,10 @@ msgstr "Applied on"
msgid "Applied Rules"
msgstr "Applied Rules"
#: src/features/resume/stylesheet/status.tsx
msgid "Applied with warnings"
msgstr "Applied with warnings"
#: src/libs/locale.ts
msgid "Arabic"
msgstr "Arabic"
@@ -795,6 +804,14 @@ msgstr "Check your email for a link to reset your password."
msgid "Check your email for a link to verify your account."
msgstr "Check your email for a link to verify your account."
#: src/features/resume/stylesheet/status.tsx
msgid "Checking"
msgstr "Checking"
#: src/features/resume/stylesheet/status.tsx
msgid "Checking draft"
msgstr "Checking draft"
#: src/libs/locale.ts
msgid "Chinese (Simplified)"
msgstr "Chinese (Simplified)"
@@ -961,6 +978,10 @@ msgstr "Conversation copied."
msgid "Conversation JSON copied."
msgstr "Conversation JSON copied."
#: src/features/resume/stylesheet/legacy-banner.tsx
msgid "Converted stylesheet draft"
msgstr "Converted stylesheet draft"
#: src/features/applications/components/application-ai-copilot.tsx
msgid "Copied to clipboard."
msgstr "Copied to clipboard."
@@ -984,6 +1005,10 @@ msgstr "Copy Backup Codes"
msgid "Copy JSON"
msgstr "Copy JSON"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Copy stylesheet"
msgstr "Copy stylesheet"
#: src/dialogs/api-key/create.tsx
msgid "Copy this secret key and use it in your applications to access your data."
msgstr "Copy this secret key and use it in your applications to access your data."
@@ -1540,6 +1565,11 @@ msgstr "Edit {chip}"
msgid "Edit application"
msgstr "Edit application"
#. placeholder {0}: selectedColor.token.value
#: src/features/resume/stylesheet/editor.tsx
msgid "Edit color {0}"
msgstr "Edit color {0}"
#. Screen reader description for the fullscreen rich-text editor dialog
#: src/components/input/rich-input.tsx
msgid "Edit content in fullscreen mode"
@@ -1669,6 +1699,10 @@ msgstr "Enter your password to confirm setting up two-factor authentication. Whe
msgid "Enter your password to disable two-factor authentication. Your account will be less secure without 2FA enabled."
msgstr "Enter your password to disable two-factor authentication. Your account will be less secure without 2FA enabled."
#: src/features/resume/stylesheet/status.tsx
msgid "Error"
msgstr "Error"
#: src/routes/_home/-sections/donate.tsx
msgid "Every contribution, big or small, makes a huge difference to the project.<0/>Thank you for your support!"
msgstr "Every contribution, big or small, makes a huge difference to the project.<0/>Thank you for your support!"
@@ -1677,6 +1711,10 @@ msgstr "Every contribution, big or small, makes a huge difference to the project
msgid "Everything you need to create, customize, and share professional resumes. Built with privacy in mind, powered by open source, and completely free forever."
msgstr "Everything you need to create, customize, and share professional resumes. Built with privacy in mind, powered by open source, and completely free forever."
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Exit focus mode"
msgstr "Exit focus mode"
#: src/components/input/rich-input.tsx
msgid "Exit Fullscreen"
msgstr "Exit Fullscreen"
@@ -1996,6 +2034,10 @@ msgctxt "Page Format (A4, Letter, Free-form)"
msgid "Format"
msgstr "Format"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Format stylesheet"
msgstr "Format stylesheet"
#: src/routes/_home/-sections/features.tsx
msgid "Free"
msgstr "Free"
@@ -2579,6 +2621,12 @@ msgstr "Light"
msgid "Light theme"
msgstr "Light theme"
#. placeholder {0}: diagnostic.range.start.line
#. placeholder {1}: diagnostic.range.start.column
#: src/features/resume/stylesheet/status.tsx
msgid "Line {0}, column {1}"
msgstr "Line {0}, column {1}"
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
msgid "Line Height"
msgstr "Line Height"
@@ -2963,6 +3011,10 @@ msgstr "Open AI assistant"
msgid "Open Email Client"
msgstr "Open Email Client"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Open focus mode"
msgstr "Open focus mode"
#: src/routes/agent/-components/resume-pane.tsx
msgid "Open in builder"
msgstr "Open in builder"
@@ -3262,6 +3314,10 @@ msgstr "Press <0>Enter</0> to open"
msgid "Preview"
msgstr "Preview"
#: src/features/resume/stylesheet/status.tsx
msgid "Preview and export use the last valid version."
msgstr "Preview and export use the last valid version."
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
msgid "Primary Color"
msgstr "Primary Color"
@@ -3373,6 +3429,14 @@ msgstr "Reactive Resume v4 (JSON)"
msgid "Reading…"
msgstr "Reading…"
#: src/features/resume/stylesheet/status.tsx
msgid "Ready to activate"
msgstr "Ready to activate"
#: src/features/resume/stylesheet/status.tsx
msgid "Ready to activate with warnings"
msgstr "Ready to activate with warnings"
#: src/dialogs/resume/sections/cover-letter.tsx
msgid "Recipient"
msgstr "Recipient"
@@ -3389,6 +3453,10 @@ msgstr "Rectangle (Full Width)"
msgid "Redo"
msgstr "Redo"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Redo stylesheet edit"
msgstr "Redo stylesheet edit"
#: src/dialogs/resume/sections/custom.tsx
#: src/libs/resume/section-title.ts
#: src/libs/resume/section.tsx
@@ -3490,6 +3558,10 @@ msgstr "Reset Password"
msgid "Reset Style"
msgstr "Reset Style"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Reset to applied stylesheet"
msgstr "Reset to applied stylesheet"
#: src/features/auth/pages/reset-password.tsx
msgid "Reset your password"
msgstr "Reset your password"
@@ -3786,6 +3858,14 @@ msgstr "selected"
msgid "Self-Host with Docker"
msgstr "Self-Host with Docker"
#: src/features/resume/stylesheet/editor.tsx
msgid "Semantic CSS stylesheet"
msgstr "Semantic CSS stylesheet"
#: src/features/resume/stylesheet/legacy-banner.tsx
msgid "Semantic styles remain active"
msgstr "Semantic styles remain active"
#: src/routes/agent/-components/agent-chat.tsx
msgid "Send message"
msgstr "Send message"
@@ -4147,6 +4227,14 @@ msgstr "Strike"
msgid "Strong fit"
msgstr "Strong fit"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Stylesheet editor"
msgstr "Stylesheet editor"
#: src/features/resume/stylesheet/status.tsx
msgid "Stylesheet has errors"
msgstr "Stylesheet has errors"
#: src/routes/_home/-sections/footer.tsx
msgid "Subreddit"
msgstr "Subreddit"
@@ -4303,6 +4391,10 @@ msgstr "The page you're looking for may have been moved or no longer exists."
msgid "The password you entered is incorrect"
msgstr "The password you entered is incorrect"
#: src/features/resume/preview/preview.browser.tsx
msgid "The resume preview could not be updated. The last valid preview is still shown."
msgstr "The resume preview could not be updated. The last valid preview is still shown."
#: src/features/auth/pages/resume-password.tsx
msgid "The resume you are trying to access is password protected"
msgstr "The resume you are trying to access is password protected"
@@ -4376,6 +4468,10 @@ msgstr "This entry will be permanently deleted. This can't be undone."
msgid "This feature requires a connected AI provider. Please set one up in the settings."
msgstr "This feature requires a connected AI provider. Please set one up in the settings."
#: src/features/resume/stylesheet/legacy-banner.tsx
msgid "This instance does not currently allow Semantic CSS editing."
msgstr "This instance does not currently allow Semantic CSS editing."
#: src/dialogs/resume/index.tsx
msgid "This is a URL-friendly name for your resume."
msgstr "This is a URL-friendly name for your resume."
@@ -4634,6 +4730,10 @@ msgstr "Underline"
msgid "Undo"
msgstr "Undo"
#: src/features/resume/stylesheet/toolbar.tsx
msgid "Undo stylesheet edit"
msgstr "Undo stylesheet edit"
#: src/routes/_home/-sections/features.tsx
msgid "Unlimited Resumes"
msgstr "Unlimited Resumes"
@@ -5008,6 +5108,10 @@ msgstr "Your data is stored securely and is never shared with third parties. You
msgid "Your latest changes could not be saved."
msgstr "Your latest changes could not be saved."
#: src/features/resume/stylesheet/legacy-banner.tsx
msgid "Your legacy styles remain active until you explicitly activate this Semantic CSS draft."
msgstr "Your legacy styles remain active until you explicitly activate this Semantic CSS draft."
#: src/features/auth/pages/reset-password.tsx
msgid "Your password has been reset successfully. You can now sign in with your new password."
msgstr "Your password has been reset successfully. You can now sign in with your new password."
+10
View File
@@ -22,6 +22,14 @@
"@better-auth/infra": "^0.3.7",
"@better-auth/oauth-provider": "^1.6.25",
"@better-auth/passkey": "^1.6.25",
"@codemirror/autocomplete": "^6.20.3",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-css": "^6.3.1",
"@codemirror/language": "^6.12.4",
"@codemirror/lint": "^6.9.7",
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.7",
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
@@ -60,6 +68,7 @@
"@uiw/react-color-colorful": "^2.10.3",
"ai": "^7.0.37",
"better-auth": "1.6.25",
"buffer": "^6.0.3",
"cmdk": "^1.1.1",
"drizzle-orm": "1.0.0-rc.4",
"es-toolkit": "^1.50.0",
@@ -69,6 +78,7 @@
"motion": "^12.42.2",
"pdfjs-dist": "6.1.200",
"pg": "^8.22.0",
"prettier": "^3.9.6",
"qrcode.react": "^4.2.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
@@ -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} />;
}