mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-22 14:22:16 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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®istryFingerprint=${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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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: [],
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test:ci": {
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
+23
-2
@@ -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} />;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user