diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 962e30bc6..c9a24a014 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -56,6 +56,9 @@ jobs: - name: Install Dependencies run: pnpm install --frozen-lockfile + - name: Run Server and Tooling Tests + run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling + - name: Install Playwright Browser run: pnpm exec playwright install --with-deps chromium @@ -73,8 +76,38 @@ jobs: - name: Build run: pnpm build - - name: Run E2E Tests - run: pnpm test:e2e:ci + - name: Run Baseline E2E Tests + env: + FLAG_SEMANTIC_CSS_AUTHORING: "false" + FLAG_SEMANTIC_CSS_DEFAULT: "false" + run: pnpm exec playwright test --grep-invert "@semantic-css" + + - name: Run Semantic CSS Opt-In Acceptance + env: + FLAG_SEMANTIC_CSS_AUTHORING: "true" + FLAG_SEMANTIC_CSS_DEFAULT: "false" + run: | + pnpm exec playwright test \ + tests/e2e/specs/semantic-css/legacy-conversion.spec.ts \ + tests/e2e/specs/semantic-css/invalid-last-valid.spec.ts \ + tests/e2e/specs/semantic-css/portable-stylesheet.spec.ts \ + tests/e2e/specs/semantic-css/revision-conflict.spec.ts \ + tests/e2e/specs/semantic-css/template-visual.spec.ts + + - name: Run Semantic CSS Default-On Acceptance + env: + FLAG_SEMANTIC_CSS_AUTHORING: "true" + FLAG_SEMANTIC_CSS_DEFAULT: "true" + run: pnpm exec playwright test tests/e2e/specs/semantic-css/default-mode.spec.ts + + - name: Run Semantic CSS Dormant Acceptance + env: + FLAG_SEMANTIC_CSS_AUTHORING: "false" + FLAG_SEMANTIC_CSS_DEFAULT: "false" + run: | + pnpm exec playwright test \ + tests/e2e/specs/semantic-css/dormant-mode.spec.ts \ + tests/e2e/specs/semantic-css/flag-off-semantic.spec.ts - name: Upload Playwright Report if: always() diff --git a/.gitignore b/.gitignore index ac5a5d96e..75f586f93 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ temp .cursor .codegraph .superpowers +.worktrees .migration # Local Storage Data diff --git a/.superpowers/sdd/pr-3274-review-followups-plan/task-1-report.md b/.superpowers/sdd/pr-3274-review-followups-plan/task-1-report.md new file mode 100644 index 000000000..90f7b0709 --- /dev/null +++ b/.superpowers/sdd/pr-3274-review-followups-plan/task-1-report.md @@ -0,0 +1,23 @@ +# Task 1: Harden public PDF response contract + +## Implemented + +- Pinned successful public PDF responses to `Content-Type: application/pdf` at the HTTP boundary. +- Kept `Cache-Control: private, no-store` hardcoded at that boundary for successful, validation-error, and service-error responses. +- Removed the unused `cacheControl` member from `createPublicResumePdf` and all affected tests/mocks. +- Updated the route regression to return a `text/plain` file from the service mock while asserting the HTTP response remains `application/pdf`. + +## Verification + +- `pnpm --filter server test -- src/http/public-resume-pdf.test.ts` — 13 files / 71 tests passed. +- `dotenvx run -f .env.local -- pnpm exec vitest run packages/api/src/features/resume/public-pdf.test.ts` — 1 file / 7 tests passed. +- `pnpm exec biome check apps/server/src/http/public-resume-pdf.ts apps/server/src/http/public-resume-pdf.test.ts packages/api/src/features/resume/public-pdf.ts packages/api/src/features/resume/public-pdf.test.ts` — passed. +- `pnpm --filter server typecheck` and `pnpm --filter @reactive-resume/api typecheck` — passed. + +## Note + +The API package test script did not scope to the supplied test path and initially ran the package suite, which has two unrelated baseline failures: `src/features/ai/url-policy.test.ts` and `src/features/resume/export.test.ts` (missing required env). The target test was then run directly with `.env.local` and passed. + +## Self-review + +`git diff --check` passed. The diff is restricted to the public PDF service contract, HTTP response header, and their focused tests. diff --git a/apps/server/package.json b/apps/server/package.json index 9bfa1516a..e88957176 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -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", diff --git a/apps/server/src/http/app.test.ts b/apps/server/src/http/app.test.ts index 9f811bcad..d556f07c5 100644 --- a/apps/server/src/http/app.test.ts +++ b/apps/server/src/http/app.test.ts @@ -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], diff --git a/apps/server/src/http/app.ts b/apps/server/src/http/app.ts index 9b199a73a..bf8cba197 100644 --- a/apps/server/src/http/app.ts +++ b/apps/server/src/http/app.ts @@ -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): 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(); + + 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)); diff --git a/apps/server/src/http/public-resume-pdf.test.ts b/apps/server/src/http/public-resume-pdf.test.ts new file mode 100644 index 000000000..1073ae364 --- /dev/null +++ b/apps/server/src/http/public-resume-pdf.test.ts @@ -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(); + }, + ); +}); diff --git a/apps/server/src/http/public-resume-pdf.ts b/apps/server/src/http/public-resume-pdf.ts new file mode 100644 index 000000000..ee67acfec --- /dev/null +++ b/apps/server/src/http/public-resume-pdf.ts @@ -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 { + 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, + ); + } +} diff --git a/apps/server/src/openapi/generate-spec.test.ts b/apps/server/src/openapi/generate-spec.test.ts new file mode 100644 index 000000000..be5a1a3c7 --- /dev/null +++ b/apps/server/src/openapi/generate-spec.test.ts @@ -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); +}); diff --git a/apps/server/src/openapi/generate-spec.ts b/apps/server/src/openapi/generate-spec.ts new file mode 100644 index 000000000..b5dca6c63 --- /dev/null +++ b/apps/server/src/openapi/generate-spec.ts @@ -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]); +} diff --git a/apps/server/src/openapi/generator.test.ts b/apps/server/src/openapi/generator.test.ts new file mode 100644 index 000000000..85fc0d31b --- /dev/null +++ b/apps/server/src/openapi/generator.test.ts @@ -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 }; + paths?: Record< + string, + Record< + string, + { + requestBody?: { + content?: Record; + }; + } + > + >; +}; + +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; + 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[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: "

Not an experience item

" }], + }, + ], + }; + + 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"], + }); + }); +}); diff --git a/apps/server/src/openapi/generator.ts b/apps/server/src/openapi/generator.ts new file mode 100644 index 000000000..e4370dc06 --- /dev/null +++ b/apps/server/src/openapi/generator.ts @@ -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>[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"), + }); +} diff --git a/apps/server/src/openapi/handler.ts b/apps/server/src/openapi/handler.ts index a1e031abd..19b619860 100644 --- a/apps/server/src/openapi/handler.ts +++ b/apps/server/src/openapi/handler.ts @@ -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 }); diff --git a/apps/server/src/rpc/handler.ts b/apps/server/src/rpc/handler.ts index bdb351c98..6438537af 100644 --- a/apps/server/src/rpc/handler.ts +++ b/apps/server/src/rpc/handler.ts @@ -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 }); diff --git a/apps/server/src/services/stylesheet-preflight.test.ts b/apps/server/src/services/stylesheet-preflight.test.ts new file mode 100644 index 000000000..3be7bf5c1 --- /dev/null +++ b/apps/server/src/services/stylesheet-preflight.test.ts @@ -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: "

Missing company

" }], + } 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); + }); +}); diff --git a/apps/server/src/services/stylesheet-preflight.ts b/apps/server/src/services/stylesheet-preflight.ts new file mode 100644 index 000000000..ca3617be5 --- /dev/null +++ b/apps/server/src/services/stylesheet-preflight.ts @@ -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 = {}, + 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 | undefined; + let startupTimer: ReturnType | 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 { + return new Promise((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(); diff --git a/apps/server/src/static/schema.test.ts b/apps/server/src/static/schema.test.ts new file mode 100644 index 000000000..67f6f0086 --- /dev/null +++ b/apps/server/src/static/schema.test.ts @@ -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[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: "

Not an experience item

" }], + }, + ], + }; + + expect(schema.safeParse(mismatched).success).toBe(false); + }); +}); diff --git a/apps/server/src/static/schema.ts b/apps/server/src/static/schema.ts index cef0552a8..d966c28c1 100644 --- a/apps/server/src/static/schema.ts +++ b/apps/server/src/static/schema.ts @@ -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", diff --git a/apps/server/src/workers/stylesheet-preflight-inspection.ts b/apps/server/src/workers/stylesheet-preflight-inspection.ts new file mode 100644 index 000000000..b70a898bf --- /dev/null +++ b/apps/server/src/workers/stylesheet-preflight-inspection.ts @@ -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; +}; + +type LoadPdf = (options: { data: Uint8Array }) => PdfLoadingTask; + +export async function inspectPreflightPdf( + rendered: Extract, + limits: StylesheetPreflightInspectionLimits, + loadPdf: LoadPdf = getDocument, +): Promise { + 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); + } +} diff --git a/apps/server/src/workers/stylesheet-preflight.test.ts b/apps/server/src/workers/stylesheet-preflight.test.ts new file mode 100644 index 000000000..86ac6cec1 --- /dev/null +++ b/apps/server/src/workers/stylesheet-preflight.test.ts @@ -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], + }); + }); +}); diff --git a/apps/server/src/workers/stylesheet-preflight.ts b/apps/server/src/workers/stylesheet-preflight.ts new file mode 100644 index 000000000..29fa9d330 --- /dev/null +++ b/apps/server/src/workers/stylesheet-preflight.ts @@ -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 { + 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: [], + }); + }); +} diff --git a/apps/server/tsdown.config.ts b/apps/server/tsdown.config.ts index 3a94384cf..1f97ea762 100644 --- a/apps/server/tsdown.config.ts +++ b/apps/server/tsdown.config.ts @@ -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", diff --git a/apps/server/turbo.json b/apps/server/turbo.json index a49fee94d..3cbfc7ade 100644 --- a/apps/server/turbo.json +++ b/apps/server/turbo.json @@ -1,4 +1,9 @@ { "extends": ["//"], - "tags": ["app:server", "runtime:server", "role:adapter"] + "tags": ["app:server", "runtime:server", "role:adapter"], + "tasks": { + "test:ci": { + "cache": false + } + } } diff --git a/apps/web/locales/en-US.po b/apps/web/locales/en-US.po index 370e56763..32d46e04f 100644 --- a/apps/web/locales/en-US.po +++ b/apps/web/locales/en-US.po @@ -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 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." diff --git a/apps/web/package.json b/apps/web/package.json index 03904a865..570866cd2 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/src/features/resume/builder/draft.test.ts b/apps/web/src/features/resume/builder/draft.test.ts index 5f376e5ff..2afee237e 100644 --- a/apps/web/src/features/resume/builder/draft.test.ts +++ b/apps/web/src/features/resume/builder/draft.test.ts @@ -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; + }; + 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; + }; + await act(async () => handlers.onEvent({ mutation: "update" })); + + expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id, remote.data); + }); }); diff --git a/apps/web/src/features/resume/builder/draft.ts b/apps/web/src/features/resume/builder/draft.ts index 359d3fa4c..3ab474c31 100644 --- a/apps/web/src/features/resume/builder/draft.ts +++ b/apps/web/src/features/resume/builder/draft.ts @@ -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); diff --git a/apps/web/src/features/resume/export/pdf-document.tsx b/apps/web/src/features/resume/export/pdf-document.tsx index 195a7c280..3bcebfa3c 100644 --- a/apps/web/src/features/resume/export/pdf-document.tsx +++ b/apps/web/src/features/resume/export/pdf-document.tsx @@ -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 & { 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, }); }; diff --git a/apps/web/src/features/resume/export/use-resume-export.test.tsx b/apps/web/src/features/resume/export/use-resume-export.test.tsx new file mode 100644 index 000000000..d0614be7d --- /dev/null +++ b/apps/web/src/features/resume/export/use-resume-export.test.tsx @@ -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); + }); +}); diff --git a/apps/web/src/features/resume/export/use-resume-export.ts b/apps/web/src/features/resume/export/use-resume-export.ts index 8fa5a5166..4ea302916 100644 --- a/apps/web/src/features/resume/export/use-resume-export.ts +++ b/apps/web/src/features/resume/export/use-resume-export.ts @@ -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( + () => + resume?.id && resume.id === stylesheetResumeId + ? { + mode: stylesheetMode, + source: stylesheetSource, + applied: stylesheetApplied, + } + : undefined, + [resume?.id, stylesheetApplied, stylesheetMode, stylesheetResumeId, stylesheetSource], + ); + const pdfPresentation = useMemo( + () => + 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 }; } diff --git a/apps/web/src/features/resume/preview/preview.browser.test.tsx b/apps/web/src/features/resume/preview/preview.browser.test.tsx index 05e10f43f..7ac9d60da 100644 --- a/apps/web/src/features/resume/preview/preview.browser.test.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.test.tsx @@ -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( + , + ); + 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(() => {})); + const glalieData: ResumeData = { + ...sampleResumeData, + metadata: { ...sampleResumeData.metadata, template: "glalie" }, + }; + view.rerender( + , + ); + + 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(); + + 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(); + 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(); + + 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(); }); }); diff --git a/apps/web/src/features/resume/preview/preview.browser.tsx b/apps/web/src/features/resume/preview/preview.browser.tsx index 2ef28de53..f4e8fb9d0 100644 --- a/apps/web/src/features/resume/preview/preview.browser.tsx +++ b/apps/web/src/features/resume/preview/preview.browser.tsx @@ -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; 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([]); @@ -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({ { @@ -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; @@ -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( + , + ); + + 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( + , + ); + + 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( + , + ); + + await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(2)); + expect(refetchStyleProjection).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/web/src/features/resume/public/pdf-viewer.tsx b/apps/web/src/features/resume/public/pdf-viewer.tsx index 803ce4d7a..53b0039c2 100644 --- a/apps/web/src/features/resume/public/pdf-viewer.tsx +++ b/apps/web/src/features/resume/public/pdf-viewer.tsx @@ -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; + publicResume?: { + username: string; + slug: string; + }; }; type PdfViewerOptions = ConstructorParameters[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(null); const containerRef = useRef(null); const viewerRef = useRef(null); const fileRef = useRef(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; diff --git a/apps/web/src/features/resume/public/public-pdf.test.ts b/apps/web/src/features/resume/public/public-pdf.test.ts new file mode 100644 index 000000000..657275afc --- /dev/null +++ b/apps/web/src/features/resume/public/public-pdf.test.ts @@ -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(); + }); +}); diff --git a/apps/web/src/features/resume/public/public-pdf.ts b/apps/web/src/features/resume/public/public-pdf.ts new file mode 100644 index 000000000..da2dd4c88 --- /dev/null +++ b/apps/web/src/features/resume/public/public-pdf.ts @@ -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; + 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 => { + 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 { + 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 }); +} diff --git a/apps/web/src/features/resume/public/public-resume.test.tsx b/apps/web/src/features/resume/public/public-resume.test.tsx index 806386f4f..eb3be8454 100644 --- a/apps/web/src/features/resume/public/public-resume.test.tsx +++ b/apps/web/src/features/resume/public/public-resume.test.tsx @@ -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; + 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 }) => (
)); @@ -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", () => { diff --git a/apps/web/src/features/resume/public/public-resume.tsx b/apps/web/src/features/resume/public/public-resume.tsx index b4f8a7932..b9347d53d 100644 --- a/apps/web/src/features/resume/public/public-resume.tsx +++ b/apps/web/src/features/resume/public/public-resume.tsx @@ -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 ; + if (!resume || projectionQuery.isPending) return ; const { basics, picture } = resume.data; @@ -44,7 +66,14 @@ export function PublicResumeRoute() {
- +