mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-08-15 02:53:25 +10:00
feat: add semantic CSS stylesheets (#3274)
Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor Agent
parent
4ac19f81b3
commit
d2ffbf9618
@@ -56,6 +56,9 @@ jobs:
|
|||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
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
|
- name: Install Playwright Browser
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
|
|
||||||
@@ -73,8 +76,38 @@ jobs:
|
|||||||
- name: Build
|
- name: Build
|
||||||
run: pnpm build
|
run: pnpm build
|
||||||
|
|
||||||
- name: Run E2E Tests
|
- name: Run Baseline E2E Tests
|
||||||
run: pnpm test:e2e:ci
|
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
|
- name: Upload Playwright Report
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ temp
|
|||||||
.cursor
|
.cursor
|
||||||
.codegraph
|
.codegraph
|
||||||
.superpowers
|
.superpowers
|
||||||
|
.worktrees
|
||||||
.migration
|
.migration
|
||||||
|
|
||||||
# Local Storage Data
|
# Local Storage Data
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch src/index.ts",
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"start": "node dist/index.mjs",
|
"start": "node dist/index.mjs",
|
||||||
|
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
@@ -36,6 +37,7 @@
|
|||||||
"@better-auth/infra": "^0.3.7",
|
"@better-auth/infra": "^0.3.7",
|
||||||
"@better-auth/oauth-provider": "^1.6.25",
|
"@better-auth/oauth-provider": "^1.6.25",
|
||||||
"@better-auth/passkey": "^1.6.25",
|
"@better-auth/passkey": "^1.6.25",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@hono/node-server": "^2.0.12",
|
"@hono/node-server": "^2.0.12",
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||||
"@orpc/client": "^1.14.12",
|
"@orpc/client": "^1.14.12",
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
"@reactive-resume/db": "workspace:*",
|
"@reactive-resume/db": "workspace:*",
|
||||||
"@reactive-resume/env": "workspace:*",
|
"@reactive-resume/env": "workspace:*",
|
||||||
"@reactive-resume/mcp": "workspace:*",
|
"@reactive-resume/mcp": "workspace:*",
|
||||||
|
"@reactive-resume/pdf": "workspace:*",
|
||||||
"@reactive-resume/schema": "workspace:*",
|
"@reactive-resume/schema": "workspace:*",
|
||||||
"@reactive-resume/utils": "workspace:*",
|
"@reactive-resume/utils": "workspace:*",
|
||||||
"@sindresorhus/slugify": "^3.0.0",
|
"@sindresorhus/slugify": "^3.0.0",
|
||||||
@@ -58,7 +61,9 @@
|
|||||||
"ai": "^7.0.37",
|
"ai": "^7.0.37",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
"better-auth": "1.6.25",
|
"better-auth": "1.6.25",
|
||||||
|
"canonicalize": "^3.0.0",
|
||||||
"cjk-regex": "^3.4.0",
|
"cjk-regex": "^3.4.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
"deepmerge-ts": "^7.1.5",
|
"deepmerge-ts": "^7.1.5",
|
||||||
"drizzle-orm": "1.0.0-rc.4",
|
"drizzle-orm": "1.0.0-rc.4",
|
||||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||||
@@ -69,6 +74,7 @@
|
|||||||
"node-html-parser": "^9.0.0",
|
"node-html-parser": "^9.0.0",
|
||||||
"nodemailer": "^9.0.3",
|
"nodemailer": "^9.0.3",
|
||||||
"ollama-ai-provider-v2": "^4.0.1",
|
"ollama-ai-provider-v2": "^4.0.1",
|
||||||
|
"pdfjs-dist": "^6.2.108",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
"phosphor-icons-react-pdf": "^0.1.3",
|
"phosphor-icons-react-pdf": "^0.1.3",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
|||||||
handleUpload: vi.fn(),
|
handleUpload: vi.fn(),
|
||||||
handleMcp: vi.fn(),
|
handleMcp: vi.fn(),
|
||||||
handleResumePdfDownload: vi.fn(),
|
handleResumePdfDownload: vi.fn(),
|
||||||
|
handlePublicResumePdf: vi.fn(),
|
||||||
handleMcpServerCard: vi.fn(),
|
handleMcpServerCard: vi.fn(),
|
||||||
handleOAuthAuthorizationServer: vi.fn(),
|
handleOAuthAuthorizationServer: vi.fn(),
|
||||||
handleOAuthProtectedResource: vi.fn(),
|
handleOAuthProtectedResource: vi.fn(),
|
||||||
@@ -69,6 +70,15 @@ vi.mock("./resume-pdf", () => ({
|
|||||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("./public-resume-pdf", () => ({
|
||||||
|
handlePublicResumePdf: mocks.handlePublicResumePdf,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const transportEnv = (remoteAddress: string) =>
|
||||||
|
({
|
||||||
|
incoming: { socket: { remoteAddress } },
|
||||||
|
}) as never;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||||
@@ -79,6 +89,7 @@ beforeEach(() => {
|
|||||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||||
|
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
|
||||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||||
@@ -117,6 +128,51 @@ describe("createApp", () => {
|
|||||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
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([
|
it.each([
|
||||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||||
|
import type { Context } from "hono";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
import { handleMcp } from "../mcp/handler";
|
import { handleMcp } from "../mcp/handler";
|
||||||
import { handleOpenApi } from "../openapi/handler";
|
import { handleOpenApi } from "../openapi/handler";
|
||||||
@@ -15,18 +19,33 @@ import { handleUpload } from "../static/uploads";
|
|||||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||||
import { handleAuth, handleOAuth } from "./auth";
|
import { handleAuth, handleOAuth } from "./auth";
|
||||||
import { handleHealth } from "./health";
|
import { handleHealth } from "./health";
|
||||||
|
import { handlePublicResumePdf } from "./public-resume-pdf";
|
||||||
import { handleResumePdfDownload } from "./resume-pdf";
|
import { handleResumePdfDownload } from "./resume-pdf";
|
||||||
|
|
||||||
export function createApp() {
|
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||||
const app = new Hono();
|
|
||||||
|
|
||||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
try {
|
||||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
const address = getConnInfo(context).remote.address?.trim();
|
||||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
return address && isIP(address) ? address : "unknown";
|
||||||
|
} catch {
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createApp() {
|
||||||
|
const app = new Hono<ServerEnvironment>();
|
||||||
|
|
||||||
|
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||||
|
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||||
|
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||||
|
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
||||||
app.get("/api/health", () => handleHealth());
|
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/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
createPublicResumePdf: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
|
||||||
|
createPublicResumePdf: mocks.createPublicResumePdf,
|
||||||
|
PUBLIC_RESUME_PDF_MISMATCH_REASONS: [
|
||||||
|
"missing-projection",
|
||||||
|
"format-version",
|
||||||
|
"language-version",
|
||||||
|
"semantic-tree-version",
|
||||||
|
"registry-fingerprint",
|
||||||
|
"adapter-fingerprint",
|
||||||
|
"render-data-hash",
|
||||||
|
"invalid-projection",
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { handlePublicResumePdf } = await import("./public-resume-pdf");
|
||||||
|
const trustedClient = "203.0.113.9";
|
||||||
|
|
||||||
|
describe("handlePublicResumePdf", () => {
|
||||||
|
beforeEach(() => vi.clearAllMocks());
|
||||||
|
|
||||||
|
it("returns the authorized fallback PDF with strict mismatch metadata and cache policy", async () => {
|
||||||
|
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
|
||||||
|
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||||
|
body,
|
||||||
|
filename: "Ada_Lovelace.pdf",
|
||||||
|
});
|
||||||
|
const registry = "0".repeat(64);
|
||||||
|
const adapter = "1".repeat(64);
|
||||||
|
const request = new Request(
|
||||||
|
`https://example.com/api/resumes/jane/resume/pdf?reason=render-data-hash®istryFingerprint=${registry}&adapterFingerprint=${adapter}`,
|
||||||
|
{ headers: { "x-forwarded-for": "203.0.113.7" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||||
|
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||||
|
expect(await response.text()).toBe("%PDF");
|
||||||
|
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||||
|
username: "jane",
|
||||||
|
slug: "resume",
|
||||||
|
requestHeaders: request.headers,
|
||||||
|
trustedClient,
|
||||||
|
mismatchReason: "render-data-hash",
|
||||||
|
clientRegistryFingerprint: registry,
|
||||||
|
clientAdapterFingerprint: adapter,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defaults a missing mismatch reason and keeps password/private responses uncacheable", async () => {
|
||||||
|
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||||
|
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
|
||||||
|
filename: "resume.pdf",
|
||||||
|
});
|
||||||
|
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
|
||||||
|
|
||||||
|
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||||
|
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ mismatchReason: "missing-projection" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[{ code: "BAD_REQUEST" }, 400],
|
||||||
|
[{ code: "NEED_PASSWORD" }, 401],
|
||||||
|
[{ code: "NOT_FOUND" }, 404],
|
||||||
|
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
|
||||||
|
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
|
||||||
|
])("maps controlled API errors without caching the response", async (error, status) => {
|
||||||
|
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
|
||||||
|
|
||||||
|
const response = await handlePublicResumePdf(
|
||||||
|
new Request("https://example.com/api/resumes/jane/resume/pdf"),
|
||||||
|
"jane",
|
||||||
|
"resume",
|
||||||
|
trustedClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(status);
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["?reason=private-source", "?registryFingerprint=unsafe", "?adapterFingerprint=unsafe"])(
|
||||||
|
"rejects invalid fallback metadata before the API service",
|
||||||
|
async (search) => {
|
||||||
|
const response = await handlePublicResumePdf(
|
||||||
|
new Request(`https://example.com/api/resumes/jane/resume/pdf${search}`),
|
||||||
|
"jane",
|
||||||
|
"resume",
|
||||||
|
trustedClient,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
expect(mocks.createPublicResumePdf).not.toHaveBeenCalled();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { PublicResumePdfMismatchReason } from "@reactive-resume/api/features/resume/public-pdf";
|
||||||
|
import {
|
||||||
|
createPublicResumePdf,
|
||||||
|
PUBLIC_RESUME_PDF_MISMATCH_REASONS,
|
||||||
|
} from "@reactive-resume/api/features/resume/public-pdf";
|
||||||
|
|
||||||
|
const noStoreResponse = (body: string, status: number) =>
|
||||||
|
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
|
||||||
|
|
||||||
|
const errorStatus = (error: unknown): number => {
|
||||||
|
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||||
|
if (code === "BAD_REQUEST") return 400;
|
||||||
|
if (code === "NEED_PASSWORD") return 401;
|
||||||
|
if (code === "NOT_FOUND") return 404;
|
||||||
|
if (code === "RATE_LIMIT_EXCEEDED") return 429;
|
||||||
|
return 500;
|
||||||
|
};
|
||||||
|
|
||||||
|
const fingerprint = (value: string | null): string | undefined => {
|
||||||
|
if (value === null) return;
|
||||||
|
return /^[a-f0-9]{64}$/.test(value) ? value : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function handlePublicResumePdf(
|
||||||
|
request: Request,
|
||||||
|
username: string,
|
||||||
|
slug: string,
|
||||||
|
trustedClient = "unknown",
|
||||||
|
): Promise<Response> {
|
||||||
|
const searchParams = new URL(request.url).searchParams;
|
||||||
|
const rawReason = searchParams.get("reason") ?? "missing-projection";
|
||||||
|
if (!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(rawReason as PublicResumePdfMismatchReason)) {
|
||||||
|
return noStoreResponse("Invalid fallback metadata", 400);
|
||||||
|
}
|
||||||
|
const rawRegistryFingerprint = searchParams.get("registryFingerprint");
|
||||||
|
const rawAdapterFingerprint = searchParams.get("adapterFingerprint");
|
||||||
|
const clientRegistryFingerprint = fingerprint(rawRegistryFingerprint);
|
||||||
|
const clientAdapterFingerprint = fingerprint(rawAdapterFingerprint);
|
||||||
|
if (
|
||||||
|
(rawRegistryFingerprint !== null && clientRegistryFingerprint === undefined) ||
|
||||||
|
(rawAdapterFingerprint !== null && clientAdapterFingerprint === undefined)
|
||||||
|
) {
|
||||||
|
return noStoreResponse("Invalid fallback metadata", 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createPublicResumePdf({
|
||||||
|
username,
|
||||||
|
slug,
|
||||||
|
requestHeaders: request.headers,
|
||||||
|
trustedClient,
|
||||||
|
mismatchReason: rawReason as PublicResumePdfMismatchReason,
|
||||||
|
...(clientRegistryFingerprint ? { clientRegistryFingerprint } : {}),
|
||||||
|
...(clientAdapterFingerprint ? { clientAdapterFingerprint } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(result.body, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/pdf",
|
||||||
|
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const status = errorStatus(error);
|
||||||
|
return noStoreResponse(
|
||||||
|
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
|
||||||
|
status,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterEach, expect, it } from "vitest";
|
||||||
|
import { generateOpenApiDocumentation } from "./generate-spec";
|
||||||
|
|
||||||
|
const temporaryDirectories: string[] = [];
|
||||||
|
const committedSpec = new URL("../../../../docs/spec.json", import.meta.url);
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await Promise.all(temporaryDirectories.splice(0).map((path) => rm(path, { recursive: true, force: true })));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the committed OpenAPI specification synchronized without rewriting it", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "openapi-documentation-"));
|
||||||
|
temporaryDirectories.push(directory);
|
||||||
|
const firstTarget = join(directory, "first.json");
|
||||||
|
const secondTarget = join(directory, "second.json");
|
||||||
|
const before = await readFile(committedSpec, "utf8");
|
||||||
|
|
||||||
|
await generateOpenApiDocumentation(firstTarget);
|
||||||
|
await generateOpenApiDocumentation(secondTarget);
|
||||||
|
|
||||||
|
expect(await readFile(firstTarget, "utf8")).toBe(before);
|
||||||
|
expect(await readFile(secondTarget, "utf8")).toBe(before);
|
||||||
|
expect(await readFile(committedSpec, "utf8")).toBe(before);
|
||||||
|
});
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
export async function generateOpenApiDocumentation(
|
||||||
|
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
|
||||||
|
) {
|
||||||
|
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
process.env.APP_URL ??= "https://rxresu.me";
|
||||||
|
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
|
||||||
|
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
|
||||||
|
const { generateOpenApiSpec } = await import("./generator");
|
||||||
|
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
|
||||||
|
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||||
|
await generateOpenApiDocumentation(process.argv[2]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import z from "zod";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||||
|
|
||||||
|
type GeneratedSpecView = {
|
||||||
|
components?: { schemas?: Record<string, unknown> };
|
||||||
|
paths?: Record<
|
||||||
|
string,
|
||||||
|
Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
requestBody?: {
|
||||||
|
content?: Record<string, { schema?: unknown }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
>
|
||||||
|
>;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function generateSpec() {
|
||||||
|
process.env.APP_URL ??= "https://rxresu.me";
|
||||||
|
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_test";
|
||||||
|
process.env.AUTH_SECRET ??= "openapi-generator-test-process-only";
|
||||||
|
const { generateOpenApiSpec } = await import("./generator");
|
||||||
|
return generateOpenApiSpec({
|
||||||
|
appUrl: "https://rxresu.me",
|
||||||
|
version: "9.8.7",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
|
||||||
|
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
|
||||||
|
}
|
||||||
|
|
||||||
|
function containsImpossibleSchema(value: unknown): boolean {
|
||||||
|
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const object = value as Record<string, unknown>;
|
||||||
|
const negated = object.not;
|
||||||
|
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Object.values(object).some(containsImpossibleSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
||||||
|
const impossibleRequests: string[] = [];
|
||||||
|
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||||
|
for (const [method, operation] of Object.entries(operations)) {
|
||||||
|
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
|
||||||
|
if (containsImpossibleSchema(content.schema)) {
|
||||||
|
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return impossibleRequests;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("generateOpenApiSpec", () => {
|
||||||
|
it("uses caller-provided application URL and version", async () => {
|
||||||
|
const spec = await generateSpec();
|
||||||
|
|
||||||
|
expect(spec.info).toMatchObject({
|
||||||
|
title: "Reactive Resume",
|
||||||
|
version: "9.8.7",
|
||||||
|
});
|
||||||
|
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
|
||||||
|
expect(spec.externalDocs).toEqual({
|
||||||
|
url: "https://docs.rxresu.me",
|
||||||
|
description: "Reactive Resume Documentation",
|
||||||
|
});
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||||
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
|
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||||
|
|
||||||
|
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
|
||||||
|
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
|
||||||
|
properties: {
|
||||||
|
data: { $ref: "#/components/schemas/ResumeData" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("publishes the custom-section type and item correlation", async () => {
|
||||||
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
|
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||||
|
const mismatched = {
|
||||||
|
...defaultResumeData,
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish impossible request schemas", async () => {
|
||||||
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
|
|
||||||
|
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("checks every request body media type for impossible schemas", () => {
|
||||||
|
const spec: GeneratedSpecView = {
|
||||||
|
paths: {
|
||||||
|
"/documents": {
|
||||||
|
post: {
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": { schema: { type: "object" } },
|
||||||
|
"multipart/form-data": { schema: { not: {} } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("documents imported data as an accepted ResumeData input", async () => {
|
||||||
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
|
|
||||||
|
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
data: { $ref: "#/components/schemas/ResumeData" },
|
||||||
|
},
|
||||||
|
required: ["data"],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||||
|
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
|
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||||
|
import router from "@reactive-resume/api/routers";
|
||||||
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||||
|
|
||||||
|
export const openAPIRouter = {
|
||||||
|
...router,
|
||||||
|
resume: {
|
||||||
|
...router.resume,
|
||||||
|
downloadPdf: downloadResumePdfProcedure,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
|
||||||
|
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
|
||||||
|
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
|
||||||
|
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
|
||||||
|
if (importResumeInputSchema) {
|
||||||
|
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
data: { $ref: "#/components/schemas/ResumeData" },
|
||||||
|
},
|
||||||
|
required: ["data"],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const openAPIGenerator = new OpenAPIGenerator({
|
||||||
|
schemaConverters: [
|
||||||
|
new ZodToJsonSchemaConverter({
|
||||||
|
interceptors: [
|
||||||
|
({ options, next }) => {
|
||||||
|
const [required, schema] = next();
|
||||||
|
const impossible =
|
||||||
|
Object.keys(schema).length === 1 &&
|
||||||
|
typeof schema.not === "object" &&
|
||||||
|
schema.not !== null &&
|
||||||
|
Object.keys(schema.not).length === 0;
|
||||||
|
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
type GenerateOpenApiSpecOptions = {
|
||||||
|
appUrl: string;
|
||||||
|
version: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||||
|
return await openAPIGenerator.generate(openAPIRouter, {
|
||||||
|
info: {
|
||||||
|
title: "Reactive Resume",
|
||||||
|
version,
|
||||||
|
description: "Reactive Resume API",
|
||||||
|
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||||
|
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||||
|
},
|
||||||
|
servers: [{ url: `${appUrl}/api/openapi` }],
|
||||||
|
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||||
|
commonSchemas: {
|
||||||
|
ResumeData: { schema: resumeDataSchema, strategy: "input" },
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
apiKey: {
|
||||||
|
type: "apiKey",
|
||||||
|
name: "x-api-key",
|
||||||
|
in: "header",
|
||||||
|
description: "The API key to authenticate requests.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
security: [{ apiKey: [] }],
|
||||||
|
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,24 +1,13 @@
|
|||||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
|
||||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||||
import { onError } from "@orpc/server";
|
import { onError } from "@orpc/server";
|
||||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
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 { env } from "@reactive-resume/env/server";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
|
||||||
import { appVersion } from "../app-version";
|
import { appVersion } from "../app-version";
|
||||||
import { mergeResponseHeaders } from "../http/headers";
|
import { mergeResponseHeaders } from "../http/headers";
|
||||||
import { getRequestLocale } from "../rpc/locale";
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||||
const openAPIRouter = {
|
|
||||||
...router,
|
|
||||||
resume: {
|
|
||||||
...router.resume,
|
|
||||||
downloadPdf: downloadResumePdfProcedure,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||||
plugins: [
|
plugins: [
|
||||||
@@ -36,46 +25,15 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
const openAPIGenerator = new OpenAPIGenerator({
|
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
||||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
|
||||||
});
|
|
||||||
|
|
||||||
export async function handleOpenApi(request: Request) {
|
|
||||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const resHeaders = new Headers();
|
const resHeaders = new Headers();
|
||||||
const { response } = await openAPIHandler.handle(request, {
|
const { response } = await openAPIHandler.handle(request, {
|
||||||
prefix: "/api/openapi",
|
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 });
|
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { RPCHandler } from "@orpc/server/fetch";
|
|||||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||||
import router from "@reactive-resume/api/routers";
|
import router from "@reactive-resume/api/routers";
|
||||||
import { mergeResponseHeaders } from "../http/headers";
|
import { mergeResponseHeaders } from "../http/headers";
|
||||||
|
import { stylesheetPreflightRunner } from "../services/stylesheet-preflight";
|
||||||
import { getRequestLocale } from "./locale";
|
import { getRequestLocale } from "./locale";
|
||||||
|
|
||||||
const rpcHandler = new RPCHandler(router, {
|
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 resHeaders = new Headers();
|
||||||
const { response } = await rpcHandler.handle(request, {
|
const { response } = await rpcHandler.handle(request, {
|
||||||
prefix: "/api/rpc",
|
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 });
|
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { createStylesheetPreflightRunner, STYLESHEET_PREFLIGHT_LIMITS } from "./stylesheet-preflight";
|
||||||
|
|
||||||
|
const validStylesheet = {
|
||||||
|
languageVersion: 1,
|
||||||
|
text: "@version 1;",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const input = {
|
||||||
|
data: defaultResumeData,
|
||||||
|
template: defaultResumeData.metadata.template,
|
||||||
|
stylesheet: validStylesheet,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const memoryExhaustionWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent(`
|
||||||
|
const retained = [];
|
||||||
|
while (true) {
|
||||||
|
const batch = Array.from({ length: 100_000 }, (_, index) => ({ batch: retained.length, index }));
|
||||||
|
retained.push(batch);
|
||||||
|
}
|
||||||
|
`)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent('throw new Error("sensitive worker details");')}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const delayedSuccessfulWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent(`
|
||||||
|
import { parentPort, workerData } from "node:worker_threads";
|
||||||
|
parentPort.postMessage({ type: "ready" });
|
||||||
|
setTimeout(() => {
|
||||||
|
parentPort.postMessage({
|
||||||
|
ok: true,
|
||||||
|
pageCount: 1,
|
||||||
|
byteCount: Number(workerData.input.data.basics.name),
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
}, 300);
|
||||||
|
`)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const delayedReadyWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent(`
|
||||||
|
import { parentPort } from "node:worker_threads";
|
||||||
|
setTimeout(() => {
|
||||||
|
parentPort.postMessage({ type: "ready" });
|
||||||
|
setTimeout(() => {
|
||||||
|
parentPort.postMessage({
|
||||||
|
ok: true,
|
||||||
|
pageCount: 1,
|
||||||
|
byteCount: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
}, 10);
|
||||||
|
}, 50);
|
||||||
|
`)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const neverCompletesWorker = new URL(
|
||||||
|
`data:text/javascript,${encodeURIComponent(`
|
||||||
|
import { parentPort } from "node:worker_threads";
|
||||||
|
parentPort.postMessage({ type: "ready" });
|
||||||
|
setInterval(() => {}, 1_000);
|
||||||
|
`)}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const synchronousFailureWorker = new URL("https://example.com/stylesheet-preflight.mjs");
|
||||||
|
|
||||||
|
const numberedInput = (number: number) => ({
|
||||||
|
...input,
|
||||||
|
data: {
|
||||||
|
...input.data,
|
||||||
|
basics: {
|
||||||
|
...input.data.basics,
|
||||||
|
name: String(number),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidInput = () => {
|
||||||
|
const data = structuredClone(defaultResumeData);
|
||||||
|
data.customSections = [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
return { ...input, data };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stylesheet PDF preflight worker", () => {
|
||||||
|
it("keeps the production resource policy fixed and immutable", () => {
|
||||||
|
expect(STYLESHEET_PREFLIGHT_LIMITS).toEqual({
|
||||||
|
timeoutMs: 5_000,
|
||||||
|
maxPages: 20,
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
maxPageWidthPt: 2_000,
|
||||||
|
maxPageHeightPt: 20_000,
|
||||||
|
maxPageAreaPt2: 20_000_000,
|
||||||
|
maxOldGenerationMb: 256,
|
||||||
|
maxConcurrentWorkers: 1,
|
||||||
|
maxQueuedRequests: 32,
|
||||||
|
});
|
||||||
|
expect(Object.isFrozen(STYLESHEET_PREFLIGHT_LIMITS)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a bounded candidate render in an isolated worker", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
|
||||||
|
|
||||||
|
const result = await runner.run(input);
|
||||||
|
|
||||||
|
expect(result).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
ok: true,
|
||||||
|
pageCount: 1,
|
||||||
|
byteCount: expect.any(Number),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
if (!result.ok) throw new Error(`Expected successful preflight, received ${result.code}.`);
|
||||||
|
expect(result.byteCount).toBeGreaterThan(0);
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
}, 45_000);
|
||||||
|
|
||||||
|
it("preserves structured resume-data failures across the worker boundary", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({ timeoutMs: 15_000 });
|
||||||
|
|
||||||
|
const result = runner.run(invalidInput());
|
||||||
|
|
||||||
|
await expect(result).rejects.toMatchObject({
|
||||||
|
name: "ZodError",
|
||||||
|
issues: expect.arrayContaining([expect.objectContaining({ path: ["customSections", 0, "items", 0, "company"] })]),
|
||||||
|
});
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
|
}, 20_000);
|
||||||
|
|
||||||
|
it("terminates a worker when the render exceeds its deadline", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({ timeoutMs: 1 }, neverCompletesWorker);
|
||||||
|
|
||||||
|
const result = await runner.run(input);
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" }));
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts the authored render deadline after the worker runtime is ready", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({ timeoutMs: 20 }, delayedReadyWorker);
|
||||||
|
|
||||||
|
await expect(runner.run(input)).resolves.toEqual(expect.objectContaining({ ok: true, pageCount: 1 }));
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns deterministic output byte and page limit codes", async () => {
|
||||||
|
const byteRunner = createStylesheetPreflightRunner({ maxBytes: 16 });
|
||||||
|
const pageRunner = createStylesheetPreflightRunner({ maxPages: 0 });
|
||||||
|
|
||||||
|
await expect(byteRunner.run(input)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT" }),
|
||||||
|
);
|
||||||
|
await expect(pageRunner.run(input)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT" }),
|
||||||
|
);
|
||||||
|
expect(byteRunner.activeWorkerCount).toBe(0);
|
||||||
|
expect(pageRunner.activeWorkerCount).toBe(0);
|
||||||
|
}, 30_000);
|
||||||
|
|
||||||
|
it("maps worker heap exhaustion to a controlled memory-limit result", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner(
|
||||||
|
{ maxOldGenerationMb: 8, timeoutMs: 10_000 },
|
||||||
|
memoryExhaustionWorker,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await runner.run(input);
|
||||||
|
|
||||||
|
expect(result).toEqual(expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_MEMORY_LIMIT" }));
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
}, 15_000);
|
||||||
|
|
||||||
|
it("does not expose internal errors from a failed worker", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({}, failedWorker);
|
||||||
|
|
||||||
|
const result = await runner.run(input);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight worker failed.",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds concurrent workers and queued requests without charging queue time to the worker deadline", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner(
|
||||||
|
{
|
||||||
|
timeoutMs: 500,
|
||||||
|
maxConcurrentWorkers: 1,
|
||||||
|
maxQueuedRequests: 2,
|
||||||
|
},
|
||||||
|
delayedSuccessfulWorker,
|
||||||
|
);
|
||||||
|
const completionOrder: number[] = [];
|
||||||
|
const accepted = [1, 2, 3].map((number) =>
|
||||||
|
runner.run(numberedInput(number)).then((result) => {
|
||||||
|
if (result.ok) completionOrder.push(result.byteCount);
|
||||||
|
return result;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const rejected = runner.run(numberedInput(4));
|
||||||
|
|
||||||
|
expect(runner.activeWorkerCount).toBe(1);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(2);
|
||||||
|
await expect(rejected).resolves.toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight queue is full.",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const results = await Promise.all(accepted);
|
||||||
|
|
||||||
|
expect(results.every((result) => result.ok)).toBe(true);
|
||||||
|
expect(completionOrder).toEqual([1, 2, 3]);
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
|
}, 5_000);
|
||||||
|
|
||||||
|
it("does not leak a slot when the worker constructor throws synchronously", async () => {
|
||||||
|
const runner = createStylesheetPreflightRunner({}, synchronousFailureWorker);
|
||||||
|
|
||||||
|
await expect(runner.run(input)).resolves.toEqual(
|
||||||
|
expect.objectContaining({ ok: false, code: "STYLESHEET_PREFLIGHT_WORKER_FAILED" }),
|
||||||
|
);
|
||||||
|
expect(runner.activeWorkerCount).toBe(0);
|
||||||
|
expect(runner.queuedPreflightCount).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
import type {
|
||||||
|
PdfPreflightFailure,
|
||||||
|
PdfPreflightResult,
|
||||||
|
StylesheetPreflightInput,
|
||||||
|
StylesheetPreflightRunner,
|
||||||
|
} from "@reactive-resume/pdf/server";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { Worker } from "node:worker_threads";
|
||||||
|
import { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
|
||||||
|
|
||||||
|
export { STYLESHEET_PREFLIGHT_LIMITS } from "@reactive-resume/pdf/preflight-reference";
|
||||||
|
|
||||||
|
type StylesheetPreflightLimits = {
|
||||||
|
timeoutMs: number;
|
||||||
|
maxPages: number;
|
||||||
|
maxBytes: number;
|
||||||
|
maxPageWidthPt: number;
|
||||||
|
maxPageHeightPt: number;
|
||||||
|
maxPageAreaPt2: number;
|
||||||
|
maxOldGenerationMb: number;
|
||||||
|
maxConcurrentWorkers: number;
|
||||||
|
maxQueuedRequests: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOURCE_WORKER_LOADER_HEAP_MB = 256;
|
||||||
|
const SOURCE_WORKER_STARTUP_TIMEOUT_MS = 30_000;
|
||||||
|
const WORKER_STARTUP_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
type SerializedPreflightCause = {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
issues: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type StylesheetPreflightWorkerMessage =
|
||||||
|
| PdfPreflightResult
|
||||||
|
| { type: "ready" }
|
||||||
|
| { type: "preflight_error"; cause: SerializedPreflightCause };
|
||||||
|
|
||||||
|
export type NodeStylesheetPreflightRunner = StylesheetPreflightRunner & {
|
||||||
|
readonly activeWorkerCount: number;
|
||||||
|
readonly queuedPreflightCount: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
|
||||||
|
ok: false,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const workerFailure = (error: Error): PdfPreflightFailure => {
|
||||||
|
const code = (error as NodeJS.ErrnoException).code;
|
||||||
|
return code === "ERR_WORKER_OUT_OF_MEMORY" || /heap out of memory/i.test(error.message)
|
||||||
|
? failure("STYLESHEET_PREFLIGHT_MEMORY_LIMIT", "The PDF preflight worker exceeded its memory limit.")
|
||||||
|
: failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceWorkerExecArgv = () => {
|
||||||
|
const importIndex = process.execArgv.findIndex(
|
||||||
|
(argument, index, arguments_) =>
|
||||||
|
(argument === "--import" && arguments_[index + 1]?.includes("tsx")) ||
|
||||||
|
(argument.startsWith("--import=") && argument.includes("tsx")),
|
||||||
|
);
|
||||||
|
const inherited = process.execArgv[importIndex];
|
||||||
|
if (inherited?.startsWith("--import=")) return [inherited];
|
||||||
|
if (inherited === "--import") return [inherited, process.execArgv[importIndex + 1] as string];
|
||||||
|
return ["--import", import.meta.resolve("tsx")];
|
||||||
|
};
|
||||||
|
|
||||||
|
const workerLocation = () => {
|
||||||
|
const source = import.meta.url.endsWith(".ts");
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
url: source
|
||||||
|
? new URL("../workers/stylesheet-preflight.ts", import.meta.url)
|
||||||
|
: new URL("./stylesheet-preflight-worker.mjs", import.meta.url),
|
||||||
|
...(source
|
||||||
|
? {
|
||||||
|
execArgv: sourceWorkerExecArgv(),
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
TSX_TSCONFIG_PATH: fileURLToPath(new URL("../../tsconfig.json", import.meta.url)),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createStylesheetPreflightRunner(
|
||||||
|
overrides: Partial<StylesheetPreflightLimits> = {},
|
||||||
|
testWorkerUrl?: URL,
|
||||||
|
): NodeStylesheetPreflightRunner {
|
||||||
|
const limits = Object.freeze({ ...STYLESHEET_PREFLIGHT_LIMITS, ...overrides });
|
||||||
|
let activeWorkerCount = 0;
|
||||||
|
// ponytail: Keep admission process-local and bounded; upgrade to a distributed/pooled queue only for multi-process coordination.
|
||||||
|
const queue: Array<{
|
||||||
|
input: StylesheetPreflightInput;
|
||||||
|
resolve: (result: PdfPreflightResult) => void;
|
||||||
|
reject: (cause: unknown) => void;
|
||||||
|
}> = [];
|
||||||
|
|
||||||
|
const runWorker = (
|
||||||
|
input: StylesheetPreflightInput,
|
||||||
|
resolve: (result: PdfPreflightResult) => void,
|
||||||
|
reject: (cause: unknown) => void,
|
||||||
|
): boolean => {
|
||||||
|
// The URL seam is internal to the server package and keeps worker failure tests independent from the PDF renderer.
|
||||||
|
const location = testWorkerUrl ? { source: false, url: testWorkerUrl } : workerLocation();
|
||||||
|
let worker: Worker;
|
||||||
|
try {
|
||||||
|
worker = new Worker(location.url, {
|
||||||
|
name: "stylesheet-preflight",
|
||||||
|
workerData: { input, limits },
|
||||||
|
resourceLimits: {
|
||||||
|
// The source-only tsx compiler heap is outside the production render budget.
|
||||||
|
maxOldGenerationSizeMb: limits.maxOldGenerationMb + (location.source ? SOURCE_WORKER_LOADER_HEAP_MB : 0),
|
||||||
|
},
|
||||||
|
...("execArgv" in location ? { execArgv: location.execArgv } : {}),
|
||||||
|
...("env" in location ? { env: location.env } : {}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
resolve(workerFailure(error instanceof Error ? error : new Error("Failed to start PDF preflight worker.")));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
activeWorkerCount += 1;
|
||||||
|
let settled = false;
|
||||||
|
let renderTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let startupTimer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
|
||||||
|
const cleanup = () => {
|
||||||
|
if (renderTimer) clearTimeout(renderTimer);
|
||||||
|
if (startupTimer) clearTimeout(startupTimer);
|
||||||
|
worker.off("message", onMessage);
|
||||||
|
worker.off("error", onError);
|
||||||
|
worker.off("exit", onExit);
|
||||||
|
activeWorkerCount -= 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
const finish = async (result: PdfPreflightResult) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
await worker.terminate().catch(() => undefined);
|
||||||
|
cleanup();
|
||||||
|
resolve(result);
|
||||||
|
drainQueue();
|
||||||
|
};
|
||||||
|
const fail = async (cause: SerializedPreflightCause) => {
|
||||||
|
if (settled) return;
|
||||||
|
settled = true;
|
||||||
|
await worker.terminate().catch(() => undefined);
|
||||||
|
cleanup();
|
||||||
|
reject(Object.assign(new Error(cause.message), { name: cause.name, issues: cause.issues }));
|
||||||
|
drainQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onMessage = (message: StylesheetPreflightWorkerMessage) => {
|
||||||
|
if ("type" in message) {
|
||||||
|
if (message.type === "preflight_error") {
|
||||||
|
void fail(message.cause);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (startupTimer) clearTimeout(startupTimer);
|
||||||
|
renderTimer = setTimeout(() => {
|
||||||
|
void finish(failure("STYLESHEET_PREFLIGHT_TIMEOUT", "The PDF preflight exceeded its deadline."));
|
||||||
|
}, limits.timeoutMs);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void finish(message);
|
||||||
|
};
|
||||||
|
const onError = (error: Error) => {
|
||||||
|
void finish(workerFailure(error));
|
||||||
|
};
|
||||||
|
const onExit = () => {
|
||||||
|
if (!settled) {
|
||||||
|
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.on("message", onMessage);
|
||||||
|
worker.once("error", onError);
|
||||||
|
worker.once("exit", onExit);
|
||||||
|
startupTimer = setTimeout(
|
||||||
|
() => {
|
||||||
|
void finish(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed."));
|
||||||
|
},
|
||||||
|
location.source ? SOURCE_WORKER_STARTUP_TIMEOUT_MS : WORKER_STARTUP_TIMEOUT_MS,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
function drainQueue() {
|
||||||
|
while (activeWorkerCount < limits.maxConcurrentWorkers && queue.length > 0) {
|
||||||
|
const next = queue.shift();
|
||||||
|
if (!next) return;
|
||||||
|
runWorker(next.input, next.resolve, next.reject);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
get activeWorkerCount() {
|
||||||
|
return activeWorkerCount;
|
||||||
|
},
|
||||||
|
get queuedPreflightCount() {
|
||||||
|
return queue.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
run(input: StylesheetPreflightInput): Promise<PdfPreflightResult> {
|
||||||
|
return new Promise<PdfPreflightResult>((resolve, reject) => {
|
||||||
|
if (activeWorkerCount < limits.maxConcurrentWorkers) {
|
||||||
|
runWorker(input, resolve, reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (queue.length >= limits.maxQueuedRequests) {
|
||||||
|
resolve(failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight queue is full."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queue.push({ input, resolve, reject });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const stylesheetPreflightRunner = createStylesheetPreflightRunner();
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import z from "zod";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { handleSchemaJson } from "./schema";
|
||||||
|
|
||||||
|
describe("handleSchemaJson", () => {
|
||||||
|
it("publishes the custom-section type and item correlation", async () => {
|
||||||
|
const response = handleSchemaJson();
|
||||||
|
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
|
||||||
|
const mismatched = {
|
||||||
|
...defaultResumeData,
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,11 +1,8 @@
|
|||||||
import z from "zod";
|
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
|
||||||
import { appVersion } from "../app-version";
|
import { appVersion } from "../app-version";
|
||||||
|
|
||||||
export function handleSchemaJson() {
|
export function handleSchemaJson() {
|
||||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
return Response.json(createResumeDataJsonSchema(), {
|
||||||
|
|
||||||
return Response.json(resumeDataJSONSchema, {
|
|
||||||
status: 200,
|
status: 200,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/schema+json; charset=utf-8",
|
"Content-Type": "application/schema+json; charset=utf-8",
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import type { PdfPreflightPageLimits, PdfPreflightResult, RenderPreflightPdfResult } from "@reactive-resume/pdf/server";
|
||||||
|
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||||
|
|
||||||
|
type StylesheetPreflightInspectionLimits = PdfPreflightPageLimits & {
|
||||||
|
maxPages: number;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PdfLoadingTask = {
|
||||||
|
promise: PromiseLike<{ numPages: number }>;
|
||||||
|
destroy(): Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LoadPdf = (options: { data: Uint8Array }) => PdfLoadingTask;
|
||||||
|
|
||||||
|
export async function inspectPreflightPdf(
|
||||||
|
rendered: Extract<RenderPreflightPdfResult, { ok: true }>,
|
||||||
|
limits: StylesheetPreflightInspectionLimits,
|
||||||
|
loadPdf: LoadPdf = getDocument,
|
||||||
|
): Promise<PdfPreflightResult> {
|
||||||
|
if (rendered.bytes.byteLength > limits.maxBytes) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_BYTE_LIMIT",
|
||||||
|
message: "The rendered PDF exceeds the preflight byte limit.",
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const byteCount = rendered.bytes.byteLength;
|
||||||
|
let loadingTask: PdfLoadingTask;
|
||||||
|
try {
|
||||||
|
loadingTask = loadPdf({ data: rendered.bytes });
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||||
|
message: "PDF inspection failed.",
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
let document: { numPages: number };
|
||||||
|
try {
|
||||||
|
document = await loadingTask.promise;
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||||
|
message: "PDF inspection failed.",
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (document.numPages > limits.maxPages) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_PAGE_LIMIT",
|
||||||
|
message: "The rendered PDF exceeds the preflight page limit.",
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
pageCount: document.numPages,
|
||||||
|
byteCount,
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await loadingTask.destroy().catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
||||||
|
|
||||||
|
const warning = {
|
||||||
|
code: "EXTREME_VALUE",
|
||||||
|
severity: "warning",
|
||||||
|
message: "The authored value is unusually large.",
|
||||||
|
range: {
|
||||||
|
start: { line: 1, column: 1, offset: 0 },
|
||||||
|
end: { line: 1, column: 10, offset: 9 },
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const rendered = {
|
||||||
|
ok: true as const,
|
||||||
|
bytes: new TextEncoder().encode("%PDF-1.7"),
|
||||||
|
diagnostics: [warning],
|
||||||
|
};
|
||||||
|
|
||||||
|
const limits = {
|
||||||
|
maxPages: 20,
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
maxPageWidthPt: 2_000,
|
||||||
|
maxPageHeightPt: 20_000,
|
||||||
|
maxPageAreaPt2: 20_000_000,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stylesheet preflight PDF inspection", () => {
|
||||||
|
it("preserves compiler warnings and hides parser exceptions", async () => {
|
||||||
|
const destroy = vi.fn(async () => undefined);
|
||||||
|
|
||||||
|
const result = await inspectPreflightPdf(rendered, limits, () => ({
|
||||||
|
promise: Promise.reject(new Error("sensitive parser details")),
|
||||||
|
destroy,
|
||||||
|
}));
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||||
|
message: "PDF inspection failed.",
|
||||||
|
diagnostics: [warning],
|
||||||
|
});
|
||||||
|
expect(destroy).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves compiler warnings when PDF.js fails before returning a loading task", async () => {
|
||||||
|
const result = await inspectPreflightPdf(rendered, limits, () => {
|
||||||
|
throw new Error("sensitive parser setup details");
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_PARSE_FAILED",
|
||||||
|
message: "PDF inspection failed.",
|
||||||
|
diagnostics: [warning],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { PdfPreflightPageLimits, PdfPreflightResult, StylesheetPreflightInput } from "@reactive-resume/pdf/server";
|
||||||
|
import { parentPort, workerData } from "node:worker_threads";
|
||||||
|
import * as React from "react";
|
||||||
|
import { renderPreflightPdf } from "@reactive-resume/pdf/server";
|
||||||
|
import { inspectPreflightPdf } from "./stylesheet-preflight-inspection";
|
||||||
|
|
||||||
|
(globalThis as typeof globalThis & { React: typeof React }).React = React;
|
||||||
|
|
||||||
|
type StylesheetPreflightWorkerLimits = PdfPreflightPageLimits & {
|
||||||
|
maxPages: number;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StylesheetPreflightWorkerData = {
|
||||||
|
input: StylesheetPreflightInput;
|
||||||
|
limits: StylesheetPreflightWorkerLimits;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SerializedPreflightCause = {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
issues: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = (result: PdfPreflightResult) => {
|
||||||
|
parentPort?.postMessage(result);
|
||||||
|
};
|
||||||
|
|
||||||
|
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||||
|
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||||
|
};
|
||||||
|
|
||||||
|
parentPort?.postMessage({ type: "ready" });
|
||||||
|
|
||||||
|
async function run(): Promise<PdfPreflightResult> {
|
||||||
|
const { input, limits } = workerData as StylesheetPreflightWorkerData;
|
||||||
|
const rendered = await renderPreflightPdf(input, limits);
|
||||||
|
return rendered.ok ? inspectPreflightPdf(rendered, limits) : rendered;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parentPort) {
|
||||||
|
void run()
|
||||||
|
.then(send)
|
||||||
|
.catch((cause: unknown) => {
|
||||||
|
const serializedCause = serializeZodCause(cause);
|
||||||
|
if (serializedCause) {
|
||||||
|
parentPort?.postMessage({ type: "preflight_error", cause: serializedCause });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
send({
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_WORKER_FAILED",
|
||||||
|
message: "The PDF preflight worker failed.",
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -33,7 +33,10 @@ const promptAssetsPlugin: TsdownPlugin = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: { index: "src/index.ts" },
|
entry: {
|
||||||
|
index: "src/index.ts",
|
||||||
|
"stylesheet-preflight-worker": "src/workers/stylesheet-preflight.ts",
|
||||||
|
},
|
||||||
format: "esm",
|
format: "esm",
|
||||||
platform: "node",
|
platform: "node",
|
||||||
target: "node24",
|
target: "node24",
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
{
|
{
|
||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||||
|
"tasks": {
|
||||||
|
"test:ci": {
|
||||||
|
"cache": false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,6 +158,10 @@ msgstr "Account menu"
|
|||||||
msgid "Actions"
|
msgid "Actions"
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-components/dock.tsx
|
||||||
msgid "Actual size (100%)"
|
msgid "Actual size (100%)"
|
||||||
msgstr "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)"
|
msgstr "Applications sent per week (last 8 weeks)"
|
||||||
|
|
||||||
#: src/features/applications/components/table-view.tsx
|
#: src/features/applications/components/table-view.tsx
|
||||||
|
#: src/features/resume/stylesheet/status.tsx
|
||||||
msgid "Applied"
|
msgid "Applied"
|
||||||
msgstr "Applied"
|
msgstr "Applied"
|
||||||
|
|
||||||
@@ -488,6 +493,10 @@ msgstr "Applied on"
|
|||||||
msgid "Applied Rules"
|
msgid "Applied Rules"
|
||||||
msgstr "Applied Rules"
|
msgstr "Applied Rules"
|
||||||
|
|
||||||
|
#: src/features/resume/stylesheet/status.tsx
|
||||||
|
msgid "Applied with warnings"
|
||||||
|
msgstr "Applied with warnings"
|
||||||
|
|
||||||
#: src/libs/locale.ts
|
#: src/libs/locale.ts
|
||||||
msgid "Arabic"
|
msgid "Arabic"
|
||||||
msgstr "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."
|
msgid "Check your email for a link to verify your account."
|
||||||
msgstr "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
|
#: src/libs/locale.ts
|
||||||
msgid "Chinese (Simplified)"
|
msgid "Chinese (Simplified)"
|
||||||
msgstr "Chinese (Simplified)"
|
msgstr "Chinese (Simplified)"
|
||||||
@@ -961,6 +978,10 @@ msgstr "Conversation copied."
|
|||||||
msgid "Conversation JSON copied."
|
msgid "Conversation JSON copied."
|
||||||
msgstr "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
|
#: src/features/applications/components/application-ai-copilot.tsx
|
||||||
msgid "Copied to clipboard."
|
msgid "Copied to clipboard."
|
||||||
msgstr "Copied to clipboard."
|
msgstr "Copied to clipboard."
|
||||||
@@ -984,6 +1005,10 @@ msgstr "Copy Backup Codes"
|
|||||||
msgid "Copy JSON"
|
msgid "Copy JSON"
|
||||||
msgstr "Copy JSON"
|
msgstr "Copy JSON"
|
||||||
|
|
||||||
|
#: src/features/resume/stylesheet/toolbar.tsx
|
||||||
|
msgid "Copy stylesheet"
|
||||||
|
msgstr "Copy stylesheet"
|
||||||
|
|
||||||
#: src/dialogs/api-key/create.tsx
|
#: src/dialogs/api-key/create.tsx
|
||||||
msgid "Copy this secret key and use it in your applications to access your data."
|
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."
|
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"
|
msgid "Edit application"
|
||||||
msgstr "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
|
#. Screen reader description for the fullscreen rich-text editor dialog
|
||||||
#: src/components/input/rich-input.tsx
|
#: src/components/input/rich-input.tsx
|
||||||
msgid "Edit content in fullscreen mode"
|
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."
|
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."
|
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
|
#: 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!"
|
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!"
|
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."
|
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."
|
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
|
#: src/components/input/rich-input.tsx
|
||||||
msgid "Exit Fullscreen"
|
msgid "Exit Fullscreen"
|
||||||
msgstr "Exit Fullscreen"
|
msgstr "Exit Fullscreen"
|
||||||
@@ -1996,6 +2034,10 @@ msgctxt "Page Format (A4, Letter, Free-form)"
|
|||||||
msgid "Format"
|
msgid "Format"
|
||||||
msgstr "Format"
|
msgstr "Format"
|
||||||
|
|
||||||
|
#: src/features/resume/stylesheet/toolbar.tsx
|
||||||
|
msgid "Format stylesheet"
|
||||||
|
msgstr "Format stylesheet"
|
||||||
|
|
||||||
#: src/routes/_home/-sections/features.tsx
|
#: src/routes/_home/-sections/features.tsx
|
||||||
msgid "Free"
|
msgid "Free"
|
||||||
msgstr "Free"
|
msgstr "Free"
|
||||||
@@ -2579,6 +2621,12 @@ msgstr "Light"
|
|||||||
msgid "Light theme"
|
msgid "Light theme"
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/typography.tsx
|
||||||
msgid "Line Height"
|
msgid "Line Height"
|
||||||
msgstr "Line Height"
|
msgstr "Line Height"
|
||||||
@@ -2963,6 +3011,10 @@ msgstr "Open AI assistant"
|
|||||||
msgid "Open Email Client"
|
msgid "Open Email Client"
|
||||||
msgstr "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
|
#: src/routes/agent/-components/resume-pane.tsx
|
||||||
msgid "Open in builder"
|
msgid "Open in builder"
|
||||||
msgstr "Open in builder"
|
msgstr "Open in builder"
|
||||||
@@ -3262,6 +3314,10 @@ msgstr "Press <0>Enter</0> to open"
|
|||||||
msgid "Preview"
|
msgid "Preview"
|
||||||
msgstr "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
|
#: src/routes/builder/$resumeId/-sidebar/right/sections/design.tsx
|
||||||
msgid "Primary Color"
|
msgid "Primary Color"
|
||||||
msgstr "Primary Color"
|
msgstr "Primary Color"
|
||||||
@@ -3373,6 +3429,14 @@ msgstr "Reactive Resume v4 (JSON)"
|
|||||||
msgid "Reading…"
|
msgid "Reading…"
|
||||||
msgstr "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
|
#: src/dialogs/resume/sections/cover-letter.tsx
|
||||||
msgid "Recipient"
|
msgid "Recipient"
|
||||||
msgstr "Recipient"
|
msgstr "Recipient"
|
||||||
@@ -3389,6 +3453,10 @@ msgstr "Rectangle (Full Width)"
|
|||||||
msgid "Redo"
|
msgid "Redo"
|
||||||
msgstr "Redo"
|
msgstr "Redo"
|
||||||
|
|
||||||
|
#: src/features/resume/stylesheet/toolbar.tsx
|
||||||
|
msgid "Redo stylesheet edit"
|
||||||
|
msgstr "Redo stylesheet edit"
|
||||||
|
|
||||||
#: src/dialogs/resume/sections/custom.tsx
|
#: src/dialogs/resume/sections/custom.tsx
|
||||||
#: src/libs/resume/section-title.ts
|
#: src/libs/resume/section-title.ts
|
||||||
#: src/libs/resume/section.tsx
|
#: src/libs/resume/section.tsx
|
||||||
@@ -3490,6 +3558,10 @@ msgstr "Reset Password"
|
|||||||
msgid "Reset Style"
|
msgid "Reset Style"
|
||||||
msgstr "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
|
#: src/features/auth/pages/reset-password.tsx
|
||||||
msgid "Reset your password"
|
msgid "Reset your password"
|
||||||
msgstr "Reset your password"
|
msgstr "Reset your password"
|
||||||
@@ -3786,6 +3858,14 @@ msgstr "selected"
|
|||||||
msgid "Self-Host with Docker"
|
msgid "Self-Host with Docker"
|
||||||
msgstr "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
|
#: src/routes/agent/-components/agent-chat.tsx
|
||||||
msgid "Send message"
|
msgid "Send message"
|
||||||
msgstr "Send message"
|
msgstr "Send message"
|
||||||
@@ -4147,6 +4227,14 @@ msgstr "Strike"
|
|||||||
msgid "Strong fit"
|
msgid "Strong fit"
|
||||||
msgstr "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
|
#: src/routes/_home/-sections/footer.tsx
|
||||||
msgid "Subreddit"
|
msgid "Subreddit"
|
||||||
msgstr "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"
|
msgid "The password you entered is incorrect"
|
||||||
msgstr "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
|
#: src/features/auth/pages/resume-password.tsx
|
||||||
msgid "The resume you are trying to access is password protected"
|
msgid "The resume you are trying to access is password protected"
|
||||||
msgstr "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."
|
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."
|
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
|
#: src/dialogs/resume/index.tsx
|
||||||
msgid "This is a URL-friendly name for your resume."
|
msgid "This is a URL-friendly name for your resume."
|
||||||
msgstr "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"
|
msgid "Undo"
|
||||||
msgstr "Undo"
|
msgstr "Undo"
|
||||||
|
|
||||||
|
#: src/features/resume/stylesheet/toolbar.tsx
|
||||||
|
msgid "Undo stylesheet edit"
|
||||||
|
msgstr "Undo stylesheet edit"
|
||||||
|
|
||||||
#: src/routes/_home/-sections/features.tsx
|
#: src/routes/_home/-sections/features.tsx
|
||||||
msgid "Unlimited Resumes"
|
msgid "Unlimited Resumes"
|
||||||
msgstr "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."
|
msgid "Your latest changes could not be saved."
|
||||||
msgstr "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
|
#: src/features/auth/pages/reset-password.tsx
|
||||||
msgid "Your password has been reset successfully. You can now sign in with your new password."
|
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."
|
msgstr "Your password has been reset successfully. You can now sign in with your new password."
|
||||||
|
|||||||
@@ -22,6 +22,14 @@
|
|||||||
"@better-auth/infra": "^0.3.7",
|
"@better-auth/infra": "^0.3.7",
|
||||||
"@better-auth/oauth-provider": "^1.6.25",
|
"@better-auth/oauth-provider": "^1.6.25",
|
||||||
"@better-auth/passkey": "^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/core": "^6.3.1",
|
||||||
"@dnd-kit/sortable": "^10.0.0",
|
"@dnd-kit/sortable": "^10.0.0",
|
||||||
"@dnd-kit/utilities": "^3.2.2",
|
"@dnd-kit/utilities": "^3.2.2",
|
||||||
@@ -60,6 +68,7 @@
|
|||||||
"@uiw/react-color-colorful": "^2.10.3",
|
"@uiw/react-color-colorful": "^2.10.3",
|
||||||
"ai": "^7.0.37",
|
"ai": "^7.0.37",
|
||||||
"better-auth": "1.6.25",
|
"better-auth": "1.6.25",
|
||||||
|
"buffer": "^6.0.3",
|
||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"drizzle-orm": "1.0.0-rc.4",
|
"drizzle-orm": "1.0.0-rc.4",
|
||||||
"es-toolkit": "^1.50.0",
|
"es-toolkit": "^1.50.0",
|
||||||
@@ -69,6 +78,7 @@
|
|||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
"pdfjs-dist": "6.1.200",
|
"pdfjs-dist": "6.1.200",
|
||||||
"pg": "^8.22.0",
|
"pg": "^8.22.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"qrcode.react": "^4.2.0",
|
"qrcode.react": "^4.2.0",
|
||||||
"react": "^19.2.8",
|
"react": "^19.2.8",
|
||||||
"react-dom": "^19.2.8",
|
"react-dom": "^19.2.8",
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ import { act, renderHook } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { i18n } from "@lingui/core";
|
import { i18n } from "@lingui/core";
|
||||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
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(() => ({
|
const orpcMocks = vi.hoisted(() => ({
|
||||||
getResumeById: vi.fn(),
|
getResumeById: vi.fn(),
|
||||||
@@ -30,6 +35,10 @@ const toastMocks = vi.hoisted(() => ({
|
|||||||
error: vi.fn(() => "sync-error-toast"),
|
error: vi.fn(() => "sync-error-toast"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const stylesheetMocks = vi.hoisted(() => ({
|
||||||
|
refresh: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@orpc/client", () => ({
|
vi.mock("@orpc/client", () => ({
|
||||||
consumeEventIterator: consumeEventIteratorMock,
|
consumeEventIterator: consumeEventIteratorMock,
|
||||||
}));
|
}));
|
||||||
@@ -72,6 +81,10 @@ vi.mock("sonner", () => ({
|
|||||||
toast: toastMocks,
|
toast: toastMocks,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||||
|
refreshStylesheetStore: stylesheetMocks.refresh,
|
||||||
|
}));
|
||||||
|
|
||||||
function cloneResumeData(data: ResumeData): ResumeData {
|
function cloneResumeData(data: ResumeData): ResumeData {
|
||||||
return structuredClone(data);
|
return structuredClone(data);
|
||||||
}
|
}
|
||||||
@@ -122,6 +135,7 @@ describe("builder resume autosave", () => {
|
|||||||
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
i18n.loadAndActivate({ locale: "en-US", messages: {} });
|
||||||
toastMocks.dismiss.mockClear();
|
toastMocks.dismiss.mockClear();
|
||||||
toastMocks.error.mockClear();
|
toastMocks.error.mockClear();
|
||||||
|
stylesheetMocks.refresh.mockReset();
|
||||||
useResumeStore.getState().reset();
|
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", () => {
|
describe("builder resume undo/redo", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
@@ -477,4 +506,37 @@ describe("resume update stream subscription", () => {
|
|||||||
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
|
expect(queryClientMock.setQueryData).toHaveBeenCalledWith(["resume", "getById", initial.id], remote);
|
||||||
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
|
expect(useResumeStore.getState().resume?.data.basics.name).toBe("Local Name");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("refetches canonical stylesheet state for stylesheet SSE events", async () => {
|
||||||
|
const initial = makeResume("resume-stylesheet");
|
||||||
|
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
|
||||||
|
routerParamsMock.value = { resumeId: initial.id };
|
||||||
|
useResumeStore.getState().initialize(initial);
|
||||||
|
|
||||||
|
renderHook(() => useBuilderResumeUpdateSubscription());
|
||||||
|
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as {
|
||||||
|
onEvent: (event: { mutation: string }) => Promise<void>;
|
||||||
|
};
|
||||||
|
await act(async () => handlers.onEvent({ mutation: "stylesheet" }));
|
||||||
|
|
||||||
|
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id);
|
||||||
|
expect(orpcMocks.getResumeById).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refreshes the render-data version after content SSE events", async () => {
|
||||||
|
const initial = makeResume("resume-content");
|
||||||
|
const remote = withBasicsName(initial, "Remote");
|
||||||
|
consumeEventIteratorMock.mockReturnValue(vi.fn().mockResolvedValue(undefined));
|
||||||
|
orpcMocks.getResumeById.mockResolvedValue(remote);
|
||||||
|
routerParamsMock.value = { resumeId: initial.id };
|
||||||
|
useResumeStore.getState().initialize(initial);
|
||||||
|
|
||||||
|
renderHook(() => useBuilderResumeUpdateSubscription());
|
||||||
|
const handlers = consumeEventIteratorMock.mock.calls[0]?.[1] as {
|
||||||
|
onEvent: (event: { mutation: string }) => Promise<void>;
|
||||||
|
};
|
||||||
|
await act(async () => handlers.onEvent({ mutation: "update" }));
|
||||||
|
|
||||||
|
expect(stylesheetMocks.refresh).toHaveBeenCalledWith(initial.id, remote.data);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useCallback, useEffect, useState } from "react";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { immer } from "zustand/middleware/immer";
|
import { immer } from "zustand/middleware/immer";
|
||||||
import { create } from "zustand/react";
|
import { create } from "zustand/react";
|
||||||
|
import { refreshStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||||
import { orpc, streamClient } from "@/libs/orpc/client";
|
import { orpc, streamClient } from "@/libs/orpc/client";
|
||||||
|
|
||||||
export type Resume = {
|
export type Resume = {
|
||||||
@@ -25,7 +26,7 @@ export type Resume = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors the server-side ResumeUpdatedEvent discriminator (packages/api resume/events.ts).
|
// 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 ResumeUpdateEvent = { mutation: ResumeUpdateMutation };
|
||||||
|
|
||||||
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
type SaveStatus = "idle" | "saving" | "saved" | "error";
|
||||||
@@ -114,7 +115,8 @@ export function isEditableElementFocused(): boolean {
|
|||||||
element.tagName === "INPUT" ||
|
element.tagName === "INPUT" ||
|
||||||
element.tagName === "TEXTAREA" ||
|
element.tagName === "TEXTAREA" ||
|
||||||
element.tagName === "SELECT" ||
|
element.tagName === "SELECT" ||
|
||||||
element.isContentEditable
|
element.isContentEditable ||
|
||||||
|
element.closest(".cm-editor") !== null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -614,9 +616,14 @@ export function useBuilderResumeUpdateSubscription() {
|
|||||||
if (!resumeId) return;
|
if (!resumeId) return;
|
||||||
|
|
||||||
bindRuntimeQueryClient(resumeId, queryClient);
|
bindRuntimeQueryClient(resumeId, queryClient);
|
||||||
|
if (event.mutation === "stylesheet") {
|
||||||
|
await refreshStylesheetStore(resumeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
const resume = (await orpc.resume.getById.call({ id: resumeId })) as Resume;
|
||||||
|
|
||||||
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
queryClient.setQueryData(getResumeQueryKey(resumeId), resume);
|
||||||
|
await refreshStylesheetStore(resumeId, resume.data);
|
||||||
|
|
||||||
if (hasPendingLocalChanges(resumeId)) {
|
if (hasPendingLocalChanges(resumeId)) {
|
||||||
useResumeStore.getState().mergeResumeMetadata(resume);
|
useResumeStore.getState().mergeResumeMetadata(resume);
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
import type { 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 type { Template } from "@reactive-resume/schema/templates";
|
||||||
import { useMemo } from "react";
|
import { useMemo } from "react";
|
||||||
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
|
import { createResumePdfBlob as createPdfBlob } from "@reactive-resume/pdf/browser";
|
||||||
@@ -9,6 +11,22 @@ type ResumePdfRenderOptions = {
|
|||||||
includeCoverLetterHeader?: boolean;
|
includeCoverLetterHeader?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ResumePdfPresentation =
|
||||||
|
| { stylesheet: Pick<SemanticStylesheet, "mode"> & { applied: StylesheetSource } }
|
||||||
|
| { publicStyleProjection: PublicStyleProjection };
|
||||||
|
|
||||||
|
const withAppliedStylesheet = (data: ResumeData, presentation?: ResumePdfPresentation): ResumeData => {
|
||||||
|
if (!presentation || !("stylesheet" in presentation)) return data;
|
||||||
|
const { mode, applied } = presentation.stylesheet;
|
||||||
|
return {
|
||||||
|
...data,
|
||||||
|
metadata: {
|
||||||
|
...data.metadata,
|
||||||
|
stylesheet: { mode, source: applied, applied },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
export const useLocalizedResumeDocument = (data?: ResumeData, template?: Template) => {
|
||||||
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
const sectionTitleResolver = useSectionTitleResolver(data?.metadata.page.locale);
|
||||||
|
|
||||||
@@ -29,13 +47,17 @@ export const createResumePdfBlob = async (
|
|||||||
data: ResumeData,
|
data: ResumeData,
|
||||||
template?: Template,
|
template?: Template,
|
||||||
renderOptions?: ResumePdfRenderOptions,
|
renderOptions?: ResumePdfRenderOptions,
|
||||||
|
presentation?: ResumePdfPresentation,
|
||||||
) => {
|
) => {
|
||||||
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
const sectionTitleResolver = await createSectionTitleResolverForLocale(data.metadata.page.locale);
|
||||||
|
|
||||||
return createPdfBlob({
|
return createPdfBlob({
|
||||||
data,
|
data: withAppliedStylesheet(data, presentation),
|
||||||
template,
|
template,
|
||||||
...(renderOptions ? { renderOptions } : {}),
|
...(renderOptions ? { renderOptions } : {}),
|
||||||
|
...(presentation && "publicStyleProjection" in presentation
|
||||||
|
? { publicStyleProjection: presentation.publicStyleProjection }
|
||||||
|
: {}),
|
||||||
resolveSectionTitle: sectionTitleResolver,
|
resolveSectionTitle: sectionTitleResolver,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { act, renderHook } from "@testing-library/react";
|
||||||
|
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
|
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||||
|
import { useResumeExport } from "./use-resume-export";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
createResumePdfBlob: vi.fn(async () => new Blob(["local"], { type: "application/pdf" })),
|
||||||
|
downloadWithAnchor: vi.fn(),
|
||||||
|
fetch: vi.fn(async (_input: string | URL) => new Response(new Blob(["server"], { type: "application/pdf" }))),
|
||||||
|
toastError: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./pdf-document", () => ({
|
||||||
|
createResumePdfBlob: mocks.createResumePdfBlob,
|
||||||
|
}));
|
||||||
|
vi.mock("@reactive-resume/utils/file", () => ({
|
||||||
|
downloadWithAnchor: mocks.downloadWithAnchor,
|
||||||
|
generateFilename: (name: string, extension: string) => `${name}.${extension}`,
|
||||||
|
}));
|
||||||
|
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||||
|
useStylesheetStore: (selector: (state: object) => unknown) =>
|
||||||
|
selector({
|
||||||
|
resumeId: undefined,
|
||||||
|
mode: "legacy",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: {
|
||||||
|
loading: vi.fn(() => "toast"),
|
||||||
|
error: mocks.toastError,
|
||||||
|
dismiss: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.createResumePdfBlob.mockClear();
|
||||||
|
mocks.downloadWithAnchor.mockClear();
|
||||||
|
mocks.fetch.mockClear();
|
||||||
|
mocks.toastError.mockClear();
|
||||||
|
vi.stubGlobal("fetch", mocks.fetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("useResumeExport public PDF", () => {
|
||||||
|
it("downloads the authorized server blob after one mismatched-projection refetch", async () => {
|
||||||
|
const semanticData = structuredClone(sampleResumeData);
|
||||||
|
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||||
|
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||||
|
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||||
|
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||||
|
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useResumeExport(
|
||||||
|
{ name: "Sample", slug: "sample", data: sampleResumeData },
|
||||||
|
{
|
||||||
|
publicResumePdf: {
|
||||||
|
stylesheetMode: "semantic",
|
||||||
|
styleProjection: mismatchedProjection,
|
||||||
|
refetchStyleProjection,
|
||||||
|
publicResume: { username: "amruth", slug: "sample" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await act(() => result.current.onDownloadPDF());
|
||||||
|
|
||||||
|
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||||
|
const blob = mocks.downloadWithAnchor.mock.calls[0]?.[0] as Blob;
|
||||||
|
expect(await blob.text()).toBe("server");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not download an unstyled PDF when semantic rendering rejects", async () => {
|
||||||
|
mocks.createResumePdfBlob.mockRejectedValueOnce(
|
||||||
|
new Error("The semantic stylesheet could not be rendered.", {
|
||||||
|
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const { result } = renderHook(() => useResumeExport({ name: "Sample", slug: "sample", data: sampleResumeData }));
|
||||||
|
|
||||||
|
await act(() => result.current.onDownloadPDF());
|
||||||
|
|
||||||
|
expect(mocks.downloadWithAnchor).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.toastError).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
import type { ResumeExportTarget } from "@reactive-resume/resume/export-sections";
|
||||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
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 { t } from "@lingui/core/macro";
|
||||||
import { useCallback, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { buildDocx } from "@reactive-resume/docx";
|
import { buildDocx } from "@reactive-resume/docx";
|
||||||
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
|
import { getResumeSectionTitle } from "@reactive-resume/pdf/section-title";
|
||||||
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
|
import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resume/export-sections";
|
||||||
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
import { buildMarkdown } from "@reactive-resume/resume/markdown";
|
||||||
import { downloadWithAnchor, generateFilename } from "@reactive-resume/utils/file";
|
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 { createSectionTitleResolverForLocale } from "@/libs/resume/section-title-locale";
|
||||||
import { createResumePdfBlob } from "./pdf-document";
|
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
|
// ponytail: loosened from Resume to Pick so public-resume (where name may be "" for non-owners) can reuse
|
||||||
type ExportableResume = {
|
type ExportableResume = {
|
||||||
|
id?: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
data: ResumeData;
|
data: ResumeData;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type UseResumeExportOptions = {
|
||||||
|
publicResumePdf?: PublicResumePdfOptions;
|
||||||
|
};
|
||||||
|
|
||||||
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
|
const getExportName = (resume: ExportableResume) => resume.name || resume.data.basics.name || resume.slug;
|
||||||
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
|
const getTargetExportName = (resume: ExportableResume, target: ResumeExportTarget) =>
|
||||||
target === "cover-letter" ? `${getExportName(resume)} Cover Letter` : getExportName(resume);
|
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
|
* 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).
|
* 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 [isExporting, setIsExporting] = useState(false);
|
||||||
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
|
const hasCoverLetter = resume ? resumeHasCoverLetter(resume.data) : false;
|
||||||
|
const stylesheetResumeId = useStylesheetStore((state) => state.resumeId);
|
||||||
|
const stylesheetMode = useStylesheetStore((state) => state.mode);
|
||||||
|
const stylesheetSource = useStylesheetStore((state) => state.source);
|
||||||
|
const stylesheetApplied = useStylesheetStore((state) => state.applied);
|
||||||
|
const canonicalStylesheet = useMemo<SemanticStylesheet | undefined>(
|
||||||
|
() =>
|
||||||
|
resume?.id && resume.id === stylesheetResumeId
|
||||||
|
? {
|
||||||
|
mode: stylesheetMode,
|
||||||
|
source: stylesheetSource,
|
||||||
|
applied: stylesheetApplied,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
[resume?.id, stylesheetApplied, stylesheetMode, stylesheetResumeId, stylesheetSource],
|
||||||
|
);
|
||||||
|
const pdfPresentation = useMemo<ResumePdfPresentation | undefined>(
|
||||||
|
() =>
|
||||||
|
canonicalStylesheet
|
||||||
|
? { stylesheet: { mode: canonicalStylesheet.mode, applied: canonicalStylesheet.applied } }
|
||||||
|
: undefined,
|
||||||
|
[canonicalStylesheet],
|
||||||
|
);
|
||||||
|
|
||||||
const onDownloadJSON = useCallback(() => {
|
const onDownloadJSON = useCallback(() => {
|
||||||
if (!resume) return;
|
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"));
|
downloadWithAnchor(blob, generateFilename(getExportName(resume), "json"));
|
||||||
}, [resume]);
|
}, [canonicalStylesheet, resume]);
|
||||||
|
|
||||||
const onDownloadMarkdown = useCallback(
|
const onDownloadMarkdown = useCallback(
|
||||||
async (target: ResumeExportTarget = "resume") => {
|
async (target: ResumeExportTarget = "resume") => {
|
||||||
@@ -80,18 +121,23 @@ export function useResumeExport(resume: ExportableResume | undefined) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const onDownloadPDF = useCallback(
|
const onDownloadPDF = useCallback(
|
||||||
async (target: ResumeExportTarget = "resume", options?: DownloadPdfOptions) => {
|
async (target: ResumeExportTarget = "resume", downloadOptions?: DownloadPdfOptions) => {
|
||||||
if (!resume) return;
|
if (!resume) return;
|
||||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) return;
|
||||||
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
const toastId = toast.loading(t`Please wait while your PDF is being generated...`);
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
try {
|
||||||
const data = getResumeExportData(resume.data, target);
|
const data = exportOptions.publicResumePdf ? resume.data : getResumeExportData(resume.data, target);
|
||||||
const blob = await createResumePdfBlob(
|
const blob = exportOptions.publicResumePdf
|
||||||
data,
|
? await resolvePublicResumePdfBlob({ data, ...exportOptions.publicResumePdf })
|
||||||
undefined,
|
: await createResumePdfBlob(
|
||||||
target === "cover-letter" ? { includeCoverLetterHeader: options?.includeCoverLetterHeader } : undefined,
|
data,
|
||||||
);
|
undefined,
|
||||||
|
target === "cover-letter"
|
||||||
|
? { includeCoverLetterHeader: downloadOptions?.includeCoverLetterHeader }
|
||||||
|
: undefined,
|
||||||
|
pdfPresentation,
|
||||||
|
);
|
||||||
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
|
downloadWithAnchor(blob, generateFilename(getTargetExportName(resume, target), "pdf"));
|
||||||
} catch {
|
} catch {
|
||||||
toast.error(t`There was a problem while generating the PDF, please try again.`);
|
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);
|
toast.dismiss(toastId);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[resume],
|
[exportOptions.publicResumePdf, pdfPresentation, resume],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onPrint = useCallback(async () => {
|
const onPrint = useCallback(async () => {
|
||||||
@@ -108,7 +154,9 @@ export function useResumeExport(resume: ExportableResume | undefined) {
|
|||||||
const toastId = toast.loading(t`Preparing your resume for printing...`);
|
const toastId = toast.loading(t`Preparing your resume for printing...`);
|
||||||
setIsExporting(true);
|
setIsExporting(true);
|
||||||
try {
|
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);
|
const url = URL.createObjectURL(blob);
|
||||||
// ponytail: print the generated PDF via a hidden iframe (reliable in Chromium). If the browser
|
// 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.
|
// 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);
|
setIsExporting(false);
|
||||||
toast.dismiss(toastId);
|
toast.dismiss(toastId);
|
||||||
}
|
}
|
||||||
}, [resume]);
|
}, [exportOptions.publicResumePdf, pdfPresentation, resume]);
|
||||||
|
|
||||||
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
|
return { onDownloadJSON, onDownloadMarkdown, onDownloadDOCX, onDownloadPDF, onPrint, isExporting, hasCoverLetter };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,15 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
|||||||
import { ResumePreviewClient } from "./preview.browser";
|
import { ResumePreviewClient } from "./preview.browser";
|
||||||
|
|
||||||
const previewMock = vi.hoisted(() => ({
|
const previewMock = vi.hoisted(() => ({
|
||||||
|
builderResumeId: undefined as string | undefined,
|
||||||
builderResumeData: undefined as ResumeData | 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" })),
|
toBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -39,11 +47,28 @@ vi.mock("@/features/resume/export/pdf-document", () => ({
|
|||||||
createResumePdfBlob: previewMock.toBlob,
|
createResumePdfBlob: previewMock.toBlob,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("sonner", () => ({
|
||||||
|
toast: { error: previewMock.toastError },
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("../builder/draft", () => ({
|
vi.mock("../builder/draft", () => ({
|
||||||
useResumeData: () => previewMock.builderResumeData,
|
useResumeData: () => previewMock.builderResumeData,
|
||||||
|
useResumeStore: (selector: (state: { resumeId?: string }) => unknown) =>
|
||||||
|
selector({ resumeId: previewMock.builderResumeId }),
|
||||||
usePreviewPausedStore: (selector: (state: { paused: boolean }) => unknown) => selector({ paused: false }),
|
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 () => {
|
vi.mock("./pdf-canvas", async () => {
|
||||||
const React = await import("react");
|
const React = await import("react");
|
||||||
const pdfDocument = { numPages: 1 };
|
const pdfDocument = { numPages: 1 };
|
||||||
@@ -52,7 +77,7 @@ vi.mock("./pdf-canvas", async () => {
|
|||||||
PdfCanvasDocument: ({ children, onLoadSuccess }: PdfCanvasDocumentProps) => {
|
PdfCanvasDocument: ({ children, onLoadSuccess }: PdfCanvasDocumentProps) => {
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
onLoadSuccess(pdfDocument);
|
onLoadSuccess(pdfDocument);
|
||||||
}, [onLoadSuccess]);
|
}, []);
|
||||||
|
|
||||||
return React.createElement(React.Fragment, null, children(pdfDocument));
|
return React.createElement(React.Fragment, null, children(pdfDocument));
|
||||||
},
|
},
|
||||||
@@ -60,7 +85,7 @@ vi.mock("./pdf-canvas", async () => {
|
|||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
onLoadSuccess(pageNumber, { height: 200, width: 100 });
|
onLoadSuccess(pageNumber, { height: 200, width: 100 });
|
||||||
onRenderSuccess?.();
|
onRenderSuccess?.();
|
||||||
}, [onLoadSuccess, onRenderSuccess, pageNumber]);
|
}, [pageNumber]);
|
||||||
|
|
||||||
return React.createElement(
|
return React.createElement(
|
||||||
"div",
|
"div",
|
||||||
@@ -77,9 +102,17 @@ describe("ResumePreviewClient", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
previewMock.builderResumeId = undefined;
|
||||||
previewMock.builderResumeData = 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.mockReset();
|
||||||
previewMock.toBlob.mockImplementation(async () => new Blob(["%PDF"], { type: "application/pdf" }));
|
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", () => {
|
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).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData);
|
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the rendered template identity on the active layer while its replacement renders", async () => {
|
||||||
|
const view = render(
|
||||||
|
<ResumePreviewClient data={sampleResumeData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
|
||||||
|
);
|
||||||
|
const page = await screen.findByRole("img", { name: "Resume page 1 of 1" });
|
||||||
|
const activeLayer = page.closest('[aria-hidden="false"]');
|
||||||
|
expect(activeLayer?.getAttribute("data-resume-preview-template")).toBe("azurill");
|
||||||
|
|
||||||
|
previewMock.toBlob.mockImplementationOnce(() => new Promise<Blob>(() => {}));
|
||||||
|
const glalieData: ResumeData = {
|
||||||
|
...sampleResumeData,
|
||||||
|
metadata: { ...sampleResumeData.metadata, template: "glalie" },
|
||||||
|
};
|
||||||
|
view.rerender(
|
||||||
|
<ResumePreviewClient data={glalieData} pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(2));
|
||||||
|
expect(activeLayer?.getAttribute("data-resume-preview-template")).toBe("azurill");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the canonical applied stylesheet and ignores invalid editable source", async () => {
|
||||||
|
const validApplied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||||
|
previewMock.builderResumeId = "resume-1";
|
||||||
|
previewMock.builderResumeData = sampleResumeData;
|
||||||
|
previewMock.stylesheet = {
|
||||||
|
resumeId: "resume-1",
|
||||||
|
mode: "semantic",
|
||||||
|
source: { languageVersion: 1, text: "section {" },
|
||||||
|
applied: validApplied,
|
||||||
|
};
|
||||||
|
|
||||||
|
render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(1));
|
||||||
|
expect(previewMock.toBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
|
||||||
|
stylesheet: { mode: "semantic", applied: validApplied },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(previewMock.toBlob).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the active PDF visible and reports later semantic render diagnostics", async () => {
|
||||||
|
previewMock.builderResumeId = "resume-1";
|
||||||
|
previewMock.builderResumeData = sampleResumeData;
|
||||||
|
previewMock.stylesheet = {
|
||||||
|
resumeId: "resume-1",
|
||||||
|
mode: "semantic",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||||
|
};
|
||||||
|
const view = render(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||||
|
expect(await screen.findByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
|
||||||
|
|
||||||
|
previewMock.toBlob.mockRejectedValueOnce(
|
||||||
|
new Error("The semantic stylesheet could not be rendered.", {
|
||||||
|
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
previewMock.stylesheet.applied = {
|
||||||
|
languageVersion: 1,
|
||||||
|
text: "@version 1;\nname { color: #654321; }\n",
|
||||||
|
};
|
||||||
|
view.rerender(<ResumePreviewClient pageLayout="vertical" pageScale={1.25} showPageNumbers={false} />);
|
||||||
|
|
||||||
|
await waitFor(() => expect(previewMock.toBlob).toHaveBeenCalledTimes(2));
|
||||||
|
await waitFor(() => expect(previewMock.toastError).toHaveBeenCalledTimes(1));
|
||||||
|
expect(screen.getByRole("img", { name: "Resume page 1 of 1" })).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
|
import type { Template } from "@reactive-resume/schema/templates";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import type { ResolvedResumePreviewProps } from "./preview.shared";
|
import type { ResolvedResumePreviewProps } from "./preview.shared";
|
||||||
import type { PreviewPageSize } from "./preview.shared.utils";
|
import type { PreviewPageSize } from "./preview.shared.utils";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
import { AnimatePresence, m } from "motion/react";
|
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 { isRTL } from "@reactive-resume/utils/locale";
|
||||||
import { cn } from "@reactive-resume/utils/style";
|
import { cn } from "@reactive-resume/utils/style";
|
||||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
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 { PdfCanvasDocument, PdfCanvasPage } from "./pdf-canvas";
|
||||||
import { ResumePreviewLoader } from "./preview.shared";
|
import { ResumePreviewLoader } from "./preview.shared";
|
||||||
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
|
import { getResumePreviewGapValue, getResumePreviewPageCount } from "./preview.shared.utils";
|
||||||
@@ -19,18 +23,20 @@ type PreviewPdf = {
|
|||||||
pageSizes: Record<number, PreviewPageSize>;
|
pageSizes: Record<number, PreviewPageSize>;
|
||||||
phase: "active" | "exiting" | "staged";
|
phase: "active" | "exiting" | "staged";
|
||||||
renderedPages: number[];
|
renderedPages: number[];
|
||||||
|
template: Template;
|
||||||
};
|
};
|
||||||
|
|
||||||
const UPDATE_DEBOUNCE_MS = 100;
|
const UPDATE_DEBOUNCE_MS = 100;
|
||||||
const CROSSFADE_DURATION_MS = 180;
|
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,
|
file,
|
||||||
id,
|
id,
|
||||||
numPages: 0,
|
numPages: 0,
|
||||||
pageSizes: {},
|
pageSizes: {},
|
||||||
phase: hasExistingPreview ? "staged" : "active",
|
phase: hasExistingPreview ? "staged" : "active",
|
||||||
renderedPages: [],
|
renderedPages: [],
|
||||||
|
template,
|
||||||
});
|
});
|
||||||
|
|
||||||
const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => {
|
const addPreviewLayer = (layers: PreviewPdf[], nextPdf: PreviewPdf) => {
|
||||||
@@ -96,6 +102,17 @@ export function ResumePreviewClient({
|
|||||||
}: ResolvedResumePreviewProps) {
|
}: ResolvedResumePreviewProps) {
|
||||||
const builderResumeData = useResumeData();
|
const builderResumeData = useResumeData();
|
||||||
const resumeData = data ?? builderResumeData;
|
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 paused = usePreviewPausedStore((state) => state.paused);
|
||||||
|
|
||||||
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
|
const [previewLayers, setPreviewLayers] = useState<PreviewPdf[]>([]);
|
||||||
@@ -116,15 +133,25 @@ export function ResumePreviewClient({
|
|||||||
const generatePdfPreview = async () => {
|
const generatePdfPreview = async () => {
|
||||||
try {
|
try {
|
||||||
if (cancelled || requestId !== requestIdRef.current) return;
|
if (cancelled || requestId !== requestIdRef.current) return;
|
||||||
const blob = await createResumePdfBlob(resumeData);
|
const blob = await createResumePdfBlob(resumeData, undefined, undefined, presentation);
|
||||||
|
|
||||||
if (!cancelled && requestId === requestIdRef.current) {
|
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;
|
hasPreviewRef.current = true;
|
||||||
setPreviewLayers((current) => addPreviewLayer(current, nextPdf));
|
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(() => {
|
const timeoutId = window.setTimeout(() => {
|
||||||
@@ -135,7 +162,7 @@ export function ResumePreviewClient({
|
|||||||
cancelled = true;
|
cancelled = true;
|
||||||
window.clearTimeout(timeoutId);
|
window.clearTimeout(timeoutId);
|
||||||
};
|
};
|
||||||
}, [resumeData, paused]);
|
}, [paused, presentation, resumeData]);
|
||||||
|
|
||||||
if (!resumeData) return null;
|
if (!resumeData) return null;
|
||||||
|
|
||||||
@@ -166,6 +193,7 @@ export function ResumePreviewClient({
|
|||||||
<m.div
|
<m.div
|
||||||
key={visiblePdf.id}
|
key={visiblePdf.id}
|
||||||
aria-hidden={visiblePdf.phase !== "active"}
|
aria-hidden={visiblePdf.phase !== "active"}
|
||||||
|
data-resume-preview-template={visiblePdf.template}
|
||||||
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
|
style={{ "--resume-preview-page-gap": resolvedPageGap } as CSSProperties}
|
||||||
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
|
className={cn("col-start-1 row-start-1", visiblePdf.phase !== "active" && "pointer-events-none")}
|
||||||
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
initial={{ opacity: visiblePdf.phase === "active" ? 1 : 0 }}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { render, waitFor } from "@testing-library/react";
|
import { render, waitFor } from "@testing-library/react";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
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 { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||||
|
|
||||||
const pdfViewerMock = vi.hoisted(() => {
|
const pdfViewerMock = vi.hoisted(() => {
|
||||||
@@ -18,6 +19,9 @@ const pdfViewerMock = vi.hoisted(() => {
|
|||||||
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
|
constructorOptions: [] as Array<{ abortSignal?: AbortSignal; container: HTMLDivElement }>,
|
||||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
||||||
getDocument: vi.fn(() => loadingTask),
|
getDocument: vi.fn(() => loadingTask),
|
||||||
|
fetch: vi.fn(
|
||||||
|
async (_input: string | URL) => new Response(new Blob(["%PDF-fallback"], { type: "application/pdf" })),
|
||||||
|
),
|
||||||
instances: [] as Array<{
|
instances: [] as Array<{
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
setDocument: ReturnType<typeof vi.fn>;
|
setDocument: ReturnType<typeof vi.fn>;
|
||||||
@@ -92,6 +96,8 @@ beforeEach(() => {
|
|||||||
pdfViewerMock.createResumePdfBlob.mockClear();
|
pdfViewerMock.createResumePdfBlob.mockClear();
|
||||||
pdfViewerMock.getDocument.mockClear();
|
pdfViewerMock.getDocument.mockClear();
|
||||||
pdfViewerMock.loadingTask.destroy.mockClear();
|
pdfViewerMock.loadingTask.destroy.mockClear();
|
||||||
|
pdfViewerMock.fetch.mockClear();
|
||||||
|
vi.stubGlobal("fetch", pdfViewerMock.fetch);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("PdfViewer", () => {
|
describe("PdfViewer", () => {
|
||||||
@@ -113,4 +119,68 @@ describe("PdfViewer", () => {
|
|||||||
expect(viewer.setDocument).toHaveBeenCalledWith(null);
|
expect(viewer.setDocument).toHaveBeenCalledWith(null);
|
||||||
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
expect(pdfViewerMock.loadingTask.destroy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders a valid public projection through the shared PDF entrypoint", async () => {
|
||||||
|
const semanticData = structuredClone(sampleResumeData);
|
||||||
|
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||||
|
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||||
|
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||||
|
const refetchStyleProjection = vi.fn();
|
||||||
|
|
||||||
|
render(
|
||||||
|
<PdfViewer
|
||||||
|
data={sampleResumeData}
|
||||||
|
stylesheetMode="semantic"
|
||||||
|
styleProjection={projection}
|
||||||
|
refetchStyleProjection={refetchStyleProjection}
|
||||||
|
publicResume={{ username: "amruth", slug: "sample" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(pdfViewerMock.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData, undefined, undefined, {
|
||||||
|
publicStyleProjection: projection,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(refetchStyleProjection).not.toHaveBeenCalled();
|
||||||
|
expect(pdfViewerMock.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches a mismatched projection once before using the authorized PDF fallback", async () => {
|
||||||
|
const semanticData = structuredClone(sampleResumeData);
|
||||||
|
const applied = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||||
|
semanticData.metadata.stylesheet = { mode: "semantic", source: applied, applied };
|
||||||
|
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||||
|
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||||
|
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||||
|
|
||||||
|
const view = render(
|
||||||
|
<PdfViewer
|
||||||
|
data={sampleResumeData}
|
||||||
|
stylesheetMode="semantic"
|
||||||
|
styleProjection={mismatchedProjection}
|
||||||
|
refetchStyleProjection={refetchStyleProjection}
|
||||||
|
publicResume={{ username: "amruth", slug: "sample" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(refetchStyleProjection).toHaveBeenCalledTimes(1));
|
||||||
|
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(1));
|
||||||
|
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
|
||||||
|
expect(String(pdfViewerMock.fetch.mock.calls[0]?.[0])).toContain("reason=render-data-hash");
|
||||||
|
expect(pdfViewerMock.createResumePdfBlob).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
view.rerender(
|
||||||
|
<PdfViewer
|
||||||
|
data={sampleResumeData}
|
||||||
|
stylesheetMode="semantic"
|
||||||
|
styleProjection={{ ...mismatchedProjection, renderDataHash: "1".repeat(64) }}
|
||||||
|
refetchStyleProjection={refetchStyleProjection}
|
||||||
|
publicResume={{ username: "amruth", slug: "sample" }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(() => expect(pdfViewerMock.fetch).toHaveBeenCalledTimes(2));
|
||||||
|
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
import type { 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 type { PDFDocumentLoadingTask, PDFDocumentProxy } from "pdfjs-dist/legacy/build/pdf.mjs";
|
||||||
import { AnnotationMode, GlobalWorkerOptions, getDocument } 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";
|
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 { Spinner } from "@reactive-resume/ui/components/spinner";
|
||||||
import { cn } from "@reactive-resume/utils/style";
|
import { cn } from "@reactive-resume/utils/style";
|
||||||
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||||
|
import { resolvePublicResumePdfBlob } from "./public-pdf";
|
||||||
import "pdfjs-dist/legacy/web/pdf_viewer.css";
|
import "pdfjs-dist/legacy/web/pdf_viewer.css";
|
||||||
import "./pdf-viewer.css";
|
import "./pdf-viewer.css";
|
||||||
|
|
||||||
@@ -14,6 +17,13 @@ GlobalWorkerOptions.workerSrc = new URL("pdfjs-dist/legacy/build/pdf.worker.min.
|
|||||||
type PdfViewerProps = {
|
type PdfViewerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
data: ResumeData;
|
data: ResumeData;
|
||||||
|
stylesheetMode?: SemanticStylesheet["mode"];
|
||||||
|
styleProjection?: PublicStyleProjection;
|
||||||
|
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||||
|
publicResume?: {
|
||||||
|
username: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
|
type PdfViewerOptions = ConstructorParameters<typeof PDFViewer>[0] & {
|
||||||
@@ -67,11 +77,21 @@ function pdfViewerReducer(state: PdfViewerState, action: PdfViewerAction): PdfVi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PdfViewer({ className, data }: PdfViewerProps) {
|
export function PdfViewer({
|
||||||
|
className,
|
||||||
|
data,
|
||||||
|
stylesheetMode,
|
||||||
|
styleProjection,
|
||||||
|
refetchStyleProjection,
|
||||||
|
publicResume,
|
||||||
|
}: PdfViewerProps) {
|
||||||
const rootRef = useRef<HTMLDivElement>(null);
|
const rootRef = useRef<HTMLDivElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const viewerRef = useRef<HTMLDivElement>(null);
|
const viewerRef = useRef<HTMLDivElement>(null);
|
||||||
const fileRef = useRef<Blob | null>(null);
|
const fileRef = useRef<Blob | null>(null);
|
||||||
|
const projectionRetryRef = useRef<{ data?: ResumeData; publicKey?: string; retried: boolean }>({
|
||||||
|
retried: false,
|
||||||
|
});
|
||||||
const [{ error, fileVersion, isReady, viewerHeight }, dispatch] = useReducer(
|
const [{ error, fileVersion, isReady, viewerHeight }, dispatch] = useReducer(
|
||||||
pdfViewerReducer,
|
pdfViewerReducer,
|
||||||
INITIAL_PDF_VIEWER_STATE,
|
INITIAL_PDF_VIEWER_STATE,
|
||||||
@@ -83,7 +103,29 @@ export function PdfViewer({ className, data }: PdfViewerProps) {
|
|||||||
fileRef.current = null;
|
fileRef.current = null;
|
||||||
dispatch({ type: "resetForData" });
|
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) => {
|
.then((blob) => {
|
||||||
if (isCancelled) return;
|
if (isCancelled) return;
|
||||||
|
|
||||||
@@ -100,7 +142,7 @@ export function PdfViewer({ className, data }: PdfViewerProps) {
|
|||||||
return () => {
|
return () => {
|
||||||
isCancelled = true;
|
isCancelled = true;
|
||||||
};
|
};
|
||||||
}, [data]);
|
}, [data, publicResume, refetchStyleProjection, styleProjection, stylesheetMode]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void fileVersion;
|
void fileVersion;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createPublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
|
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
||||||
|
import { resolvePublicResumePdfBlob } from "./public-pdf";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
createResumePdfBlob: vi.fn(async () => new Blob(["local"], { type: "application/pdf" })),
|
||||||
|
fetch: vi.fn(async (_input: string | URL) => new Response(new Blob(["server"], { type: "application/pdf" }))),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resume/export/pdf-document", () => ({
|
||||||
|
createResumePdfBlob: mocks.createResumePdfBlob,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const publicResume = { username: "amruth", slug: "sample" };
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.createResumePdfBlob.mockClear();
|
||||||
|
mocks.fetch.mockClear();
|
||||||
|
vi.stubGlobal("fetch", mocks.fetch);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolvePublicResumePdfBlob", () => {
|
||||||
|
it("keeps legitimate legacy resumes on the local PDF path", async () => {
|
||||||
|
await resolvePublicResumePdfBlob({
|
||||||
|
data: sampleResumeData,
|
||||||
|
stylesheetMode: "legacy",
|
||||||
|
publicResume,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.createResumePdfBlob).toHaveBeenCalledWith(sampleResumeData);
|
||||||
|
expect(mocks.fetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the authorized server fallback when a semantic projection is unavailable", async () => {
|
||||||
|
const refetchStyleProjection = vi.fn().mockRejectedValue(new Error("projection unavailable"));
|
||||||
|
|
||||||
|
await resolvePublicResumePdfBlob({
|
||||||
|
data: sampleResumeData,
|
||||||
|
stylesheetMode: "semantic",
|
||||||
|
publicResume,
|
||||||
|
refetchStyleProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("/api/resumes/amruth/sample/pdf");
|
||||||
|
expect(String(mocks.fetch.mock.calls[0]?.[0])).toContain("reason=missing-projection");
|
||||||
|
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refetches a mismatched projection once before returning the authorized server blob", async () => {
|
||||||
|
const semanticData = structuredClone(sampleResumeData);
|
||||||
|
const source = { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" };
|
||||||
|
semanticData.metadata.stylesheet = { mode: "semantic", source, applied: source };
|
||||||
|
const projection = await createPublicStyleProjection({ data: semanticData });
|
||||||
|
const mismatchedProjection = { ...projection, renderDataHash: "0".repeat(64) };
|
||||||
|
const refetchStyleProjection = vi.fn(async () => mismatchedProjection);
|
||||||
|
|
||||||
|
const blob = await resolvePublicResumePdfBlob({
|
||||||
|
data: sampleResumeData,
|
||||||
|
stylesheetMode: "semantic",
|
||||||
|
styleProjection: mismatchedProjection,
|
||||||
|
publicResume,
|
||||||
|
refetchStyleProjection,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(refetchStyleProjection).toHaveBeenCalledTimes(1);
|
||||||
|
expect(mocks.fetch).toHaveBeenCalledTimes(1);
|
||||||
|
expect(await blob.text()).toBe("server");
|
||||||
|
expect(mocks.createResumePdfBlob).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
import {
|
||||||
|
getPublicStyleProjectionFingerprints,
|
||||||
|
PUBLIC_STYLE_PROJECTION_FORMAT_VERSION,
|
||||||
|
SEMANTIC_TREE_VERSION,
|
||||||
|
validatePublicStyleProjection,
|
||||||
|
} from "@reactive-resume/pdf/public-projection";
|
||||||
|
import { createResumePdfBlob } from "@/features/resume/export/pdf-document";
|
||||||
|
|
||||||
|
export type PublicResumePdfOptions = {
|
||||||
|
stylesheetMode: SemanticStylesheet["mode"];
|
||||||
|
styleProjection?: PublicStyleProjection;
|
||||||
|
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||||
|
publicResume: {
|
||||||
|
username: string;
|
||||||
|
slug: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type ProjectionMismatchReason =
|
||||||
|
| "format-version"
|
||||||
|
| "language-version"
|
||||||
|
| "semantic-tree-version"
|
||||||
|
| "registry-fingerprint"
|
||||||
|
| "adapter-fingerprint"
|
||||||
|
| "render-data-hash"
|
||||||
|
| "invalid-projection";
|
||||||
|
|
||||||
|
const projectionMismatchReason = async (
|
||||||
|
data: ResumeData,
|
||||||
|
projection: PublicStyleProjection,
|
||||||
|
): Promise<ProjectionMismatchReason | null> => {
|
||||||
|
if (projection.formatVersion !== PUBLIC_STYLE_PROJECTION_FORMAT_VERSION) return "format-version";
|
||||||
|
if (projection.languageVersion !== 1) return "language-version";
|
||||||
|
if (projection.semanticTreeVersion !== SEMANTIC_TREE_VERSION) return "semantic-tree-version";
|
||||||
|
const fingerprints = await getPublicStyleProjectionFingerprints();
|
||||||
|
if (projection.registryFingerprint !== fingerprints.registryFingerprint) return "registry-fingerprint";
|
||||||
|
if (projection.adapterFingerprint !== fingerprints.adapterFingerprint) return "adapter-fingerprint";
|
||||||
|
return (await validatePublicStyleProjection(data, projection)) ? null : "render-data-hash";
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchPublicResumePdf = async (
|
||||||
|
publicResume: PublicResumePdfOptions["publicResume"],
|
||||||
|
reason: ProjectionMismatchReason | "missing-projection",
|
||||||
|
projection?: PublicStyleProjection,
|
||||||
|
) => {
|
||||||
|
const search = new URLSearchParams({
|
||||||
|
reason,
|
||||||
|
...(projection
|
||||||
|
? {
|
||||||
|
registryFingerprint: projection.registryFingerprint,
|
||||||
|
adapterFingerprint: projection.adapterFingerprint,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
const response = await fetch(
|
||||||
|
`/api/resumes/${encodeURIComponent(publicResume.username)}/${encodeURIComponent(publicResume.slug)}/pdf?${search}`,
|
||||||
|
{ credentials: "include" },
|
||||||
|
);
|
||||||
|
if (!response.ok) throw new Error(`Public PDF fallback failed with ${response.status}`);
|
||||||
|
return response.blob();
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function resolvePublicResumePdfBlob({
|
||||||
|
data,
|
||||||
|
...options
|
||||||
|
}: PublicResumePdfOptions & { data: ResumeData }): Promise<Blob> {
|
||||||
|
if (options.stylesheetMode === "legacy") return createResumePdfBlob(data);
|
||||||
|
|
||||||
|
let projection = options.styleProjection;
|
||||||
|
let refetched = false;
|
||||||
|
const refetch = async () => {
|
||||||
|
if (!options.refetchStyleProjection || refetched) return;
|
||||||
|
refetched = true;
|
||||||
|
try {
|
||||||
|
projection = await options.refetchStyleProjection();
|
||||||
|
} catch {
|
||||||
|
projection = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projection) await refetch();
|
||||||
|
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
|
||||||
|
|
||||||
|
let reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
|
||||||
|
if (reason) {
|
||||||
|
await refetch();
|
||||||
|
if (!projection) return fetchPublicResumePdf(options.publicResume, "missing-projection");
|
||||||
|
reason = await projectionMismatchReason(data, projection).catch(() => "invalid-projection" as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason
|
||||||
|
? fetchPublicResumePdf(options.publicResume, reason, projection)
|
||||||
|
: createResumePdfBlob(data, undefined, undefined, { publicStyleProjection: projection });
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
// @vitest-environment happy-dom
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import type { PublicStyleProjection } from "@reactive-resume/pdf/public-projection";
|
||||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { render, screen } from "@testing-library/react";
|
import { render, screen } from "@testing-library/react";
|
||||||
@@ -11,24 +12,46 @@ import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
|
|||||||
type PdfViewerProps = {
|
type PdfViewerProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
data: ResumeData;
|
data: ResumeData;
|
||||||
|
stylesheetMode?: "legacy" | "semantic";
|
||||||
|
styleProjection?: PublicStyleProjection;
|
||||||
|
refetchStyleProjection?: () => Promise<PublicStyleProjection | undefined>;
|
||||||
|
publicResume?: { username: string; slug: string };
|
||||||
};
|
};
|
||||||
|
|
||||||
const publicResumeMock = vi.hoisted(() => ({
|
const publicResumeMock = vi.hoisted(() => ({
|
||||||
createResumePdfBlob: vi.fn(async () => new Blob(["%PDF"], { type: "application/pdf" })),
|
onDownloadPDF: vi.fn(),
|
||||||
downloadWithAnchor: vi.fn(),
|
|
||||||
generateFilename: vi.fn((name: string, extension: string) => `${name}.${extension}`),
|
|
||||||
PdfViewer: vi.fn<(_props: PdfViewerProps) => ReactNode>(() => null),
|
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
|
resume: undefined as
|
||||||
| undefined
|
| undefined
|
||||||
| {
|
| {
|
||||||
data: ResumeData;
|
data: ResumeData;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
stylesheetMode: "legacy" | "semantic";
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tanstack/react-query", () => ({
|
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", () => ({
|
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", () => ({
|
vi.mock("./pdf-viewer", () => ({
|
||||||
PdfViewer: publicResumeMock.PdfViewer,
|
PdfViewer: publicResumeMock.PdfViewer,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/libs/orpc/client", () => ({
|
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", () => ({
|
vi.mock("@/features/resume/export/use-resume-export", () => ({
|
||||||
createResumePdfBlob: publicResumeMock.createResumePdfBlob,
|
useResumeExport: publicResumeMock.useResumeExport,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { PublicResumeRoute } = await import("./public-resume");
|
const { PublicResumeRoute } = await import("./public-resume");
|
||||||
@@ -65,8 +88,21 @@ beforeEach(() => {
|
|||||||
data: sampleResumeData,
|
data: sampleResumeData,
|
||||||
name: "Sample Resume",
|
name: "Sample Resume",
|
||||||
slug: "sample",
|
slug: "sample",
|
||||||
|
stylesheetMode: "semantic",
|
||||||
|
};
|
||||||
|
publicResumeMock.projectionResult = {
|
||||||
|
data: publicResumeMock.projection,
|
||||||
|
isError: false,
|
||||||
|
isPending: false,
|
||||||
};
|
};
|
||||||
publicResumeMock.PdfViewer.mockClear();
|
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 }) => (
|
publicResumeMock.PdfViewer.mockImplementation(({ className }) => (
|
||||||
<div className={className} data-testid="pdf-viewer" />
|
<div className={className} data-testid="pdf-viewer" />
|
||||||
));
|
));
|
||||||
@@ -88,6 +124,62 @@ describe("PublicResumeRoute", () => {
|
|||||||
expect.objectContaining({ data: sampleResumeData }),
|
expect.objectContaining({ data: sampleResumeData }),
|
||||||
undefined,
|
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", () => {
|
it("lets the public resume page grow to the full PDF length", () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { Trans } from "@lingui/react/macro";
|
|||||||
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
import { CircleNotchIcon, DownloadSimpleIcon } from "@phosphor-icons/react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { getRouteApi } from "@tanstack/react-router";
|
import { getRouteApi } from "@tanstack/react-router";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
import { BrandIcon } from "@reactive-resume/ui/components/brand-icon";
|
||||||
import { Button } from "@reactive-resume/ui/components/button";
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
import { LoadingScreen } from "@/components/layout/loading-screen";
|
import { LoadingScreen } from "@/components/layout/loading-screen";
|
||||||
@@ -16,9 +17,30 @@ export function PublicResumeRoute() {
|
|||||||
const { username, slug } = publicResumeRoute.useParams();
|
const { username, slug } = publicResumeRoute.useParams();
|
||||||
|
|
||||||
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
const { data: resume } = useQuery(orpc.resume.getBySlug.queryOptions({ input: { username, slug } }));
|
||||||
const { onDownloadPDF, isExporting } = useResumeExport(resume);
|
const projectionQuery = useQuery(
|
||||||
|
orpc.resume.getStyleProjection.queryOptions({ input: { username, slug }, enabled: resume !== undefined }),
|
||||||
|
);
|
||||||
|
const styleProjection =
|
||||||
|
projectionQuery.data && Object.keys(projectionQuery.data.nodes).length > 0 ? projectionQuery.data : undefined;
|
||||||
|
const publicResume = useMemo(() => ({ username, slug }), [slug, username]);
|
||||||
|
const refetchStyleProjection = useCallback(async () => {
|
||||||
|
const result = await projectionQuery.refetch();
|
||||||
|
return result.data;
|
||||||
|
}, [projectionQuery.refetch]);
|
||||||
|
const { onDownloadPDF, isExporting } = useResumeExport(resume, {
|
||||||
|
...(resume
|
||||||
|
? {
|
||||||
|
publicResumePdf: {
|
||||||
|
stylesheetMode: resume.stylesheetMode,
|
||||||
|
publicResume,
|
||||||
|
refetchStyleProjection,
|
||||||
|
...(styleProjection ? { styleProjection } : {}),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
});
|
||||||
|
|
||||||
if (!resume) return <LoadingScreen />;
|
if (!resume || projectionQuery.isPending) return <LoadingScreen />;
|
||||||
|
|
||||||
const { basics, picture } = resume.data;
|
const { basics, picture } = resume.data;
|
||||||
|
|
||||||
@@ -44,7 +66,14 @@ export function PublicResumeRoute() {
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="w-full max-w-5xl bg-white print:max-w-full">
|
<main className="w-full max-w-5xl bg-white print:max-w-full">
|
||||||
<PdfViewer data={resume.data} className="block w-full" />
|
<PdfViewer
|
||||||
|
data={resume.data}
|
||||||
|
className="block w-full"
|
||||||
|
stylesheetMode={resume.stylesheetMode}
|
||||||
|
styleProjection={styleProjection}
|
||||||
|
publicResume={publicResume}
|
||||||
|
refetchStyleProjection={refetchStyleProjection}
|
||||||
|
/>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<footer className="flex justify-center print:hidden">
|
<footer className="flex justify-center print:hidden">
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import type { StyleProgram } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet";
|
||||||
|
|
||||||
|
export type SemanticCssColorToken = {
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
value: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const colorValue =
|
||||||
|
/^(?:#[\da-f]{3,8}|(?:rgb|rgba|hsl|hsla)\([^)]*\)|(?:aqua|black|blue|currentcolor|fuchsia|gray|green|lime|maroon|navy|olive|orange|purple|red|silver|teal|transparent|white|yellow))$/i;
|
||||||
|
|
||||||
|
const isColorProperty = (property: string) =>
|
||||||
|
PROPERTY_REGISTRY_V1[property] !== undefined &&
|
||||||
|
(PROPERTY_REGISTRY_V1[property]?.category === "color" || property.endsWith("-color"));
|
||||||
|
|
||||||
|
export function collectCompiledColorTokens(
|
||||||
|
source: string,
|
||||||
|
program: StyleProgram | null,
|
||||||
|
): readonly SemanticCssColorToken[] {
|
||||||
|
if (!program) return [];
|
||||||
|
const tokens = new Map<string, SemanticCssColorToken>();
|
||||||
|
|
||||||
|
for (const rule of program.rules) {
|
||||||
|
for (const declaration of rule.declarations) {
|
||||||
|
if (!isColorProperty(declaration.property) || !colorValue.test(declaration.value)) continue;
|
||||||
|
const declarationSource = source.slice(declaration.range.start.offset, declaration.range.end.offset);
|
||||||
|
const valueOffset = declarationSource.indexOf(declaration.value, declarationSource.indexOf(":") + 1);
|
||||||
|
if (valueOffset < 0) continue;
|
||||||
|
const from = declaration.range.start.offset + valueOffset;
|
||||||
|
const token = { from, to: from + declaration.value.length, value: declaration.value };
|
||||||
|
tokens.set(`${token.from}:${token.to}`, token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...tokens.values()].sort((left, right) => left.from - right.from);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { Transaction } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { collectCompiledColorTokens } from "./color-tokens";
|
||||||
|
import {
|
||||||
|
compositionAwareDocumentListener,
|
||||||
|
copySourceToClipboard,
|
||||||
|
createSemanticCssEditorExtensions,
|
||||||
|
getSemanticCssCompletionLabels,
|
||||||
|
getSemanticCssHoverDocumentation,
|
||||||
|
mapCompilerDiagnostics,
|
||||||
|
} from "./editor-extensions";
|
||||||
|
|
||||||
|
const semanticTree: SemanticNode = {
|
||||||
|
key: "resume",
|
||||||
|
kind: "resume",
|
||||||
|
attributes: { template: "onyx" },
|
||||||
|
roles: [],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "section",
|
||||||
|
kind: "section",
|
||||||
|
id: "section-experience",
|
||||||
|
attributes: { type: "experience", placement: "main" },
|
||||||
|
roles: [],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "item",
|
||||||
|
kind: "item",
|
||||||
|
id: "item-current",
|
||||||
|
attributes: {},
|
||||||
|
roles: [],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "field",
|
||||||
|
kind: "field",
|
||||||
|
attributes: { name: "company" },
|
||||||
|
roles: ["primary-text"],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const metadata = {
|
||||||
|
semanticTree,
|
||||||
|
templateParts: ["timeline-line", "timeline-marker"],
|
||||||
|
} as const;
|
||||||
|
const borderShorthands = ["border", "border-top", "border-right", "border-bottom", "border-left"] as const;
|
||||||
|
|
||||||
|
const views: EditorView[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const view of views.splice(0)) view.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Semantic CSS editor extensions", () => {
|
||||||
|
it("uses only Semantic CSS registries and the current resume for completion", async () => {
|
||||||
|
const selectorLabels = await getSemanticCssCompletionLabels("", 0, metadata);
|
||||||
|
const propertyLabels = await getSemanticCssCompletionLabels("section {\n\tco", 13, metadata);
|
||||||
|
const variableSource = "resume { --brand-accent: #f00; color: var(--br";
|
||||||
|
const variableLabels = await getSemanticCssCompletionLabels(variableSource, variableSource.length, metadata);
|
||||||
|
const systemLabels = await getSemanticCssCompletionLabels("--resume-", 5, metadata);
|
||||||
|
const directiveLabels = await getSemanticCssCompletionLabels("@", 1, metadata);
|
||||||
|
|
||||||
|
expect(selectorLabels).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"section",
|
||||||
|
"#section-experience",
|
||||||
|
"#item-current",
|
||||||
|
'[name="company"]',
|
||||||
|
'[role~="primary-text"]',
|
||||||
|
'template-part[name="timeline-marker"]',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(propertyLabels).toContain("color");
|
||||||
|
expect(propertyLabels).toContain("-resume-fixed");
|
||||||
|
expect(propertyLabels).not.toContain("cursor");
|
||||||
|
expect(propertyLabels).not.toContain("font-family");
|
||||||
|
expect(variableLabels).toEqual(expect.arrayContaining(["--brand-accent", "--resume-primary-color"]));
|
||||||
|
expect(systemLabels).toEqual(expect.arrayContaining(["--resume-primary-color", "--resume-sidebar-width"]));
|
||||||
|
expect(systemLabels).not.toContain("--resume-font-family");
|
||||||
|
expect(directiveLabels).toEqual(expect.arrayContaining(["@media", "@version 1;"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers only the current property's registered compiler vocabulary", () => {
|
||||||
|
const displaySource = "section { display: f";
|
||||||
|
const borderStyleSource = "section { border-style: d";
|
||||||
|
const fontSizeSource = "section { font-size: 1";
|
||||||
|
|
||||||
|
const displayLabels = getSemanticCssCompletionLabels(displaySource, displaySource.length, metadata);
|
||||||
|
const borderStyleLabels = getSemanticCssCompletionLabels(borderStyleSource, borderStyleSource.length, metadata);
|
||||||
|
const fontSizeLabels = getSemanticCssCompletionLabels(fontSizeSource, fontSizeSource.length, metadata);
|
||||||
|
|
||||||
|
expect(displayLabels).toEqual(expect.arrayContaining(["flex", "none", "inherit"]));
|
||||||
|
expect(displayLabels).not.toEqual(expect.arrayContaining(["portrait", "dashed", "pt"]));
|
||||||
|
expect(borderStyleLabels).toEqual(expect.arrayContaining(["dashed", "dotted", "solid"]));
|
||||||
|
expect(borderStyleLabels).not.toContain("double");
|
||||||
|
expect(fontSizeLabels).toEqual(expect.arrayContaining(["pt", "rem"]));
|
||||||
|
expect(fontSizeLabels).not.toEqual(expect.arrayContaining(["none", "normal", "max-content"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(borderShorthands)("offers complete %s shorthand values instead of bare units", (property) => {
|
||||||
|
const source = `section { ${property}: `;
|
||||||
|
const labels = getSemanticCssCompletionLabels(source, source.length, metadata);
|
||||||
|
|
||||||
|
expect(labels).toEqual(expect.arrayContaining(["1pt dotted", "1pt dashed", "1pt solid"]));
|
||||||
|
expect(labels).not.toEqual(expect.arrayContaining(["pt", "px", "in", "mm", "cm", "%", "vw", "vh", "em", "rem"]));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escapes dynamic IDs and attribute values before inserting selectors", () => {
|
||||||
|
const unsafeMetadata = {
|
||||||
|
semanticTree: {
|
||||||
|
...semanticTree,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
key: "unsafe",
|
||||||
|
kind: "field",
|
||||||
|
id: "123 current#item",
|
||||||
|
attributes: { name: 'company"lead\n' },
|
||||||
|
roles: [],
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
templateParts: ['timeline"marker\n'],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const labels = getSemanticCssCompletionLabels("", 0, unsafeMetadata);
|
||||||
|
|
||||||
|
expect(labels).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
"#\\31 23\\ current\\#item",
|
||||||
|
'[name="company\\"lead\\a "]',
|
||||||
|
'template-part[name="timeline\\"marker\\a "]',
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(labels).not.toContain("#123 current#item");
|
||||||
|
expect(labels).not.toContain('[name="company"lead\n"]');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("builds hover text from the same registries", () => {
|
||||||
|
expect(getSemanticCssHoverDocumentation("section", metadata)).toMatch(
|
||||||
|
/semantic element.*placement.*featured-summary/i,
|
||||||
|
);
|
||||||
|
const colorDocumentation = getSemanticCssHoverDocumentation("color", metadata);
|
||||||
|
expect(colorDocumentation).toMatch(/property.*inherited.*field/i);
|
||||||
|
expect(colorDocumentation?.match(/section-heading/g)).toHaveLength(1);
|
||||||
|
expect(getSemanticCssHoverDocumentation("--resume-primary-color", metadata)).toMatch(
|
||||||
|
/read-only.*builder primary color/i,
|
||||||
|
);
|
||||||
|
expect(getSemanticCssHoverDocumentation("#section-experience", metadata)).toMatch(/current resume.*section/i);
|
||||||
|
expect(getSemanticCssHoverDocumentation('template-part[name="timeline-line"]', metadata)).toMatch(
|
||||||
|
/current template part/i,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps compiler offsets and only decorates compiler-confirmed color values", () => {
|
||||||
|
const source = "@version 1;\nsection { color: #ff0000; background-color: rgb(0 0 0); }\n";
|
||||||
|
const compiled = compileStylesheet({ languageVersion: 1, text: source });
|
||||||
|
expect(compiled.program).not.toBeNull();
|
||||||
|
const tokens = collectCompiledColorTokens(source, compiled.program);
|
||||||
|
expect(tokens).toEqual([
|
||||||
|
{ from: source.indexOf("#ff0000"), to: source.indexOf("#ff0000") + 7, value: "#ff0000" },
|
||||||
|
{
|
||||||
|
from: source.indexOf("rgb(0 0 0)"),
|
||||||
|
to: source.indexOf("rgb(0 0 0)") + "rgb(0 0 0)".length,
|
||||||
|
value: "rgb(0 0 0)",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diagnostic: SemanticCssDiagnostic = {
|
||||||
|
code: "INVALID_VALUE",
|
||||||
|
severity: "error",
|
||||||
|
message: "Bad value",
|
||||||
|
range: {
|
||||||
|
start: { line: 1, column: 1, offset: 2 },
|
||||||
|
end: { line: 1, column: 30, offset: 99 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
expect(mapCompilerDiagnostics(10, [diagnostic])).toEqual([
|
||||||
|
expect.objectContaining({ from: 2, to: 10, severity: "error", message: "Bad value" }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const selected = vi.fn();
|
||||||
|
const view = new EditorView({
|
||||||
|
doc: source,
|
||||||
|
extensions: createSemanticCssEditorExtensions({
|
||||||
|
metadata,
|
||||||
|
diagnostics: [],
|
||||||
|
colorTokens: tokens,
|
||||||
|
onColorSelect: selected,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
views.push(view);
|
||||||
|
const swatches = view.dom.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
|
||||||
|
expect(swatches).toHaveLength(2);
|
||||||
|
swatches[0]?.click();
|
||||||
|
expect(selected).toHaveBeenCalledWith(tokens[0], expect.any(DOMRect));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves exact clipboard text and emits one change for an IME composition", async () => {
|
||||||
|
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||||
|
Object.defineProperty(navigator, "clipboard", { configurable: true, value: { writeText } });
|
||||||
|
const source = "@version 1;\n/* exact spacing */\n";
|
||||||
|
await copySourceToClipboard(source);
|
||||||
|
expect(writeText).toHaveBeenCalledWith(source);
|
||||||
|
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const view = new EditorView({
|
||||||
|
doc: "",
|
||||||
|
extensions: compositionAwareDocumentListener(onChange),
|
||||||
|
});
|
||||||
|
views.push(view);
|
||||||
|
view.contentDOM.dispatchEvent(new CompositionEvent("compositionstart", { bubbles: true, data: "" }));
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, insert: "セク" },
|
||||||
|
annotations: Transaction.userEvent.of("input.type.compose"),
|
||||||
|
});
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: 2, insert: "セクション" },
|
||||||
|
annotations: Transaction.userEvent.of("input.type.compose"),
|
||||||
|
});
|
||||||
|
view.contentDOM.dispatchEvent(new CompositionEvent("compositionend", { bubbles: true, data: "セクション" }));
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(view.state.doc.toString()).toBe("セクション");
|
||||||
|
expect(onChange).toHaveBeenCalledOnce();
|
||||||
|
expect(onChange).toHaveBeenCalledWith("セクション");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens the built-in search and replace panel", () => {
|
||||||
|
const view = new EditorView({
|
||||||
|
doc: "section { color: red; }",
|
||||||
|
extensions: createSemanticCssEditorExtensions({
|
||||||
|
metadata,
|
||||||
|
diagnostics: [],
|
||||||
|
colorTokens: [],
|
||||||
|
onColorSelect: vi.fn(),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
views.push(view);
|
||||||
|
view.contentDOM.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, ctrlKey: true, key: "f" }));
|
||||||
|
|
||||||
|
expect(view.dom.querySelector("[name=search]")).not.toBeNull();
|
||||||
|
expect(view.dom.querySelector("[name=replace]")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,333 @@
|
|||||||
|
import type { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
|
||||||
|
import type { Diagnostic } from "@codemirror/lint";
|
||||||
|
import type { EditorState, Extension } from "@codemirror/state";
|
||||||
|
import type { DecorationSet, EditorView as EditorViewType, ViewUpdate } from "@codemirror/view";
|
||||||
|
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet/registry";
|
||||||
|
import type { SemanticCssColorToken } from "./color-tokens";
|
||||||
|
import type { SemanticCssEditorMetadata } from "./protocol";
|
||||||
|
import { autocompletion } from "@codemirror/autocomplete";
|
||||||
|
import { linter, lintGutter } from "@codemirror/lint";
|
||||||
|
import { search, searchKeymap } from "@codemirror/search";
|
||||||
|
import { Decoration, EditorView, hoverTooltip, keymap, ViewPlugin, WidgetType } from "@codemirror/view";
|
||||||
|
import {
|
||||||
|
escapeCssIdentifier,
|
||||||
|
escapeCssString,
|
||||||
|
PROPERTY_REGISTRY_V1,
|
||||||
|
SEMANTIC_NODE_KINDS,
|
||||||
|
SEMANTIC_REGISTRY_V1,
|
||||||
|
SYSTEM_VARIABLE_REGISTRY_V1,
|
||||||
|
} from "@reactive-resume/resume/stylesheet/registry";
|
||||||
|
|
||||||
|
export type SemanticCssColorSelection = (token: SemanticCssColorToken, rect: DOMRect) => void;
|
||||||
|
|
||||||
|
const directives = ["@media", "@version 1;"] as const;
|
||||||
|
|
||||||
|
function walk(root: SemanticNode): SemanticNode[] {
|
||||||
|
const nodes: SemanticNode[] = [];
|
||||||
|
const stack = [root];
|
||||||
|
while (stack.length > 0) {
|
||||||
|
const node = stack.pop();
|
||||||
|
if (!node) continue;
|
||||||
|
nodes.push(node);
|
||||||
|
stack.push(...node.children);
|
||||||
|
}
|
||||||
|
return nodes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unique(values: readonly string[]): string[] {
|
||||||
|
return [...new Set(values)];
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectorLabels(metadata: SemanticCssEditorMetadata): string[] {
|
||||||
|
const nodes = walk(metadata.semanticTree);
|
||||||
|
const attributes = unique([
|
||||||
|
"id",
|
||||||
|
"role",
|
||||||
|
...Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ attributes }) => attributes),
|
||||||
|
]);
|
||||||
|
const roles = unique(Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ roles }) => roles));
|
||||||
|
return unique([
|
||||||
|
...SEMANTIC_NODE_KINDS,
|
||||||
|
"*",
|
||||||
|
...nodes.flatMap((node) => (node.id ? [`#${escapeCssIdentifier(node.id)}`] : [])),
|
||||||
|
...attributes.map((attribute) => `[${escapeCssIdentifier(attribute)}]`),
|
||||||
|
...nodes.flatMap((node) =>
|
||||||
|
Object.entries(node.attributes).map(
|
||||||
|
([name, value]) => `[${escapeCssIdentifier(name)}=${escapeCssString(value)}]`,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
...roles.map((role) => `[role~=${escapeCssString(role)}]`),
|
||||||
|
...metadata.templateParts.map((name) => `template-part[name=${escapeCssString(name)}]`),
|
||||||
|
":root",
|
||||||
|
":first-child",
|
||||||
|
":last-child",
|
||||||
|
":only-child",
|
||||||
|
":nth-child()",
|
||||||
|
":nth-of-type()",
|
||||||
|
":is()",
|
||||||
|
":where()",
|
||||||
|
":not()",
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function userVariables(source: string): string[] {
|
||||||
|
return unique([...source.matchAll(/(--(?!resume-)[-_a-zA-Z0-9]+)\s*:/g)].map((match) => match[1] as string));
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionKind(source: string, position: number): "directive" | "property" | "selector" | "system" | "value" {
|
||||||
|
const before = source.slice(0, position);
|
||||||
|
if (/--resume-[-\w]*$/.test(before)) return "system";
|
||||||
|
if (/@[-\w]*$/.test(before)) return "directive";
|
||||||
|
const open = before.lastIndexOf("{");
|
||||||
|
const close = before.lastIndexOf("}");
|
||||||
|
if (open <= close) return "selector";
|
||||||
|
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
|
||||||
|
return declaration.includes(":") ? "value" : "property";
|
||||||
|
}
|
||||||
|
|
||||||
|
function declarationProperty(source: string, position: number): string | undefined {
|
||||||
|
const before = source.slice(0, position);
|
||||||
|
const open = before.lastIndexOf("{");
|
||||||
|
const close = before.lastIndexOf("}");
|
||||||
|
if (open <= close) return;
|
||||||
|
const declaration = before.slice(Math.max(open, before.lastIndexOf(";")) + 1);
|
||||||
|
const colon = declaration.indexOf(":");
|
||||||
|
if (colon < 0) return;
|
||||||
|
const property = declaration.slice(0, colon).trim().toLowerCase();
|
||||||
|
return property || undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionLabels(source: string, position: number, metadata: SemanticCssEditorMetadata): string[] {
|
||||||
|
switch (completionKind(source, position)) {
|
||||||
|
case "directive":
|
||||||
|
return [...directives];
|
||||||
|
case "property":
|
||||||
|
return Object.keys(PROPERTY_REGISTRY_V1);
|
||||||
|
case "selector":
|
||||||
|
return selectorLabels(metadata);
|
||||||
|
case "system":
|
||||||
|
return Object.keys(SYSTEM_VARIABLE_REGISTRY_V1);
|
||||||
|
case "value": {
|
||||||
|
const property = declarationProperty(source, position);
|
||||||
|
const definition = property ? PROPERTY_REGISTRY_V1[property] : undefined;
|
||||||
|
return unique([
|
||||||
|
...(definition?.values ?? []),
|
||||||
|
...(definition?.units ?? []),
|
||||||
|
...userVariables(source),
|
||||||
|
...Object.keys(SYSTEM_VARIABLE_REGISTRY_V1),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSemanticCssCompletionLabels(
|
||||||
|
source: string,
|
||||||
|
position: number,
|
||||||
|
metadata: SemanticCssEditorMetadata,
|
||||||
|
): readonly string[] {
|
||||||
|
return completionLabels(source, position, metadata);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getSemanticCssHoverDocumentation(
|
||||||
|
label: string,
|
||||||
|
metadata: SemanticCssEditorMetadata,
|
||||||
|
): string | undefined {
|
||||||
|
const semantic = SEMANTIC_REGISTRY_V1[label as keyof typeof SEMANTIC_REGISTRY_V1];
|
||||||
|
if (semantic) {
|
||||||
|
return `Semantic element ${label}. Attributes: ${semantic.attributes.join(", ") || "none"}. Roles: ${semantic.roles.join(", ") || "none"}.`;
|
||||||
|
}
|
||||||
|
const property = PROPERTY_REGISTRY_V1[label];
|
||||||
|
if (property) {
|
||||||
|
return `Semantic CSS ${property.category} property ${label}. ${property.inheritable ? "Inherited" : "Not inherited"}. Applies to: ${property.appliesTo.join(", ")}.`;
|
||||||
|
}
|
||||||
|
const systemVariable = SYSTEM_VARIABLE_REGISTRY_V1[label as keyof typeof SYSTEM_VARIABLE_REGISTRY_V1];
|
||||||
|
if (systemVariable) return `Read-only Semantic CSS system variable. ${systemVariable.description}`;
|
||||||
|
const normalized = label.startsWith("#") ? label.slice(1) : label;
|
||||||
|
const currentNode = walk(metadata.semanticTree).find((node) => node.id === normalized);
|
||||||
|
if (currentNode) return `Current resume ${currentNode.kind} ID.`;
|
||||||
|
const part = label.match(/^template-part\[name="(.+)"\]$/)?.[1] ?? label;
|
||||||
|
if (metadata.templateParts.includes(part)) return `Current template part ${part}.`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function mapCompilerDiagnostics(
|
||||||
|
docLength: number,
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[],
|
||||||
|
): readonly Diagnostic[] {
|
||||||
|
return diagnostics.map(({ message, severity, range, code }) => ({
|
||||||
|
from: Math.max(0, Math.min(docLength, range.start.offset)),
|
||||||
|
to: Math.max(0, Math.min(docLength, Math.max(range.start.offset, range.end.offset))),
|
||||||
|
severity,
|
||||||
|
message,
|
||||||
|
source: code,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compositionAwareDocumentListener(
|
||||||
|
onChange: (source: string) => void,
|
||||||
|
ignore?: (update: ViewUpdate) => boolean,
|
||||||
|
): Extension {
|
||||||
|
let composing = false;
|
||||||
|
return [
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
compositionstart: () => {
|
||||||
|
composing = true;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
compositionend: (_event, view) => {
|
||||||
|
composing = false;
|
||||||
|
queueMicrotask(() => onChange(view.state.doc.toString()));
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged && !composing && !ignore?.(update)) onChange(update.state.doc.toString());
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function completionSource(metadata: SemanticCssEditorMetadata): CompletionSource {
|
||||||
|
return (context: CompletionContext): CompletionResult | null => {
|
||||||
|
const source = context.state.doc.toString();
|
||||||
|
const labels = completionLabels(source, context.pos, metadata);
|
||||||
|
const word = context.matchBefore(/(?:--|[-@#])?[-_a-zA-Z0-9]*$/);
|
||||||
|
if (!context.explicit && (!word || word.from === word.to)) return null;
|
||||||
|
const options: Completion[] = labels.map((label) => ({
|
||||||
|
label,
|
||||||
|
type: label.startsWith("@") ? "keyword" : label.startsWith("#") || label.includes("[") ? "text" : "property",
|
||||||
|
}));
|
||||||
|
return { from: word?.from ?? context.pos, options, validFor: /[-_@#a-zA-Z0-9]*/ };
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function tokenAt(state: EditorState, position: number): { from: number; to: number; label: string } | undefined {
|
||||||
|
const line = state.doc.lineAt(position);
|
||||||
|
const before = line.text.slice(0, position - line.from).match(/(?:--|[#@])?[-_a-zA-Z0-9]+$/)?.[0] ?? "";
|
||||||
|
const after = line.text.slice(position - line.from).match(/^[-_a-zA-Z0-9]+/)?.[0] ?? "";
|
||||||
|
if (!before && !after) return;
|
||||||
|
const from = position - before.length;
|
||||||
|
return { from, to: position + after.length, label: `${before}${after}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hoverExtension(metadata: SemanticCssEditorMetadata): Extension {
|
||||||
|
return hoverTooltip((view, position) => {
|
||||||
|
const token = tokenAt(view.state, position);
|
||||||
|
if (!token) return null;
|
||||||
|
const documentation = getSemanticCssHoverDocumentation(token.label, metadata);
|
||||||
|
if (!documentation) return null;
|
||||||
|
return {
|
||||||
|
pos: token.from,
|
||||||
|
end: token.to,
|
||||||
|
above: true,
|
||||||
|
create() {
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
dom.className = "cm-semantic-css-hover";
|
||||||
|
dom.textContent = documentation;
|
||||||
|
return { dom };
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class ColorSwatch extends WidgetType {
|
||||||
|
constructor(
|
||||||
|
private readonly token: SemanticCssColorToken,
|
||||||
|
private readonly onSelect: SemanticCssColorSelection,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
eq(other: ColorSwatch): boolean {
|
||||||
|
return (
|
||||||
|
other.token.from === this.token.from && other.token.to === this.token.to && other.token.value === this.token.value
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
toDOM(): HTMLElement {
|
||||||
|
const button = document.createElement("button");
|
||||||
|
button.type = "button";
|
||||||
|
button.className = "semantic-css-color-swatch";
|
||||||
|
button.title = `Edit color ${this.token.value}`;
|
||||||
|
button.setAttribute("aria-label", button.title);
|
||||||
|
button.style.backgroundColor = this.token.value;
|
||||||
|
button.addEventListener("click", () => this.onSelect(this.token, button.getBoundingClientRect()));
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
ignoreEvent(): boolean {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorDecorations(
|
||||||
|
view: EditorViewType,
|
||||||
|
tokens: readonly SemanticCssColorToken[],
|
||||||
|
onSelect: SemanticCssColorSelection,
|
||||||
|
): DecorationSet {
|
||||||
|
const ranges = tokens
|
||||||
|
.filter(
|
||||||
|
(token) =>
|
||||||
|
token.from >= 0 &&
|
||||||
|
token.to <= view.state.doc.length &&
|
||||||
|
view.visibleRanges.some(({ from, to }) => token.to >= from && token.from <= to),
|
||||||
|
)
|
||||||
|
.map((token) => Decoration.widget({ widget: new ColorSwatch(token, onSelect), side: 1 }).range(token.to));
|
||||||
|
return Decoration.set(ranges, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function colorExtension(tokens: readonly SemanticCssColorToken[], onSelect: SemanticCssColorSelection): Extension {
|
||||||
|
return [
|
||||||
|
EditorView.baseTheme({
|
||||||
|
".semantic-css-color-swatch": {
|
||||||
|
display: "inline-block",
|
||||||
|
width: "0.75rem",
|
||||||
|
height: "0.75rem",
|
||||||
|
marginInline: "0.25rem",
|
||||||
|
padding: "0",
|
||||||
|
verticalAlign: "middle",
|
||||||
|
border: "1px solid currentColor",
|
||||||
|
borderRadius: "9999px",
|
||||||
|
cursor: "pointer",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ViewPlugin.fromClass(
|
||||||
|
class {
|
||||||
|
decorations: DecorationSet;
|
||||||
|
|
||||||
|
constructor(view: EditorViewType) {
|
||||||
|
this.decorations = colorDecorations(view, tokens, onSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(update: ViewUpdate) {
|
||||||
|
if (update.docChanged || update.viewportChanged) {
|
||||||
|
this.decorations = colorDecorations(update.view, tokens, onSelect);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ decorations: (plugin) => plugin.decorations },
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSemanticCssEditorExtensions(input: {
|
||||||
|
metadata: SemanticCssEditorMetadata;
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
colorTokens: readonly SemanticCssColorToken[];
|
||||||
|
onColorSelect: SemanticCssColorSelection;
|
||||||
|
}): Extension {
|
||||||
|
return [
|
||||||
|
autocompletion({ override: [completionSource(input.metadata)] }),
|
||||||
|
hoverExtension(input.metadata),
|
||||||
|
search({ top: true }),
|
||||||
|
keymap.of(searchKeymap),
|
||||||
|
lintGutter(),
|
||||||
|
linter((view) => mapCompilerDiagnostics(view.state.doc.length, input.diagnostics), { delay: 0 }),
|
||||||
|
colorExtension(input.colorTokens, input.onColorSelect),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function copySourceToClipboard(source: string): Promise<void> {
|
||||||
|
await navigator.clipboard.writeText(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { SemanticCssEditorMetadata };
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { i18n } from "@lingui/core";
|
||||||
|
import { I18nProvider } from "@lingui/react";
|
||||||
|
import { TooltipProvider } from "@reactive-resume/ui/components/tooltip";
|
||||||
|
import StylesheetEditorShell, { StylesheetCodeEditor } from "./editor";
|
||||||
|
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||||
|
import { StylesheetStatus } from "./status";
|
||||||
|
import { useStylesheetStore } from "./store";
|
||||||
|
|
||||||
|
const media = vi.hoisted(() => ({ mobile: false }));
|
||||||
|
|
||||||
|
vi.mock("usehooks-ts", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("usehooks-ts")>()),
|
||||||
|
useMediaQuery: () => media.mobile,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/theme/provider", () => ({
|
||||||
|
useTheme: () => ({ theme: "light" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const error: SemanticCssDiagnostic = {
|
||||||
|
code: "SEMANTIC_CSS_UNKNOWN_PROPERTY",
|
||||||
|
severity: "error",
|
||||||
|
message: "Unknown property",
|
||||||
|
range: {
|
||||||
|
start: { line: 2, column: 3, offset: 17 },
|
||||||
|
end: { line: 2, column: 9, offset: 23 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const guideName = /read the applying custom styles guide.*opens in new tab/i;
|
||||||
|
|
||||||
|
const expectGuideLink = (root: HTMLElement) => {
|
||||||
|
const link = within(root).getByRole("link", { name: guideName });
|
||||||
|
expect(link).toHaveAttribute("href", "https://docs.rxresu.me/applying-custom-styles");
|
||||||
|
expect(link).toHaveAttribute("target", "_blank");
|
||||||
|
expect(link).toHaveAttribute("rel", "noopener noreferrer");
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||||
|
Object.defineProperty(Element.prototype, "getAnimations", { configurable: true, value: () => [] });
|
||||||
|
});
|
||||||
|
|
||||||
|
const renderWithI18n = (element: React.ReactNode) => render(<I18nProvider i18n={i18n}>{element}</I18nProvider>);
|
||||||
|
|
||||||
|
describe("stylesheet editor status", () => {
|
||||||
|
it("shows that invalid source keeps the last valid preview", () => {
|
||||||
|
renderWithI18n(<StylesheetStatus mode="semantic" status="error" diagnostics={[error]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText(/preview and export use the last valid version/i)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Unknown property")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels a valid legacy draft as ready to activate", () => {
|
||||||
|
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Ready to activate")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Applied")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("labels legacy warnings without claiming they are applied", () => {
|
||||||
|
renderWithI18n(<StylesheetStatus mode="legacy" status="idle" diagnostics={[{ ...error, severity: "warning" }]} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Applied with warnings")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("disables activation while the converted draft has errors", () => {
|
||||||
|
renderWithI18n(<LegacyStylesheetBanner disabled onActivate={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /activate semantic css/i })).toBeDisabled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StylesheetCodeEditor", () => {
|
||||||
|
it("owns one LTR EditorView and ignores externally replaced documents", () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
const destroy = vi.spyOn(EditorView.prototype, "destroy");
|
||||||
|
const props = {
|
||||||
|
diagnostics: [] as const,
|
||||||
|
theme: "light" as const,
|
||||||
|
onChange,
|
||||||
|
onUndo: vi.fn(),
|
||||||
|
onRedo: vi.fn(),
|
||||||
|
};
|
||||||
|
const { container, rerender, unmount } = render(
|
||||||
|
<div style={{ height: 200 }}>
|
||||||
|
<StylesheetCodeEditor value="@version 1;\n" {...props} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||||
|
expect(container.querySelector(".cm-editor")).toHaveAttribute("dir", "ltr");
|
||||||
|
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute("dir", "ltr");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<div style={{ height: 200 }}>
|
||||||
|
<StylesheetCodeEditor value={"@version 1;\nsection { color: red; }\n"} {...props} />
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(onChange).not.toHaveBeenCalled();
|
||||||
|
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveTextContent("color: red");
|
||||||
|
|
||||||
|
rerender(
|
||||||
|
<div style={{ height: 200 }}>
|
||||||
|
<StylesheetCodeEditor
|
||||||
|
value={"@version 1;\nsection { color: red; }\n"}
|
||||||
|
{...props}
|
||||||
|
diagnostics={[error]}
|
||||||
|
theme="dark"
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
</div>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
|
||||||
|
"contenteditable",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".cm-gutter-lint")).toBeInTheDocument();
|
||||||
|
|
||||||
|
unmount();
|
||||||
|
expect(destroy).toHaveBeenCalledOnce();
|
||||||
|
destroy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reuses one React color picker for compiler-confirmed swatches", async () => {
|
||||||
|
const source = "section { color: #f00; background-color: #fff; }";
|
||||||
|
const first = source.indexOf("#f00");
|
||||||
|
const second = source.indexOf("#fff");
|
||||||
|
const { container } = render(
|
||||||
|
<StylesheetCodeEditor
|
||||||
|
value={source}
|
||||||
|
diagnostics={[]}
|
||||||
|
colorTokens={[
|
||||||
|
{ from: first, to: first + 4, value: "#f00" },
|
||||||
|
{ from: second, to: second + 4, value: "#fff" },
|
||||||
|
]}
|
||||||
|
theme="light"
|
||||||
|
onChange={vi.fn()}
|
||||||
|
onUndo={vi.fn()}
|
||||||
|
onRedo={vi.fn()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const swatches = container.querySelectorAll<HTMLButtonElement>(".semantic-css-color-swatch");
|
||||||
|
expect(swatches).toHaveLength(2);
|
||||||
|
|
||||||
|
swatches[0]?.click();
|
||||||
|
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||||
|
swatches[1]?.click();
|
||||||
|
await waitFor(() => expect(container.querySelectorAll("[data-semantic-css-color-picker-trigger]")).toHaveLength(1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("StylesheetEditorShell", () => {
|
||||||
|
it("links desktop editor help to the Semantic CSS language reference", () => {
|
||||||
|
media.mobile = false;
|
||||||
|
const { container } = render(
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<StylesheetEditorShell />
|
||||||
|
</TooltipProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expectGuideLink(container);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("makes the editor and mutation controls read-only while a restore is pending", () => {
|
||||||
|
media.mobile = false;
|
||||||
|
useStylesheetStore.setState({
|
||||||
|
mode: "legacy",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
diagnostics: [],
|
||||||
|
status: "idle",
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: true,
|
||||||
|
restoreLocked: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<StylesheetEditorShell />
|
||||||
|
</TooltipProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(screen.getByRole("textbox", { name: "Semantic CSS stylesheet" })).toHaveAttribute(
|
||||||
|
"contenteditable",
|
||||||
|
"false",
|
||||||
|
);
|
||||||
|
expect(screen.getByRole("button", { name: "Activate Semantic CSS" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Undo stylesheet edit" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Redo stylesheet edit" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Format stylesheet" })).toBeDisabled();
|
||||||
|
expect(screen.getByRole("button", { name: "Reset to applied stylesheet" })).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the only visible editor into a titled mobile sheet", async () => {
|
||||||
|
media.mobile = true;
|
||||||
|
useStylesheetStore.setState({
|
||||||
|
mode: "legacy",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
diagnostics: [{ ...error, severity: "warning" }],
|
||||||
|
status: "idle",
|
||||||
|
restoreLocked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { container } = render(
|
||||||
|
<I18nProvider i18n={i18n}>
|
||||||
|
<TooltipProvider>
|
||||||
|
<StylesheetEditorShell />
|
||||||
|
</TooltipProvider>
|
||||||
|
</I18nProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(container.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Open focus mode" }));
|
||||||
|
|
||||||
|
const sheet = await screen.findByRole("dialog");
|
||||||
|
expect(within(sheet).getByRole("heading", { name: "Semantic CSS stylesheet" })).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByRole("button", { name: "Activate Semantic CSS" })).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByRole("toolbar", { name: "Stylesheet editor" })).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("Ready to activate with warnings")).toBeInTheDocument();
|
||||||
|
expect(within(sheet).getByText("Unknown property")).toBeInTheDocument();
|
||||||
|
expectGuideLink(sheet);
|
||||||
|
expect(document.querySelectorAll(".cm-editor")).toHaveLength(1);
|
||||||
|
media.mobile = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,455 @@
|
|||||||
|
import type { Extension } from "@codemirror/state";
|
||||||
|
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import type { SemanticCssColorToken } from "./color-tokens";
|
||||||
|
import type { SemanticCssEditorMetadata } from "./protocol";
|
||||||
|
import { defaultKeymap, indentWithTab } from "@codemirror/commands";
|
||||||
|
import { css } from "@codemirror/lang-css";
|
||||||
|
import { defaultHighlightStyle, syntaxHighlighting } from "@codemirror/language";
|
||||||
|
import { Annotation, Compartment, EditorState, Prec, Transaction } from "@codemirror/state";
|
||||||
|
import {
|
||||||
|
drawSelection,
|
||||||
|
EditorView,
|
||||||
|
highlightActiveLine,
|
||||||
|
highlightSpecialChars,
|
||||||
|
keymap,
|
||||||
|
lineNumbers,
|
||||||
|
} from "@codemirror/view";
|
||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { BookOpenIcon } from "@phosphor-icons/react";
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import { useMediaQuery } from "usehooks-ts";
|
||||||
|
import { PopoverTrigger } from "@reactive-resume/ui/components/popover";
|
||||||
|
import { Sheet, SheetContent, SheetTitle } from "@reactive-resume/ui/components/sheet";
|
||||||
|
import { ColorPicker } from "@/components/input/color-picker";
|
||||||
|
import { useTheme } from "@/features/theme/provider";
|
||||||
|
import { useBuilderSidebarStore } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||||
|
import { compositionAwareDocumentListener, createSemanticCssEditorExtensions } from "./editor-extensions";
|
||||||
|
import { enterStylesheetFocusMode } from "./focus-mode";
|
||||||
|
import { formatEditorDocument } from "./formatter";
|
||||||
|
import { LegacyStylesheetBanner } from "./legacy-banner";
|
||||||
|
import { StylesheetStatus } from "./status";
|
||||||
|
import { useStylesheetStore } from "./store";
|
||||||
|
import { StylesheetToolbar } from "./toolbar";
|
||||||
|
|
||||||
|
const externalReplacement = Annotation.define<boolean>();
|
||||||
|
const emptyMetadata: SemanticCssEditorMetadata = {
|
||||||
|
semanticTree: { key: "resume", kind: "resume", attributes: {}, roles: [], children: [] },
|
||||||
|
templateParts: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditorCompartments = {
|
||||||
|
theme: Compartment;
|
||||||
|
readOnly: Compartment;
|
||||||
|
intelligence: Compartment;
|
||||||
|
};
|
||||||
|
|
||||||
|
const editorTheme = (dark: boolean): Extension =>
|
||||||
|
EditorView.theme(
|
||||||
|
{
|
||||||
|
"&": {
|
||||||
|
height: "100%",
|
||||||
|
backgroundColor: "var(--background)",
|
||||||
|
color: "var(--foreground)",
|
||||||
|
direction: "ltr",
|
||||||
|
},
|
||||||
|
".cm-scroller": {
|
||||||
|
overflow: "auto",
|
||||||
|
fontFamily: "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace",
|
||||||
|
lineHeight: "1.5",
|
||||||
|
},
|
||||||
|
".cm-content": { minHeight: "100%", padding: "0.75rem 0" },
|
||||||
|
".cm-gutters": {
|
||||||
|
backgroundColor: "var(--muted)",
|
||||||
|
borderRight: "1px solid var(--border)",
|
||||||
|
},
|
||||||
|
".cm-activeLine, .cm-activeLineGutter": {
|
||||||
|
backgroundColor: "var(--accent)",
|
||||||
|
},
|
||||||
|
"&.cm-focused": { outline: "none" },
|
||||||
|
},
|
||||||
|
{ dark },
|
||||||
|
);
|
||||||
|
|
||||||
|
const readOnlyExtensions = (readOnly: boolean): Extension => [
|
||||||
|
EditorState.readOnly.of(readOnly),
|
||||||
|
EditorView.editable.of(!readOnly),
|
||||||
|
];
|
||||||
|
|
||||||
|
export type StylesheetCodeEditorProps = {
|
||||||
|
value: string;
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
colorTokens?: readonly SemanticCssColorToken[];
|
||||||
|
metadata?: SemanticCssEditorMetadata;
|
||||||
|
theme: "light" | "dark";
|
||||||
|
readOnly?: boolean;
|
||||||
|
label?: string;
|
||||||
|
onChange(value: string): void;
|
||||||
|
onFocusChange?(focused: boolean): void;
|
||||||
|
onReady?(view: EditorView | null): void;
|
||||||
|
onUndo(): void;
|
||||||
|
onRedo(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StylesheetCodeEditor({
|
||||||
|
value,
|
||||||
|
diagnostics,
|
||||||
|
colorTokens = [],
|
||||||
|
metadata = emptyMetadata,
|
||||||
|
theme,
|
||||||
|
readOnly = false,
|
||||||
|
label = "Semantic CSS stylesheet",
|
||||||
|
onChange,
|
||||||
|
onFocusChange,
|
||||||
|
onReady,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
}: StylesheetCodeEditorProps) {
|
||||||
|
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const viewRef = useRef<EditorView | null>(null);
|
||||||
|
const colorTriggerRef = useRef<HTMLButtonElement | null>(null);
|
||||||
|
const openColorPickerRef = useRef(false);
|
||||||
|
const compartmentsRef = useRef<EditorCompartments | null>(null);
|
||||||
|
const initialPropsRef = useRef({ value, diagnostics, colorTokens, metadata, theme, readOnly, label });
|
||||||
|
const onChangeRef = useRef(onChange);
|
||||||
|
const onFocusChangeRef = useRef(onFocusChange);
|
||||||
|
const onReadyRef = useRef(onReady);
|
||||||
|
const onUndoRef = useRef(onUndo);
|
||||||
|
const onRedoRef = useRef(onRedo);
|
||||||
|
const [selectedColor, setSelectedColor] = useState<{
|
||||||
|
token: SemanticCssColorToken;
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
} | null>(null);
|
||||||
|
const selectColor = useCallback((token: SemanticCssColorToken, rect: DOMRect) => {
|
||||||
|
const hostRect = hostRef.current?.getBoundingClientRect();
|
||||||
|
if (!hostRect) return;
|
||||||
|
openColorPickerRef.current = true;
|
||||||
|
setSelectedColor({ token, left: rect.left - hostRect.left, top: rect.top - hostRect.top });
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
onChangeRef.current = onChange;
|
||||||
|
onFocusChangeRef.current = onFocusChange;
|
||||||
|
onReadyRef.current = onReady;
|
||||||
|
onUndoRef.current = onUndo;
|
||||||
|
onRedoRef.current = onRedo;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const parent = hostRef.current;
|
||||||
|
if (!parent) return;
|
||||||
|
const initial = initialPropsRef.current;
|
||||||
|
|
||||||
|
const compartments: EditorCompartments = {
|
||||||
|
theme: new Compartment(),
|
||||||
|
readOnly: new Compartment(),
|
||||||
|
intelligence: new Compartment(),
|
||||||
|
};
|
||||||
|
compartmentsRef.current = compartments;
|
||||||
|
const view = new EditorView({
|
||||||
|
parent,
|
||||||
|
doc: initial.value,
|
||||||
|
extensions: [
|
||||||
|
lineNumbers(),
|
||||||
|
highlightSpecialChars(),
|
||||||
|
drawSelection(),
|
||||||
|
highlightActiveLine(),
|
||||||
|
css(),
|
||||||
|
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
|
||||||
|
EditorView.editorAttributes.of({ dir: "ltr" }),
|
||||||
|
EditorView.contentAttributes.of({ "aria-label": initial.label, dir: "ltr", spellcheck: "false" }),
|
||||||
|
Prec.high(
|
||||||
|
keymap.of([
|
||||||
|
{
|
||||||
|
key: "Mod-z",
|
||||||
|
run: () => {
|
||||||
|
onUndoRef.current();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-Shift-z",
|
||||||
|
run: () => {
|
||||||
|
onRedoRef.current();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "Mod-y",
|
||||||
|
run: () => {
|
||||||
|
onRedoRef.current();
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
keymap.of([indentWithTab, ...defaultKeymap]),
|
||||||
|
EditorView.domEventHandlers({
|
||||||
|
focus: () => {
|
||||||
|
onFocusChangeRef.current?.(true);
|
||||||
|
},
|
||||||
|
blur: () => {
|
||||||
|
onFocusChangeRef.current?.(false);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
compositionAwareDocumentListener(
|
||||||
|
(source) => onChangeRef.current(source),
|
||||||
|
(update) => update.transactions.some((transaction) => transaction.annotation(externalReplacement)),
|
||||||
|
),
|
||||||
|
compartments.theme.of(editorTheme(initial.theme === "dark")),
|
||||||
|
compartments.readOnly.of(readOnlyExtensions(initial.readOnly)),
|
||||||
|
compartments.intelligence.of(
|
||||||
|
createSemanticCssEditorExtensions({
|
||||||
|
metadata: initial.metadata,
|
||||||
|
diagnostics: initial.diagnostics,
|
||||||
|
colorTokens: initial.colorTokens,
|
||||||
|
onColorSelect: selectColor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
viewRef.current = view;
|
||||||
|
onReadyRef.current?.(view);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
onReadyRef.current?.(null);
|
||||||
|
view.destroy();
|
||||||
|
viewRef.current = null;
|
||||||
|
compartmentsRef.current = null;
|
||||||
|
};
|
||||||
|
}, [selectColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
const compartments = compartmentsRef.current;
|
||||||
|
if (!view || !compartments) return;
|
||||||
|
view.dispatch({ effects: compartments.theme.reconfigure(editorTheme(theme === "dark")) });
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
const compartments = compartmentsRef.current;
|
||||||
|
if (!view || !compartments) return;
|
||||||
|
view.dispatch({ effects: compartments.readOnly.reconfigure(readOnlyExtensions(readOnly)) });
|
||||||
|
}, [readOnly]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
const compartments = compartmentsRef.current;
|
||||||
|
if (!view || !compartments) return;
|
||||||
|
view.dispatch({
|
||||||
|
effects: compartments.intelligence.reconfigure(
|
||||||
|
createSemanticCssEditorExtensions({
|
||||||
|
metadata,
|
||||||
|
diagnostics,
|
||||||
|
colorTokens,
|
||||||
|
onColorSelect: selectColor,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}, [colorTokens, diagnostics, metadata, selectColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || view.state.doc.toString() === value) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: value },
|
||||||
|
annotations: externalReplacement.of(true),
|
||||||
|
});
|
||||||
|
}, [value]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!selectedColor || !openColorPickerRef.current) return;
|
||||||
|
openColorPickerRef.current = false;
|
||||||
|
queueMicrotask(() => colorTriggerRef.current?.click());
|
||||||
|
}, [selectedColor]);
|
||||||
|
|
||||||
|
const updateColor = (value: string) => {
|
||||||
|
const view = viewRef.current;
|
||||||
|
if (!view || !selectedColor) return;
|
||||||
|
const { from, to } = selectedColor.token;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from, to, insert: value },
|
||||||
|
annotations: Transaction.userEvent.of("input"),
|
||||||
|
});
|
||||||
|
setSelectedColor((current) =>
|
||||||
|
current
|
||||||
|
? {
|
||||||
|
...current,
|
||||||
|
token: { from, to: from + value.length, value },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={hostRef} className="relative h-full overflow-hidden rounded-md border text-xs" dir="ltr">
|
||||||
|
{selectedColor && (
|
||||||
|
<div className="pointer-events-none absolute z-20" style={{ left: selectedColor.left, top: selectedColor.top }}>
|
||||||
|
<ColorPicker
|
||||||
|
value={selectedColor.token.value}
|
||||||
|
onChange={updateColor}
|
||||||
|
trigger={
|
||||||
|
<PopoverTrigger
|
||||||
|
render={
|
||||||
|
<button
|
||||||
|
ref={colorTriggerRef}
|
||||||
|
data-semantic-css-color-picker-trigger=""
|
||||||
|
type="button"
|
||||||
|
title={t`Edit color ${selectedColor.token.value}`}
|
||||||
|
aria-label={t`Edit color ${selectedColor.token.value}`}
|
||||||
|
className="pointer-events-auto size-3 rounded-full border border-foreground/40"
|
||||||
|
style={{ backgroundColor: selectedColor.token.value }}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StylesheetEditorShellProps = {
|
||||||
|
readOnly?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
function StylesheetEditorShell({ readOnly = false }: StylesheetEditorShellProps) {
|
||||||
|
const { theme } = useTheme();
|
||||||
|
const isMobile = useMediaQuery("(max-width: 767px)", { initializeWithValue: false });
|
||||||
|
const [focusOpen, setFocusOpen] = useState(false);
|
||||||
|
const restoreDesktopRef = useRef<(() => void) | null>(null);
|
||||||
|
const mode = useStylesheetStore((state) => state.mode);
|
||||||
|
const source = useStylesheetStore((state) => state.source.text);
|
||||||
|
const applied = useStylesheetStore((state) => state.applied.text);
|
||||||
|
const diagnostics = useStylesheetStore((state) => state.diagnostics);
|
||||||
|
const colorTokens = useStylesheetStore((state) => state.colorTokens);
|
||||||
|
const metadata = useStylesheetStore((state) => state.editorMetadata);
|
||||||
|
const status = useStylesheetStore((state) => state.status);
|
||||||
|
const restoreLocked = useStylesheetStore((state) => state.restoreLocked);
|
||||||
|
const canUndo = useStylesheetStore((state) => state.canUndo);
|
||||||
|
const canRedo = useStylesheetStore((state) => state.canRedo);
|
||||||
|
const setSourceText = useStylesheetStore((state) => state.setSourceText);
|
||||||
|
const setFocused = useStylesheetStore((state) => state.setFocused);
|
||||||
|
const activate = useStylesheetStore((state) => state.activate);
|
||||||
|
const undo = useStylesheetStore((state) => state.undo);
|
||||||
|
const redo = useStylesheetStore((state) => state.redo);
|
||||||
|
const refreshIntelligence = useStylesheetStore((state) => state.refreshIntelligence);
|
||||||
|
const editorViewRef = useRef<EditorView | null>(null);
|
||||||
|
const hasErrors = status === "error" || diagnostics.some(({ severity }) => severity === "error");
|
||||||
|
const isChecking = status === "compiling" || status === "preflighting" || status === "saving";
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
restoreDesktopRef.current?.();
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
refreshIntelligence();
|
||||||
|
}, [refreshIntelligence]);
|
||||||
|
|
||||||
|
const toggleFocus = () => {
|
||||||
|
if (isMobile) {
|
||||||
|
setFocusOpen((open) => !open);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (restoreDesktopRef.current) {
|
||||||
|
restoreDesktopRef.current();
|
||||||
|
restoreDesktopRef.current = null;
|
||||||
|
setFocusOpen(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rightSidebar, layout, setLayout } = useBuilderSidebarStore.getState();
|
||||||
|
restoreDesktopRef.current = enterStylesheetFocusMode({
|
||||||
|
rightPanel: rightSidebar,
|
||||||
|
currentLayout: layout,
|
||||||
|
setLayout,
|
||||||
|
});
|
||||||
|
setFocusOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const editor = (
|
||||||
|
<StylesheetCodeEditor
|
||||||
|
value={source}
|
||||||
|
diagnostics={diagnostics}
|
||||||
|
colorTokens={colorTokens}
|
||||||
|
metadata={metadata}
|
||||||
|
theme={theme}
|
||||||
|
readOnly={readOnly || restoreLocked}
|
||||||
|
label={t`Semantic CSS stylesheet`}
|
||||||
|
onChange={setSourceText}
|
||||||
|
onFocusChange={setFocused}
|
||||||
|
onReady={(view) => {
|
||||||
|
editorViewRef.current = view;
|
||||||
|
}}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const editorChrome = (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{mode === "legacy" && (
|
||||||
|
<LegacyStylesheetBanner disabled={restoreLocked || hasErrors || isChecking} onActivate={activate} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StylesheetToolbar
|
||||||
|
source={source}
|
||||||
|
canUndo={canUndo}
|
||||||
|
canRedo={canRedo}
|
||||||
|
focused={focusOpen}
|
||||||
|
disabled={restoreLocked}
|
||||||
|
onUndo={undo}
|
||||||
|
onRedo={redo}
|
||||||
|
onFormat={() => {
|
||||||
|
const view = editorViewRef.current;
|
||||||
|
if (view) void formatEditorDocument(view).catch(() => undefined);
|
||||||
|
}}
|
||||||
|
onReset={() => setSourceText(applied)}
|
||||||
|
onFocusToggle={toggleFocus}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="flex items-center gap-1.5 text-muted-foreground text-xs">
|
||||||
|
<BookOpenIcon aria-hidden="true" className="shrink-0" />
|
||||||
|
<span>
|
||||||
|
<Trans>Not sure what to write?</Trans>{" "}
|
||||||
|
<a
|
||||||
|
className="text-primary underline underline-offset-4"
|
||||||
|
href="https://docs.rxresu.me/applying-custom-styles"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
>
|
||||||
|
<Trans>Read the Applying Custom Styles guide.</Trans>
|
||||||
|
<span className="sr-only">
|
||||||
|
{" "}
|
||||||
|
(<Trans>opens in new tab</Trans>)
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className={focusOpen ? (isMobile ? "h-[55svh]" : "h-[calc(100svh-14rem)]") : "h-72"}>{editor}</div>
|
||||||
|
|
||||||
|
<StylesheetStatus mode={mode} status={status} diagnostics={diagnostics} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{!(isMobile && focusOpen) && editorChrome}
|
||||||
|
<Sheet open={isMobile && focusOpen} onOpenChange={setFocusOpen}>
|
||||||
|
<SheetContent side="right" className="w-full max-w-full gap-3 overflow-hidden p-4 sm:max-w-full">
|
||||||
|
<SheetTitle>
|
||||||
|
<Trans>Semantic CSS stylesheet</Trans>
|
||||||
|
</SheetTitle>
|
||||||
|
<div className="min-h-0 flex-1 overflow-y-auto">{isMobile && focusOpen ? editorChrome : null}</div>
|
||||||
|
</SheetContent>
|
||||||
|
</Sheet>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default StylesheetEditorShell;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { enterStylesheetFocusMode } from "./focus-mode";
|
||||||
|
|
||||||
|
describe("stylesheet focus mode", () => {
|
||||||
|
it("resizes and restores the desktop right panel", () => {
|
||||||
|
const currentLayout = { left: 22, artboard: 56, right: 22 };
|
||||||
|
const resize = vi.fn();
|
||||||
|
const setLayout = vi.fn();
|
||||||
|
const rightPanel = { current: { resize } };
|
||||||
|
|
||||||
|
const restore = enterStylesheetFocusMode({ rightPanel, currentLayout, setLayout });
|
||||||
|
|
||||||
|
expect(resize).toHaveBeenCalledWith("45%");
|
||||||
|
restore();
|
||||||
|
expect(resize).toHaveBeenLastCalledWith("22%");
|
||||||
|
expect(setLayout).toHaveBeenCalledWith(currentLayout);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import type { BuilderLayout } from "@/routes/builder/$resumeId/-store/sidebar";
|
||||||
|
|
||||||
|
type FocusPanel = {
|
||||||
|
current: { resize(size: string): void } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StylesheetFocusModeInput = {
|
||||||
|
rightPanel: FocusPanel | null;
|
||||||
|
currentLayout: BuilderLayout;
|
||||||
|
setLayout(layout: BuilderLayout): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function enterStylesheetFocusMode({
|
||||||
|
rightPanel,
|
||||||
|
currentLayout,
|
||||||
|
setLayout,
|
||||||
|
}: StylesheetFocusModeInput): () => void {
|
||||||
|
rightPanel?.current?.resize("45%");
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
rightPanel?.current?.resize(`${currentLayout.right}%`);
|
||||||
|
setLayout(currentLayout);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { EditorState } from "@codemirror/state";
|
||||||
|
import { EditorView } from "@codemirror/view";
|
||||||
|
import { formatEditorDocument, formatSemanticCss } from "./formatter";
|
||||||
|
|
||||||
|
const views: EditorView[] = [];
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
for (const view of views.splice(0)) view.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Semantic CSS formatter", () => {
|
||||||
|
it("preserves comments and translates the cursor", async () => {
|
||||||
|
const result = await formatSemanticCss("/* keep */ section{color:red}", 18);
|
||||||
|
|
||||||
|
expect(result.formatted).toContain("/* keep */");
|
||||||
|
expect(result.formatted).toContain("section {");
|
||||||
|
expect(result.cursorOffset).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("applies an explicit format as one editor transaction", async () => {
|
||||||
|
const transactions = vi.fn();
|
||||||
|
const view = new EditorView({
|
||||||
|
state: EditorState.create({
|
||||||
|
doc: "/* keep */ section{color:red}",
|
||||||
|
selection: { anchor: 18 },
|
||||||
|
extensions: EditorView.updateListener.of((update) => {
|
||||||
|
if (update.docChanged) transactions(update.transactions);
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
views.push(view);
|
||||||
|
|
||||||
|
await formatEditorDocument(view);
|
||||||
|
|
||||||
|
expect(view.state.doc.toString()).toContain("section {");
|
||||||
|
expect(transactions).toHaveBeenCalledOnce();
|
||||||
|
expect(transactions.mock.calls[0]?.[0]).toHaveLength(1);
|
||||||
|
expect(view.state.selection.main.head).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves malformed source untouched when formatting fails", async () => {
|
||||||
|
const dispatch = vi.spyOn(EditorView.prototype, "dispatch");
|
||||||
|
const view = new EditorView({ doc: "section {" });
|
||||||
|
views.push(view);
|
||||||
|
|
||||||
|
await expect(formatEditorDocument(view)).rejects.toThrow(/css|syntax|unexpected/i);
|
||||||
|
expect(view.state.doc.toString()).toBe("section {");
|
||||||
|
expect(dispatch).not.toHaveBeenCalled();
|
||||||
|
dispatch.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import type { EditorView } from "@codemirror/view";
|
||||||
|
import { EditorSelection, Transaction } from "@codemirror/state";
|
||||||
|
|
||||||
|
export type FormattedSemanticCss = {
|
||||||
|
formatted: string;
|
||||||
|
cursorOffset: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function formatSemanticCss(source: string, cursorOffset: number): Promise<FormattedSemanticCss> {
|
||||||
|
const [{ formatWithCursor }, { default: postcss }] = await Promise.all([
|
||||||
|
import("prettier/standalone"),
|
||||||
|
import("prettier/plugins/postcss"),
|
||||||
|
]);
|
||||||
|
return formatWithCursor(source, {
|
||||||
|
parser: "css",
|
||||||
|
plugins: [postcss],
|
||||||
|
cursorOffset,
|
||||||
|
useTabs: true,
|
||||||
|
tabWidth: 4,
|
||||||
|
printWidth: 120,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function formatEditorDocument(view: EditorView): Promise<void> {
|
||||||
|
const source = view.state.doc.toString();
|
||||||
|
const result = await formatSemanticCss(source, view.state.selection.main.head);
|
||||||
|
if (view.state.doc.toString() !== source) return;
|
||||||
|
view.dispatch({
|
||||||
|
changes: { from: 0, to: view.state.doc.length, insert: result.formatted },
|
||||||
|
selection: EditorSelection.cursor(Math.min(result.cursorOffset, result.formatted.length)),
|
||||||
|
annotations: Transaction.userEvent.of("input.format"),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { ArrowRightIcon, InfoIcon } from "@phosphor-icons/react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||||
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
|
|
||||||
|
export type LegacyStylesheetBannerProps = {
|
||||||
|
disabled: boolean;
|
||||||
|
onActivate(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function LegacyStylesheetBanner({ disabled, onActivate }: LegacyStylesheetBannerProps) {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Converted stylesheet draft</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription className="space-y-3">
|
||||||
|
<p>
|
||||||
|
<Trans>Your legacy styles remain active until you explicitly activate this Semantic CSS draft.</Trans>
|
||||||
|
</p>
|
||||||
|
<Button type="button" size="sm" disabled={disabled} onClick={onActivate}>
|
||||||
|
<Trans>Activate Semantic CSS</Trans>
|
||||||
|
<ArrowRightIcon data-icon="inline-end" />
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SemanticStylesheetReadOnlyNotice() {
|
||||||
|
return (
|
||||||
|
<Alert>
|
||||||
|
<InfoIcon />
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Semantic styles remain active</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>This instance does not currently allow Semantic CSS editing.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { inspectPdfPageCount } from "./pdf-inspection";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
workerDestroy: vi.fn(),
|
||||||
|
getDocument: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("pdfjs-dist/legacy/build/pdf.mjs", () => ({
|
||||||
|
PDFWorker: class {
|
||||||
|
destroy = mocks.workerDestroy;
|
||||||
|
},
|
||||||
|
getDocument: mocks.getDocument,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("inspectPdfPageCount", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("inspects a copy through a nested worker without detaching the result buffer", async () => {
|
||||||
|
const destroy = vi.fn();
|
||||||
|
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||||
|
mocks.getDocument.mockReturnValue({ promise: Promise.resolve({ numPages: 3 }), destroy });
|
||||||
|
const pdf = Uint8Array.of(1, 2, 3, 4).buffer;
|
||||||
|
|
||||||
|
await expect(inspectPdfPageCount(pdf, () => nestedWorker)).resolves.toBe(3);
|
||||||
|
|
||||||
|
expect(mocks.getDocument).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ data: expect.any(ArrayBuffer), worker: expect.any(Object) }),
|
||||||
|
);
|
||||||
|
const inspectedPdf = mocks.getDocument.mock.calls[0]?.[0].data as ArrayBuffer;
|
||||||
|
expect(inspectedPdf).not.toBe(pdf);
|
||||||
|
expect(Array.from(new Uint8Array(inspectedPdf))).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(Array.from(new Uint8Array(pdf))).toEqual([1, 2, 3, 4]);
|
||||||
|
expect(destroy).toHaveBeenCalledOnce();
|
||||||
|
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("destroys the loading task and nested worker when parsing fails", async () => {
|
||||||
|
const destroy = vi.fn();
|
||||||
|
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||||
|
mocks.getDocument.mockReturnValue({ promise: Promise.reject(new Error("invalid PDF")), destroy });
|
||||||
|
|
||||||
|
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("invalid PDF");
|
||||||
|
|
||||||
|
expect(destroy).toHaveBeenCalledOnce();
|
||||||
|
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates the nested worker even when loading-task cleanup fails", async () => {
|
||||||
|
const nestedWorker = { terminate: vi.fn() } as unknown as Worker;
|
||||||
|
mocks.getDocument.mockReturnValue({
|
||||||
|
promise: Promise.resolve({ numPages: 1 }),
|
||||||
|
destroy: vi.fn().mockRejectedValue(new Error("cleanup failed")),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(inspectPdfPageCount(new ArrayBuffer(4), () => nestedWorker)).rejects.toThrow("cleanup failed");
|
||||||
|
|
||||||
|
expect(nestedWorker.terminate).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
let pdfModule: Promise<typeof import("pdfjs-dist/legacy/build/pdf.mjs")>;
|
||||||
|
|
||||||
|
const loadPdfModule = () => (pdfModule ??= import("pdfjs-dist/legacy/build/pdf.mjs"));
|
||||||
|
|
||||||
|
const createNestedWorker = () =>
|
||||||
|
new Worker(new URL("pdfjs-dist/legacy/build/pdf.worker.min.mjs", import.meta.url), {
|
||||||
|
type: "module",
|
||||||
|
name: "semantic-css-pdfjs",
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function initializePdfInspection(): Promise<void> {
|
||||||
|
await loadPdfModule();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function inspectPdfPageCount(
|
||||||
|
pdf: ArrayBuffer,
|
||||||
|
createWorker: () => Worker = createNestedWorker,
|
||||||
|
): Promise<number> {
|
||||||
|
const { PDFWorker, getDocument } = await loadPdfModule();
|
||||||
|
const nestedWorker = createWorker();
|
||||||
|
const WorkerWithPort = PDFWorker as unknown as new (options: { port: Worker }) => InstanceType<typeof PDFWorker>;
|
||||||
|
const worker = new WorkerWithPort({ port: nestedWorker });
|
||||||
|
let loadingTask: ReturnType<typeof getDocument> | undefined;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// PDF.js transfers its input to the nested worker and detaches the buffer.
|
||||||
|
// Keep the caller's buffer intact so preflight can return those same bytes.
|
||||||
|
loadingTask = getDocument({ data: pdf.slice(0), worker });
|
||||||
|
const document = await loadingTask.promise;
|
||||||
|
return document.numPages;
|
||||||
|
} finally {
|
||||||
|
try {
|
||||||
|
if (loadingTask) await loadingTask.destroy();
|
||||||
|
else worker.destroy();
|
||||||
|
} finally {
|
||||||
|
nestedWorker.terminate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import type { PreflightWorkerRequest } from "./protocol";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
renderPreflightPdf: vi.fn(),
|
||||||
|
initializePdfInspection: vi.fn(async () => undefined),
|
||||||
|
inspectPdfPageCount: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/pdf/preflight", () => ({
|
||||||
|
renderPreflightPdf: mocks.renderPreflightPdf,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./pdf-inspection", () => ({
|
||||||
|
initializePdfInspection: mocks.initializePdfInspection,
|
||||||
|
inspectPdfPageCount: mocks.inspectPdfPageCount,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe("stylesheet preflight worker", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("serializes schema errors into a correlated preflight error packet", async () => {
|
||||||
|
let handler: ((event: MessageEvent<PreflightWorkerRequest>) => Promise<void>) | undefined;
|
||||||
|
const postMessage = vi.fn();
|
||||||
|
vi.stubGlobal("self", {
|
||||||
|
postMessage,
|
||||||
|
addEventListener: vi.fn((_type, listener) => {
|
||||||
|
handler = listener as typeof handler;
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const issues = [{ path: ["customSections", 0, "items", 0, "company"] }];
|
||||||
|
mocks.renderPreflightPdf.mockRejectedValueOnce(
|
||||||
|
Object.assign(new Error("Invalid resume data"), { name: "ZodError", issues }),
|
||||||
|
);
|
||||||
|
vi.resetModules();
|
||||||
|
await import("./preflight.worker");
|
||||||
|
|
||||||
|
await handler?.({
|
||||||
|
data: {
|
||||||
|
type: "preflight",
|
||||||
|
requestId: 7,
|
||||||
|
editGeneration: 3,
|
||||||
|
input: {} as never,
|
||||||
|
limits: {} as never,
|
||||||
|
},
|
||||||
|
} as unknown as MessageEvent<PreflightWorkerRequest>);
|
||||||
|
|
||||||
|
expect(postMessage).toHaveBeenCalledWith({
|
||||||
|
type: "preflight_error",
|
||||||
|
requestId: 7,
|
||||||
|
editGeneration: 3,
|
||||||
|
cause: { name: "ZodError", message: "Invalid resume data", issues },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import type { PdfPreflightFailure } from "@reactive-resume/pdf/preflight";
|
||||||
|
import type {
|
||||||
|
PreflightWorkerError,
|
||||||
|
PreflightWorkerRequest,
|
||||||
|
PreflightWorkerResponse,
|
||||||
|
SerializedPreflightCause,
|
||||||
|
} from "./protocol";
|
||||||
|
import { Buffer } from "buffer";
|
||||||
|
import { initializePdfInspection, inspectPdfPageCount } from "./pdf-inspection";
|
||||||
|
import { getPreflightTransferables } from "./protocol";
|
||||||
|
|
||||||
|
Object.assign(globalThis, { Buffer });
|
||||||
|
|
||||||
|
const failure = (code: PdfPreflightFailure["code"], message: string): PdfPreflightFailure => ({
|
||||||
|
ok: false,
|
||||||
|
code,
|
||||||
|
message,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const serializeZodCause = (cause: unknown): SerializedPreflightCause | undefined => {
|
||||||
|
if (!(cause instanceof Error) || cause.name !== "ZodError" || !("issues" in cause) || !Array.isArray(cause.issues)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return { name: cause.name, message: cause.message, issues: cause.issues };
|
||||||
|
};
|
||||||
|
|
||||||
|
const initialization = Promise.all([import("@reactive-resume/pdf/preflight"), initializePdfInspection()] as const);
|
||||||
|
void initialization.then(() => self.postMessage({ type: "preflight_ready" }));
|
||||||
|
|
||||||
|
self.addEventListener("message", async ({ data }: MessageEvent<PreflightWorkerRequest>) => {
|
||||||
|
if (data.type !== "preflight") return;
|
||||||
|
const [{ renderPreflightPdf }] = await initialization;
|
||||||
|
let rendered: Awaited<ReturnType<typeof renderPreflightPdf>>;
|
||||||
|
|
||||||
|
try {
|
||||||
|
rendered = await renderPreflightPdf(data.input, data.limits);
|
||||||
|
} catch (cause) {
|
||||||
|
const serializedCause = serializeZodCause(cause);
|
||||||
|
if (serializedCause) {
|
||||||
|
const response: PreflightWorkerError = {
|
||||||
|
type: "preflight_error",
|
||||||
|
requestId: data.requestId,
|
||||||
|
editGeneration: data.editGeneration,
|
||||||
|
cause: serializedCause,
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const result = failure("STYLESHEET_PREFLIGHT_WORKER_FAILED", "The PDF preflight worker failed.");
|
||||||
|
const response: PreflightWorkerResponse = {
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: data.requestId,
|
||||||
|
editGeneration: data.editGeneration,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let result: PreflightWorkerResponse["result"];
|
||||||
|
if (!rendered.ok) {
|
||||||
|
result = rendered;
|
||||||
|
} else if (rendered.bytes.byteLength > data.limits.maxBytes) {
|
||||||
|
result = failure("STYLESHEET_PREFLIGHT_BYTE_LIMIT", "The PDF exceeds the preflight byte limit.");
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const pdf = Uint8Array.from(rendered.bytes).buffer;
|
||||||
|
const pageCount = await inspectPdfPageCount(pdf);
|
||||||
|
result =
|
||||||
|
pageCount > data.limits.maxPages
|
||||||
|
? failure("STYLESHEET_PREFLIGHT_PAGE_LIMIT", "The PDF exceeds the preflight page limit.")
|
||||||
|
: {
|
||||||
|
ok: true,
|
||||||
|
pageCount,
|
||||||
|
byteCount: pdf.byteLength,
|
||||||
|
diagnostics: rendered.diagnostics,
|
||||||
|
pdf,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
result = failure("STYLESHEET_PREFLIGHT_PARSE_FAILED", "The generated PDF could not be inspected.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: PreflightWorkerResponse = {
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: data.requestId,
|
||||||
|
editGeneration: data.editGeneration,
|
||||||
|
result,
|
||||||
|
};
|
||||||
|
self.postMessage(response, { transfer: getPreflightTransferables(response) });
|
||||||
|
});
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import type {
|
||||||
|
BrowserPdfPreflightResult,
|
||||||
|
PdfPreflightPageLimits,
|
||||||
|
StylesheetPreflightInput,
|
||||||
|
} from "@reactive-resume/pdf/preflight";
|
||||||
|
import type {
|
||||||
|
AuthoredPageContext,
|
||||||
|
BaseSettingsSnapshot,
|
||||||
|
SemanticCssDiagnostic,
|
||||||
|
SemanticNode,
|
||||||
|
StyleProgram,
|
||||||
|
} from "@reactive-resume/resume/stylesheet";
|
||||||
|
import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
import type { SemanticCssColorToken } from "./color-tokens";
|
||||||
|
|
||||||
|
export type SemanticCssEditorMetadata = {
|
||||||
|
semanticTree: SemanticNode;
|
||||||
|
templateParts: readonly string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompileWorkerInput = {
|
||||||
|
editGeneration: number;
|
||||||
|
source: StylesheetSource;
|
||||||
|
semanticTree: SemanticNode;
|
||||||
|
baseSettings: BaseSettingsSnapshot;
|
||||||
|
pages: readonly AuthoredPageContext[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompileWorkerRequest = CompileWorkerInput & {
|
||||||
|
type: "compile";
|
||||||
|
requestId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CompileWorkerResponse = {
|
||||||
|
type: "compile_result";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
program: StyleProgram | null;
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
colorTokens?: readonly SemanticCssColorToken[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreflightLimits = PdfPreflightPageLimits & {
|
||||||
|
maxPages: number;
|
||||||
|
maxBytes: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreflightWorkerInput = {
|
||||||
|
editGeneration: number;
|
||||||
|
input: StylesheetPreflightInput;
|
||||||
|
limits: PreflightLimits;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreflightWorkerRequest = PreflightWorkerInput & {
|
||||||
|
type: "preflight";
|
||||||
|
requestId: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreflightWorkerResponse = {
|
||||||
|
type: "preflight_result";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
result: BrowserPdfPreflightResult;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SerializedPreflightCause = {
|
||||||
|
name: string;
|
||||||
|
message: string;
|
||||||
|
issues: readonly unknown[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreflightWorkerError = {
|
||||||
|
type: "preflight_error";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
cause: SerializedPreflightCause;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PreflightWorkerReady = {
|
||||||
|
type: "preflight_ready";
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getPreflightTransferables(response: PreflightWorkerResponse): Transferable[] {
|
||||||
|
return response.result.ok ? [response.result.pdf] : [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
|
||||||
|
import type { SemanticStylesheet } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getState: vi.fn(),
|
||||||
|
workers: [] as FakeWorker[],
|
||||||
|
}));
|
||||||
|
|
||||||
|
class FakeWorker {
|
||||||
|
terminated = false;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
mocks.workers.push(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
postMessage() {}
|
||||||
|
addEventListener() {}
|
||||||
|
removeEventListener() {}
|
||||||
|
terminate() {
|
||||||
|
this.terminated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
vi.mock("@/libs/orpc/client", () => ({
|
||||||
|
orpc: {
|
||||||
|
resume: {
|
||||||
|
stylesheet: {
|
||||||
|
getState: { call: mocks.getState },
|
||||||
|
mutate: { call: vi.fn() },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const stylesheet = (text: string): SemanticStylesheet => {
|
||||||
|
const source = { languageVersion: 1, text };
|
||||||
|
return { mode: "semantic", source, applied: source };
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stylesheet store reinitialization", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.workers.length = 0;
|
||||||
|
vi.stubGlobal("Worker", FakeWorker);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
|
||||||
|
it("suspends edits while a delayed restore is pending, then atomically installs the restored state", async () => {
|
||||||
|
const storeModule = await import("./store");
|
||||||
|
const cleanup = storeModule.initializeStylesheetStore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
});
|
||||||
|
let finishRestore: (() => void) | undefined;
|
||||||
|
const delayedRestore = new Promise<void>((resolve) => {
|
||||||
|
finishRestore = resolve;
|
||||||
|
});
|
||||||
|
const restore = async () => {
|
||||||
|
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||||
|
await delayedRestore;
|
||||||
|
return storeModule.replaceStylesheetStoreAfterRestore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
initial: { stylesheet: stylesheet("restored"), revision: 9, renderDataVersion: 12 },
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRestore = restore();
|
||||||
|
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||||
|
const editGeneration = storeModule.useStylesheetStore.getState().editGeneration;
|
||||||
|
storeModule.useStylesheetStore.getState().setSourceText("edit while restoring");
|
||||||
|
storeModule.useStylesheetStore.getState().deactivate();
|
||||||
|
storeModule.useStylesheetStore.getState().undo();
|
||||||
|
expect(storeModule.useStylesheetStore.getState().source.text).toBe("old");
|
||||||
|
expect(storeModule.useStylesheetStore.getState().editGeneration).toBe(editGeneration);
|
||||||
|
finishRestore?.();
|
||||||
|
const replaced = await pendingRestore;
|
||||||
|
expect(replaced).toBe(true);
|
||||||
|
expect(mocks.getState).not.toHaveBeenCalled();
|
||||||
|
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
source: { text: "restored" },
|
||||||
|
applied: { text: "restored" },
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 12,
|
||||||
|
restoreLocked: false,
|
||||||
|
});
|
||||||
|
storeModule.useStylesheetStore.getState().setSourceText("later edit");
|
||||||
|
expect(storeModule.useStylesheetStore.getState().source.text).toBe("later edit");
|
||||||
|
expect(mocks.workers).toHaveLength(4);
|
||||||
|
expect(mocks.workers.slice(0, 2).every((worker) => worker.terminated)).toBe(true);
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
|
||||||
|
expect(mocks.workers.slice(2).every((worker) => worker.terminated)).toBe(true);
|
||||||
|
expect(storeModule.useStylesheetStore.getState().resumeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks interaction after a restore request fails", async () => {
|
||||||
|
const storeModule = await import("./store");
|
||||||
|
const cleanup = storeModule.initializeStylesheetStore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { stylesheet: stylesheet("old"), revision: 3, renderDataVersion: 7 },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const token = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||||
|
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(true);
|
||||||
|
expect(storeModule.unlockStylesheetStoreAfterRestore(token)).toBe(true);
|
||||||
|
expect(storeModule.useStylesheetStore.getState().restoreLocked).toBe(false);
|
||||||
|
|
||||||
|
storeModule.useStylesheetStore.getState().setSourceText("edit after failure");
|
||||||
|
expect(storeModule.useStylesheetStore.getState().source.text).toBe("edit after failure");
|
||||||
|
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a stale same-resume restore completion after away-and-back runtime replacement", async () => {
|
||||||
|
const storeModule = await import("./store");
|
||||||
|
const cleanupFirst = storeModule.initializeStylesheetStore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { stylesheet: stylesheet("first"), revision: 1, renderDataVersion: 1 },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
});
|
||||||
|
const staleToken = storeModule.lockStylesheetStoreForRestore("resume-1");
|
||||||
|
|
||||||
|
cleanupFirst();
|
||||||
|
const cleanupSecond = storeModule.initializeStylesheetStore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { stylesheet: stylesheet("second"), revision: 2, renderDataVersion: 2 },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
});
|
||||||
|
|
||||||
|
const replaced = storeModule.replaceStylesheetStoreAfterRestore({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
initial: { stylesheet: stylesheet("stale restore"), revision: 3, renderDataVersion: 3 },
|
||||||
|
token: staleToken,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(replaced).toBe(false);
|
||||||
|
expect(storeModule.useStylesheetStore.getState()).toMatchObject({
|
||||||
|
source: { text: "second" },
|
||||||
|
revision: 2,
|
||||||
|
renderDataVersion: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
cleanupSecond();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import type { SemanticCssDiagnostic } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { Trans } from "@lingui/react/macro";
|
||||||
|
import { WarningCircleIcon, WarningIcon } from "@phosphor-icons/react";
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from "@reactive-resume/ui/components/alert";
|
||||||
|
import { Badge } from "@reactive-resume/ui/components/badge";
|
||||||
|
import { ScrollArea } from "@reactive-resume/ui/components/scroll-area";
|
||||||
|
|
||||||
|
export type StylesheetStatusProps = {
|
||||||
|
mode: "legacy" | "semantic";
|
||||||
|
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StylesheetStatus({ mode, status, diagnostics }: StylesheetStatusProps) {
|
||||||
|
const errors = diagnostics.filter(({ severity }) => severity === "error");
|
||||||
|
const warnings = diagnostics.filter(({ severity }) => severity === "warning");
|
||||||
|
const hasErrors = status === "error" || errors.length > 0;
|
||||||
|
const isPending = status === "compiling" || status === "preflighting" || status === "saving";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-2" aria-live="polite">
|
||||||
|
{hasErrors ? (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
<WarningCircleIcon data-icon="inline-start" />
|
||||||
|
<Trans>Error</Trans>
|
||||||
|
</Badge>
|
||||||
|
) : isPending ? (
|
||||||
|
<Badge variant="outline">{mode === "legacy" ? <Trans>Checking draft</Trans> : <Trans>Checking</Trans>}</Badge>
|
||||||
|
) : warnings.length > 0 ? (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
<WarningIcon data-icon="inline-start" />
|
||||||
|
{mode === "legacy" ? <Trans>Ready to activate with warnings</Trans> : <Trans>Applied with warnings</Trans>}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{mode === "legacy" ? <Trans>Ready to activate</Trans> : <Trans>Applied</Trans>}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasErrors && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<WarningCircleIcon />
|
||||||
|
<AlertTitle>
|
||||||
|
<Trans>Stylesheet has errors</Trans>
|
||||||
|
</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
<Trans>Preview and export use the last valid version.</Trans>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{diagnostics.length > 0 && (
|
||||||
|
<ScrollArea className="max-h-32 rounded-md border">
|
||||||
|
<ul className="space-y-2 p-3 text-xs">
|
||||||
|
{diagnostics.map((diagnostic) => (
|
||||||
|
<li key={`${diagnostic.code}-${diagnostic.range.start.offset}`} className="space-y-0.5">
|
||||||
|
<p className="font-medium">{diagnostic.message}</p>
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
<Trans>
|
||||||
|
Line {diagnostic.range.start.line}, column {diagnostic.range.start.column}
|
||||||
|
</Trans>
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,789 @@
|
|||||||
|
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { createStylesheetStoreRuntime } from "./store";
|
||||||
|
|
||||||
|
const source = (text: string): StylesheetSource => ({ languageVersion: 1, text });
|
||||||
|
const stylesheet = (text: string): SemanticStylesheet => ({
|
||||||
|
mode: "semantic",
|
||||||
|
source: source(text),
|
||||||
|
applied: source(text),
|
||||||
|
});
|
||||||
|
|
||||||
|
const initial = {
|
||||||
|
stylesheet: stylesheet("generation zero"),
|
||||||
|
revision: 3,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
};
|
||||||
|
|
||||||
|
type MutationResult = {
|
||||||
|
stylesheet: SemanticStylesheet;
|
||||||
|
revision: number;
|
||||||
|
renderDataVersion: number;
|
||||||
|
editGeneration: number;
|
||||||
|
diagnostics: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("stylesheet store runtime", () => {
|
||||||
|
beforeEach(() => vi.useFakeTimers());
|
||||||
|
|
||||||
|
it("clears compiler-confirmed color tokens synchronously when same-length source text changes", () => {
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 1_000_000,
|
||||||
|
compile: vi.fn(),
|
||||||
|
preflight: vi.fn(),
|
||||||
|
mutate: vi.fn(),
|
||||||
|
});
|
||||||
|
runtime.store.setState({ colorTokens: [{ from: 17, to: 20, value: "red" }] });
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("section { color: var; }");
|
||||||
|
|
||||||
|
expect(runtime.store.getState().source.text).toBe("section { color: var; }");
|
||||||
|
expect(runtime.store.getState().colorTokens).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects delayed editor intelligence for a canonically replaced source", async () => {
|
||||||
|
let resolveCompile!: (value: {
|
||||||
|
type: "compile_result";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
program: { languageVersion: number; rules: [] };
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: string;
|
||||||
|
severity: "error";
|
||||||
|
message: string;
|
||||||
|
range: {
|
||||||
|
start: { line: number; column: number; offset: number };
|
||||||
|
end: { line: number; column: number; offset: number };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
];
|
||||||
|
colorTokens: [{ from: number; to: number; value: string }];
|
||||||
|
}) => void;
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { ...initial, stylesheet: stylesheet("section { color: red; }") },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
compile: () => new Promise((resolve) => (resolveCompile = resolve)),
|
||||||
|
preflight: vi.fn(),
|
||||||
|
mutate: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().refreshIntelligence();
|
||||||
|
runtime.rebaseCanonical({
|
||||||
|
stylesheet: stylesheet("section { color: blue; }"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
});
|
||||||
|
resolveCompile({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: 1,
|
||||||
|
editGeneration: 0,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: "OLD_SOURCE",
|
||||||
|
severity: "error",
|
||||||
|
message: "Old source diagnostic",
|
||||||
|
range: {
|
||||||
|
start: { line: 1, column: 1, offset: 0 },
|
||||||
|
end: { line: 1, column: 2, offset: 1 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
colorTokens: [{ from: 17, to: 20, value: "red" }],
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
source: source("section { color: blue; }"),
|
||||||
|
diagnostics: [],
|
||||||
|
colorTokens: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("consumes stale acknowledgements before saving the replaceable pending edit", async () => {
|
||||||
|
const resolvers: Array<(value: MutationResult) => void> = [];
|
||||||
|
const mutate = vi.fn(
|
||||||
|
(_input: unknown) =>
|
||||||
|
new Promise<MutationResult>((resolve) => {
|
||||||
|
resolvers.push((value) => resolve(value));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("generation one");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
runtime.store.getState().setSourceText("generation two");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
resolvers[0]?.({
|
||||||
|
stylesheet: stylesheet("generation one"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 8,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 8,
|
||||||
|
source: source("generation two"),
|
||||||
|
applied: source("generation zero"),
|
||||||
|
});
|
||||||
|
expect(mutate).toHaveBeenCalledTimes(2);
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
expectedRevision: 4,
|
||||||
|
expectedRenderDataVersion: 8,
|
||||||
|
editGeneration: 2,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebases conflicts without dropping the focused local draft", async () => {
|
||||||
|
const mutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce({
|
||||||
|
code: "STYLESHEET_REVISION_CONFLICT",
|
||||||
|
data: {
|
||||||
|
state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: stylesheet("local unsaved source"),
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 11,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setFocused(true);
|
||||||
|
runtime.store.getState().setSourceText("local unsaved source");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 11,
|
||||||
|
source: source("local unsaved source"),
|
||||||
|
});
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({ expectedRevision: 8, expectedRenderDataVersion: 11 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the newer pending edit when an older request conflicts", async () => {
|
||||||
|
let rejectFirst!: (error: unknown) => void;
|
||||||
|
const mutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(
|
||||||
|
new Promise((_resolve, reject) => {
|
||||||
|
rejectFirst = reject;
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: stylesheet("newer"),
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 11,
|
||||||
|
editGeneration: 2,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("older");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
runtime.store.getState().setSourceText("newer");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
rejectFirst({
|
||||||
|
code: "STYLESHEET_REVISION_CONFLICT",
|
||||||
|
data: { state: { stylesheet: stylesheet("remote"), revision: 8, renderDataVersion: 11 } },
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
transition: "edit_source",
|
||||||
|
editGeneration: 2,
|
||||||
|
source: source("newer"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("invalidates pending preflight eligibility on content changes and keeps versions monotonic", async () => {
|
||||||
|
let resolveMutation!: (result: MutationResult) => void;
|
||||||
|
let resolveRepreflight!: (result: {
|
||||||
|
type: "preflight_result";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
result: {
|
||||||
|
ok: true;
|
||||||
|
pageCount: number;
|
||||||
|
byteCount: number;
|
||||||
|
diagnostics: [];
|
||||||
|
pdf: ArrayBuffer;
|
||||||
|
};
|
||||||
|
}) => void;
|
||||||
|
const mutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(new Promise<MutationResult>((resolve) => (resolveMutation = resolve)))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: stylesheet("newer"),
|
||||||
|
revision: 11,
|
||||||
|
renderDataVersion: 20,
|
||||||
|
editGeneration: 2,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRepreflight = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("older");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
runtime.store.getState().setSourceText("newer");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||||
|
stylesheet: stylesheet("remote"),
|
||||||
|
revision: 10,
|
||||||
|
renderDataVersion: 20,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(preflight).toHaveBeenCalledTimes(3);
|
||||||
|
|
||||||
|
resolveMutation({
|
||||||
|
stylesheet: stylesheet("older"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(runtime.store.getState()).toMatchObject({ revision: 10, renderDataVersion: 20 });
|
||||||
|
expect(mutate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveRepreflight({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 3,
|
||||||
|
editGeneration: 2,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
source: source("newer"),
|
||||||
|
expectedRevision: 10,
|
||||||
|
expectedRenderDataVersion: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not requeue an invalidated in-flight candidate after conflict", async () => {
|
||||||
|
let rejectMutation!: (error: unknown) => void;
|
||||||
|
let resolveRepreflight!: (result: {
|
||||||
|
type: "preflight_result";
|
||||||
|
requestId: number;
|
||||||
|
editGeneration: number;
|
||||||
|
result: {
|
||||||
|
ok: true;
|
||||||
|
pageCount: number;
|
||||||
|
byteCount: number;
|
||||||
|
diagnostics: [];
|
||||||
|
pdf: ArrayBuffer;
|
||||||
|
};
|
||||||
|
}) => void;
|
||||||
|
const mutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockReturnValueOnce(new Promise<MutationResult>((_resolve, reject) => (rejectMutation = reject)))
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: stylesheet("newer"),
|
||||||
|
revision: 11,
|
||||||
|
renderDataVersion: 20,
|
||||||
|
editGeneration: 2,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}))
|
||||||
|
.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
resolveRepreflight = resolve;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("older");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
runtime.store.getState().setSourceText("newer");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||||
|
stylesheet: stylesheet("remote"),
|
||||||
|
revision: 10,
|
||||||
|
renderDataVersion: 20,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
rejectMutation({
|
||||||
|
code: "STYLESHEET_REVISION_CONFLICT",
|
||||||
|
data: { state: { stylesheet: stylesheet("remote"), revision: 10, renderDataVersion: 20 } },
|
||||||
|
});
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(mutate).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
resolveRepreflight({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 3,
|
||||||
|
editGeneration: 2,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
source: source("newer"),
|
||||||
|
expectedRevision: 10,
|
||||||
|
expectedRenderDataVersion: 20,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reconciles a deferred focused canonical source on blur", () => {
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
compile: vi.fn(),
|
||||||
|
preflight: vi.fn(),
|
||||||
|
mutate: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setFocused(true);
|
||||||
|
runtime.rebaseCanonical({
|
||||||
|
stylesheet: stylesheet("remote"),
|
||||||
|
revision: 8,
|
||||||
|
renderDataVersion: 11,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
source: source("generation zero"),
|
||||||
|
applied: source("remote"),
|
||||||
|
revision: 8,
|
||||||
|
renderDataVersion: 11,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setFocused(false);
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
source: source("remote"),
|
||||||
|
applied: source("remote"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists invalid source while preserving applied and restores stylesheet history separately", async () => {
|
||||||
|
const mutate = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: {
|
||||||
|
mode: "semantic",
|
||||||
|
source: source("invalid {"),
|
||||||
|
applied: source("generation zero"),
|
||||||
|
},
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [
|
||||||
|
{
|
||||||
|
code: "PARSE_ERROR",
|
||||||
|
severity: "error",
|
||||||
|
message: "Invalid",
|
||||||
|
range: { start: { line: 1, column: 1, offset: 0 }, end: { line: 1, column: 1, offset: 0 } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
stylesheet: stylesheet("generation zero"),
|
||||||
|
revision: 5,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
editGeneration: 2,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration, source: candidate }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: candidate.text === "invalid {" ? null : { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("invalid {");
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(runtime.store.getState()).toMatchObject({
|
||||||
|
source: source("invalid {"),
|
||||||
|
applied: source("generation zero"),
|
||||||
|
});
|
||||||
|
expect(mutate.mock.calls[0]?.[0]).toMatchObject({ transition: "edit_source", source: source("invalid {") });
|
||||||
|
|
||||||
|
runtime.store.getState().undo();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate.mock.calls[1]?.[0]).toMatchObject({
|
||||||
|
transition: "restore_history",
|
||||||
|
restore: stylesheet("generation zero"),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not publish historical applied state before restore acknowledgement", async () => {
|
||||||
|
const mutate = vi.fn(() => new Promise<MutationResult>(() => {}));
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { ...initial, stylesheet: stylesheet("current applied") },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
runtime.store.setState({
|
||||||
|
source: source("local invalid"),
|
||||||
|
applied: source("current applied"),
|
||||||
|
undoStack: [stylesheet("historical")],
|
||||||
|
canUndo: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().undo();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState().source).toEqual(source("historical"));
|
||||||
|
expect(runtime.store.getState().applied).toEqual(source("current applied"));
|
||||||
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transition: "restore_history", restore: stylesheet("historical") }),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries the focused draft against a newer content render-data version", async () => {
|
||||||
|
const mutate = vi.fn().mockResolvedValue({
|
||||||
|
stylesheet: stylesheet("local"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 12,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setFocused(true);
|
||||||
|
runtime.store.getState().setSourceText("local");
|
||||||
|
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||||
|
stylesheet: stylesheet("remote"),
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 12,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||||
|
expect(mutate).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a queued draft when content changes after editor blur", async () => {
|
||||||
|
const mutate = vi.fn().mockResolvedValue({
|
||||||
|
stylesheet: stylesheet("local"),
|
||||||
|
revision: 10,
|
||||||
|
renderDataVersion: 12,
|
||||||
|
editGeneration: 1,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight: async ({ editGeneration }) => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
}),
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().setSourceText("local");
|
||||||
|
runtime.replaceResumeSnapshot(defaultResumeData, {
|
||||||
|
stylesheet: stylesheet("remote"),
|
||||||
|
revision: 9,
|
||||||
|
renderDataVersion: 12,
|
||||||
|
});
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(runtime.store.getState().source).toEqual(source("local"));
|
||||||
|
expect(mutate).toHaveBeenLastCalledWith(
|
||||||
|
expect.objectContaining({ expectedRevision: 9, expectedRenderDataVersion: 12, source: source("local") }),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("queues activation only after browser preflight succeeds", async () => {
|
||||||
|
const mutate = vi.fn().mockResolvedValue({
|
||||||
|
stylesheet: stylesheet("generation zero"),
|
||||||
|
revision: 4,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
editGeneration: 2,
|
||||||
|
diagnostics: [],
|
||||||
|
});
|
||||||
|
const preflight = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 1,
|
||||||
|
editGeneration: 1,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_RENDER_FAILED",
|
||||||
|
message: "failed",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 2,
|
||||||
|
editGeneration: 2,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 1, diagnostics: [], pdf: new ArrayBuffer(1) },
|
||||||
|
});
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial: { ...initial, stylesheet: { ...initial.stylesheet, mode: "legacy" } },
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 0,
|
||||||
|
compile: async ({ editGeneration }) => ({
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: editGeneration,
|
||||||
|
editGeneration,
|
||||||
|
program: { languageVersion: 1, rules: [] },
|
||||||
|
diagnostics: [],
|
||||||
|
}),
|
||||||
|
preflight,
|
||||||
|
mutate,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().activate();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
runtime.store.getState().activate();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
expect(mutate).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ transition: "activate", source: source("generation zero") }),
|
||||||
|
expect.any(AbortSignal),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates both worker clients and clears the store on cleanup", () => {
|
||||||
|
const destroy = vi.fn();
|
||||||
|
let mutationSignal: AbortSignal | undefined;
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
compile: vi.fn(),
|
||||||
|
preflight: vi.fn(),
|
||||||
|
mutate: vi.fn((_input: unknown, signal: AbortSignal) => {
|
||||||
|
mutationSignal = signal;
|
||||||
|
return new Promise<MutationResult>(() => {});
|
||||||
|
}),
|
||||||
|
destroy,
|
||||||
|
});
|
||||||
|
|
||||||
|
runtime.store.getState().deactivate();
|
||||||
|
runtime.destroy();
|
||||||
|
|
||||||
|
expect(destroy).toHaveBeenCalledOnce();
|
||||||
|
expect(mutationSignal?.aborted).toBe(true);
|
||||||
|
expect(runtime.store.getState().resumeId).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("coalesces rapid source edits and bounds stylesheet history", () => {
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
resumeId: "resume-1",
|
||||||
|
initial,
|
||||||
|
resumeData: defaultResumeData,
|
||||||
|
debounceMs: 1_000_000,
|
||||||
|
compile: vi.fn(),
|
||||||
|
preflight: vi.fn(),
|
||||||
|
mutate: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let index = 0; index < 10; index++) runtime.store.getState().setSourceText(`rapid ${index}`);
|
||||||
|
expect(runtime.store.getState().undoStack).toHaveLength(1);
|
||||||
|
expect(runtime.store.getState().undoStack[0]).toEqual(stylesheet("generation zero"));
|
||||||
|
|
||||||
|
for (let index = 0; index < 60; index++) {
|
||||||
|
vi.advanceTimersByTime(501);
|
||||||
|
runtime.store.getState().setSourceText(`separate ${index}`);
|
||||||
|
}
|
||||||
|
expect(runtime.store.getState().undoStack).toHaveLength(50);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,677 @@
|
|||||||
|
import type { SemanticCssDiagnostic, SemanticNode } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import type { SemanticStylesheet, StylesheetSource } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
import type { StoreApi } from "zustand/vanilla";
|
||||||
|
import type { SemanticCssColorToken } from "./color-tokens";
|
||||||
|
import type {
|
||||||
|
CompileWorkerInput,
|
||||||
|
CompileWorkerResponse,
|
||||||
|
PreflightWorkerInput,
|
||||||
|
PreflightWorkerResponse,
|
||||||
|
SemanticCssEditorMetadata,
|
||||||
|
} from "./protocol";
|
||||||
|
import { create } from "zustand/react";
|
||||||
|
import { createStore } from "zustand/vanilla";
|
||||||
|
import {
|
||||||
|
buildSemanticTree,
|
||||||
|
getTemplateSemanticManifest,
|
||||||
|
semanticNodeKeys,
|
||||||
|
shouldShowResumeHeader,
|
||||||
|
} from "@reactive-resume/pdf/semantic-tree";
|
||||||
|
import { orpc } from "@/libs/orpc/client";
|
||||||
|
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||||
|
|
||||||
|
export type StylesheetCanonicalState = {
|
||||||
|
stylesheet: SemanticStylesheet;
|
||||||
|
revision: number;
|
||||||
|
renderDataVersion: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StylesheetMutationResult = StylesheetCanonicalState & {
|
||||||
|
editGeneration: number;
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type EditMutation = {
|
||||||
|
id: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
expectedRenderDataVersion: number;
|
||||||
|
editGeneration: number;
|
||||||
|
transition: "edit_source";
|
||||||
|
source: StylesheetSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RestoreMutation = {
|
||||||
|
id: string;
|
||||||
|
expectedRevision: number;
|
||||||
|
expectedRenderDataVersion: number;
|
||||||
|
editGeneration: number;
|
||||||
|
transition: "restore_history";
|
||||||
|
restore: SemanticStylesheet;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivateMutation = Omit<EditMutation, "transition"> & { transition: "activate" };
|
||||||
|
type DeactivateMutation = Omit<EditMutation, "transition" | "source"> & { transition: "deactivate" };
|
||||||
|
type StylesheetMutation = EditMutation | RestoreMutation | ActivateMutation | DeactivateMutation;
|
||||||
|
|
||||||
|
type Candidate =
|
||||||
|
| { generation: number; transition: "edit_source"; source: StylesheetSource }
|
||||||
|
| { generation: number; transition: "restore_history"; restore: SemanticStylesheet }
|
||||||
|
| { generation: number; transition: "activate"; source: StylesheetSource }
|
||||||
|
| { generation: number; transition: "deactivate" };
|
||||||
|
|
||||||
|
export type StylesheetStoreState = {
|
||||||
|
resumeId?: string;
|
||||||
|
mode: SemanticStylesheet["mode"];
|
||||||
|
source: StylesheetSource;
|
||||||
|
applied: StylesheetSource;
|
||||||
|
revision: number;
|
||||||
|
renderDataVersion: number;
|
||||||
|
editGeneration: number;
|
||||||
|
diagnostics: readonly SemanticCssDiagnostic[];
|
||||||
|
colorTokens: readonly SemanticCssColorToken[];
|
||||||
|
editorMetadata: SemanticCssEditorMetadata;
|
||||||
|
status: "idle" | "compiling" | "preflighting" | "saving" | "applied" | "error";
|
||||||
|
restoreLocked: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
undoStack: SemanticStylesheet[];
|
||||||
|
redoStack: SemanticStylesheet[];
|
||||||
|
setSourceText(text: string): void;
|
||||||
|
setFocused(focused: boolean): void;
|
||||||
|
activate(): void;
|
||||||
|
deactivate(): void;
|
||||||
|
undo(): void;
|
||||||
|
redo(): void;
|
||||||
|
refreshIntelligence(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RuntimeDependencies = {
|
||||||
|
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse>;
|
||||||
|
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse>;
|
||||||
|
mutate(input: StylesheetMutation, signal: AbortSignal): Promise<StylesheetMutationResult>;
|
||||||
|
destroy?(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateStylesheetStoreRuntimeOptions = RuntimeDependencies & {
|
||||||
|
resumeId: string;
|
||||||
|
initial: StylesheetCanonicalState;
|
||||||
|
resumeData: ResumeData;
|
||||||
|
debounceMs?: number;
|
||||||
|
store?: StoreApi<StylesheetStoreState>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const emptySource = (): StylesheetSource => ({ languageVersion: 1, text: "@version 1;\n" });
|
||||||
|
const emptySemanticTree = (): SemanticNode => ({
|
||||||
|
key: "resume",
|
||||||
|
kind: "resume",
|
||||||
|
attributes: {},
|
||||||
|
roles: [],
|
||||||
|
children: [],
|
||||||
|
});
|
||||||
|
const HISTORY_COALESCE_MS = 500;
|
||||||
|
const MAX_HISTORY_ENTRIES = 50;
|
||||||
|
|
||||||
|
const inactiveState = (): Omit<
|
||||||
|
StylesheetStoreState,
|
||||||
|
"setSourceText" | "setFocused" | "activate" | "deactivate" | "undo" | "redo" | "refreshIntelligence"
|
||||||
|
> => ({
|
||||||
|
resumeId: undefined,
|
||||||
|
mode: "legacy",
|
||||||
|
source: emptySource(),
|
||||||
|
applied: emptySource(),
|
||||||
|
revision: 0,
|
||||||
|
renderDataVersion: 0,
|
||||||
|
editGeneration: 0,
|
||||||
|
diagnostics: [],
|
||||||
|
colorTokens: [],
|
||||||
|
editorMetadata: { semanticTree: emptySemanticTree(), templateParts: [] },
|
||||||
|
status: "idle",
|
||||||
|
restoreLocked: false,
|
||||||
|
focused: false,
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const sourceFromText = (source: StylesheetSource, text: string): StylesheetSource => ({ ...source, text });
|
||||||
|
const sourcesEqual = (left: StylesheetSource, right: StylesheetSource) =>
|
||||||
|
left.languageVersion === right.languageVersion && left.text === right.text;
|
||||||
|
const isEditorFocused = () =>
|
||||||
|
typeof document !== "undefined" && document.activeElement instanceof HTMLElement
|
||||||
|
? document.activeElement.closest(".cm-editor") !== null
|
||||||
|
: false;
|
||||||
|
const currentStylesheet = (state: StylesheetStoreState): SemanticStylesheet => ({
|
||||||
|
mode: state.mode,
|
||||||
|
source: structuredClone(state.source),
|
||||||
|
applied: structuredClone(state.applied),
|
||||||
|
});
|
||||||
|
const appendHistory = (stack: SemanticStylesheet[], value: SemanticStylesheet) =>
|
||||||
|
[...stack, value].slice(-MAX_HISTORY_ENTRIES);
|
||||||
|
|
||||||
|
const pageDimensions = (data: ResumeData) => {
|
||||||
|
const format = data.metadata.page.format;
|
||||||
|
const size = format === "letter" ? { width: 612, height: 792 } : { width: 595.28, height: 841.89 };
|
||||||
|
return data.metadata.layout.pages.map((_page, index) => ({
|
||||||
|
pageKey: semanticNodeKeys.page(index + 1),
|
||||||
|
...size,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createEditorMetadata = (data: ResumeData): SemanticCssEditorMetadata => {
|
||||||
|
const pages = data.metadata.layout.pages.map((page, index) =>
|
||||||
|
buildSemanticTree({
|
||||||
|
data,
|
||||||
|
template: data.metadata.template,
|
||||||
|
page,
|
||||||
|
pageNumber: index + 1,
|
||||||
|
showHeader: shouldShowResumeHeader(data, index),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const semanticTree: SemanticNode = {
|
||||||
|
key: semanticNodeKeys.resume(),
|
||||||
|
kind: "resume",
|
||||||
|
attributes: { template: data.metadata.template },
|
||||||
|
roles: [],
|
||||||
|
children: pages.flatMap(({ children }) => children),
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
semanticTree,
|
||||||
|
templateParts: getTemplateSemanticManifest(data.metadata.template).parts.map(({ name }) => name),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const compileInput = (
|
||||||
|
data: ResumeData,
|
||||||
|
source: StylesheetSource,
|
||||||
|
editGeneration: number,
|
||||||
|
semanticTree: SemanticNode,
|
||||||
|
): CompileWorkerInput => {
|
||||||
|
return {
|
||||||
|
editGeneration,
|
||||||
|
source,
|
||||||
|
semanticTree,
|
||||||
|
baseSettings: {
|
||||||
|
picture: data.picture,
|
||||||
|
template: data.metadata.template,
|
||||||
|
design: data.metadata.design,
|
||||||
|
typography: data.metadata.typography,
|
||||||
|
page: data.metadata.page,
|
||||||
|
layout: { sidebarWidth: data.metadata.layout.sidebarWidth },
|
||||||
|
},
|
||||||
|
pages: pageDimensions(data),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const conflictState = (error: unknown): StylesheetCanonicalState | undefined => {
|
||||||
|
if (!error || typeof error !== "object") return;
|
||||||
|
const value = error as { code?: string; data?: { state?: StylesheetCanonicalState } };
|
||||||
|
return value.code === "STYLESHEET_REVISION_CONFLICT" ? value.data?.state : undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createStylesheetStoreRuntime(options: CreateStylesheetStoreRuntimeOptions) {
|
||||||
|
let resumeData = structuredClone(options.resumeData);
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
let inFlight: Candidate | undefined;
|
||||||
|
let pending: Candidate | undefined;
|
||||||
|
let latestCandidate: Candidate | undefined;
|
||||||
|
let deferredCanonical: StylesheetCanonicalState | undefined;
|
||||||
|
let validationEpoch = 0;
|
||||||
|
let intelligenceEpoch = 0;
|
||||||
|
let historyLastEditAt = 0;
|
||||||
|
let historyCanCoalesce = false;
|
||||||
|
let destroyed = false;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
const debounceMs = options.debounceMs ?? 180;
|
||||||
|
const initial = options.initial.stylesheet;
|
||||||
|
let editorMetadata = createEditorMetadata(resumeData);
|
||||||
|
const store =
|
||||||
|
options.store ??
|
||||||
|
createStore<StylesheetStoreState>(() => ({
|
||||||
|
...inactiveState(),
|
||||||
|
setSourceText: () => {},
|
||||||
|
setFocused: () => {},
|
||||||
|
activate: () => {},
|
||||||
|
deactivate: () => {},
|
||||||
|
undo: () => {},
|
||||||
|
redo: () => {},
|
||||||
|
refreshIntelligence: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const patch = (next: Partial<StylesheetStoreState>) => store.setState(next);
|
||||||
|
const replaceCanonical = (canonical: StylesheetCanonicalState, preserveSource: boolean) => {
|
||||||
|
const state = store.getState();
|
||||||
|
const next: Partial<StylesheetStoreState> = {
|
||||||
|
revision: Math.max(state.revision, canonical.revision),
|
||||||
|
renderDataVersion: Math.max(state.renderDataVersion, canonical.renderDataVersion),
|
||||||
|
};
|
||||||
|
if (canonical.revision >= state.revision) {
|
||||||
|
next.mode = canonical.stylesheet.mode;
|
||||||
|
const nextSource = preserveSource ? state.source : canonical.stylesheet.source;
|
||||||
|
next.source = nextSource;
|
||||||
|
next.applied = canonical.stylesheet.applied;
|
||||||
|
if (!sourcesEqual(nextSource, state.source)) {
|
||||||
|
intelligenceEpoch += 1;
|
||||||
|
next.colorTokens = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
patch(next);
|
||||||
|
};
|
||||||
|
const resetHistoryCoalescing = () => {
|
||||||
|
historyLastEditAt = 0;
|
||||||
|
historyCanCoalesce = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const startNext = () => {
|
||||||
|
if (destroyed || inFlight || !pending) return;
|
||||||
|
const candidate = pending;
|
||||||
|
pending = undefined;
|
||||||
|
inFlight = candidate;
|
||||||
|
const requestValidationEpoch = validationEpoch;
|
||||||
|
const state = store.getState();
|
||||||
|
const common = {
|
||||||
|
id: options.resumeId,
|
||||||
|
expectedRevision: state.revision,
|
||||||
|
expectedRenderDataVersion: state.renderDataVersion,
|
||||||
|
editGeneration: candidate.generation,
|
||||||
|
};
|
||||||
|
let input: StylesheetMutation;
|
||||||
|
if (candidate.transition === "edit_source" || candidate.transition === "activate") {
|
||||||
|
input = { ...common, transition: candidate.transition, source: candidate.source };
|
||||||
|
} else if (candidate.transition === "restore_history") {
|
||||||
|
input = { ...common, transition: "restore_history", restore: candidate.restore };
|
||||||
|
} else {
|
||||||
|
input = { ...common, transition: "deactivate" };
|
||||||
|
}
|
||||||
|
patch({ status: "saving" });
|
||||||
|
|
||||||
|
void options
|
||||||
|
.mutate(input, abortController.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
const state = store.getState();
|
||||||
|
const staleStylesheet = result.revision < state.revision;
|
||||||
|
patch({
|
||||||
|
revision: Math.max(state.revision, result.revision),
|
||||||
|
renderDataVersion: Math.max(state.renderDataVersion, result.renderDataVersion),
|
||||||
|
});
|
||||||
|
if (result.editGeneration !== store.getState().editGeneration) return;
|
||||||
|
if (staleStylesheet) return;
|
||||||
|
const sourceChanged = !sourcesEqual(result.stylesheet.source, state.source);
|
||||||
|
if (sourceChanged) intelligenceEpoch += 1;
|
||||||
|
patch({
|
||||||
|
mode: result.stylesheet.mode,
|
||||||
|
source: result.stylesheet.source,
|
||||||
|
applied: result.stylesheet.applied,
|
||||||
|
diagnostics: result.diagnostics,
|
||||||
|
colorTokens: sourceChanged ? [] : state.colorTokens,
|
||||||
|
status: result.diagnostics.some(({ severity }) => severity === "error") ? "error" : "applied",
|
||||||
|
});
|
||||||
|
if (latestCandidate?.generation === result.editGeneration) latestCandidate = undefined;
|
||||||
|
if (deferredCanonical && result.revision >= deferredCanonical.revision) deferredCanonical = undefined;
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
if (destroyed) return;
|
||||||
|
const canonical = conflictState(error);
|
||||||
|
if (!canonical) {
|
||||||
|
patch({ status: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceCanonical(canonical, true);
|
||||||
|
if (requestValidationEpoch === validationEpoch) pending ??= candidate;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
inFlight = undefined;
|
||||||
|
startNext();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const queue = (candidate: Candidate) => {
|
||||||
|
latestCandidate = candidate;
|
||||||
|
pending = candidate;
|
||||||
|
startNext();
|
||||||
|
};
|
||||||
|
|
||||||
|
const processCandidate = async (candidate: Candidate) => {
|
||||||
|
if (destroyed || candidate.generation !== store.getState().editGeneration) return;
|
||||||
|
const candidateValidationEpoch = validationEpoch;
|
||||||
|
if (candidate.transition === "deactivate") {
|
||||||
|
queue(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const source = candidate.transition === "restore_history" ? candidate.restore.applied : candidate.source;
|
||||||
|
patch({ status: "compiling" });
|
||||||
|
let compiled: CompileWorkerResponse;
|
||||||
|
try {
|
||||||
|
compiled = await options.compile(
|
||||||
|
compileInput(resumeData, source, candidate.generation, editorMetadata.semanticTree),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidateValidationEpoch !== validationEpoch) return;
|
||||||
|
if (destroyed || compiled.editGeneration !== store.getState().editGeneration) return;
|
||||||
|
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||||
|
|
||||||
|
if (!compiled.program) {
|
||||||
|
if (candidate.transition === "edit_source") queue(candidate);
|
||||||
|
else patch({ status: "error" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compiled.program) {
|
||||||
|
patch({ status: "preflighting" });
|
||||||
|
let preflight: PreflightWorkerResponse;
|
||||||
|
try {
|
||||||
|
preflight = await options.preflight({
|
||||||
|
editGeneration: candidate.generation,
|
||||||
|
input: { data: resumeData, template: resumeData.metadata.template, stylesheet: source },
|
||||||
|
limits: {
|
||||||
|
maxPages: 20,
|
||||||
|
maxBytes: 10_000_000,
|
||||||
|
maxPageWidthPt: 2_000,
|
||||||
|
maxPageHeightPt: 20_000,
|
||||||
|
maxPageAreaPt2: 20_000_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (candidateValidationEpoch !== validationEpoch) return;
|
||||||
|
if (candidate.generation !== store.getState().editGeneration) return;
|
||||||
|
patch({ status: "error" });
|
||||||
|
if (candidate.transition === "edit_source") queue(candidate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (candidateValidationEpoch !== validationEpoch) return;
|
||||||
|
if (destroyed || preflight.editGeneration !== store.getState().editGeneration) return;
|
||||||
|
if (!preflight.result.ok) {
|
||||||
|
patch({ diagnostics: [...compiled.diagnostics, ...preflight.result.diagnostics], status: "error" });
|
||||||
|
if (candidate.transition !== "edit_source") return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
queue(candidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = (candidate: Candidate) => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
latestCandidate = candidate;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = undefined;
|
||||||
|
void processCandidate(candidate);
|
||||||
|
}, debounceMs);
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = (target: SemanticStylesheet, opposite: "undoStack" | "redoStack") => {
|
||||||
|
const state = store.getState();
|
||||||
|
const stack = opposite === "undoStack" ? state.undoStack : state.redoStack;
|
||||||
|
const previous = stack.at(-1);
|
||||||
|
if (!previous) return;
|
||||||
|
const generation = state.editGeneration + 1;
|
||||||
|
const other = opposite === "undoStack" ? "redoStack" : "undoStack";
|
||||||
|
resetHistoryCoalescing();
|
||||||
|
patch({
|
||||||
|
source: previous.source,
|
||||||
|
editGeneration: generation,
|
||||||
|
colorTokens: [],
|
||||||
|
[opposite]: stack.slice(0, -1),
|
||||||
|
[other]: appendHistory(state[other], target),
|
||||||
|
canUndo: opposite === "redoStack" || stack.length > 1,
|
||||||
|
canRedo: opposite === "undoStack" || stack.length > 1,
|
||||||
|
});
|
||||||
|
schedule({ generation, transition: "restore_history", restore: previous });
|
||||||
|
};
|
||||||
|
|
||||||
|
store.setState({
|
||||||
|
resumeId: options.resumeId,
|
||||||
|
mode: initial.mode,
|
||||||
|
source: structuredClone(initial.source),
|
||||||
|
applied: structuredClone(initial.applied),
|
||||||
|
revision: options.initial.revision,
|
||||||
|
renderDataVersion: options.initial.renderDataVersion,
|
||||||
|
editGeneration: 0,
|
||||||
|
diagnostics: [],
|
||||||
|
colorTokens: [],
|
||||||
|
editorMetadata,
|
||||||
|
status: "idle",
|
||||||
|
restoreLocked: false,
|
||||||
|
focused: false,
|
||||||
|
undoStack: [],
|
||||||
|
redoStack: [],
|
||||||
|
canUndo: false,
|
||||||
|
canRedo: false,
|
||||||
|
setSourceText(text) {
|
||||||
|
const state = store.getState();
|
||||||
|
if (state.restoreLocked || text === state.source.text) return;
|
||||||
|
const generation = state.editGeneration + 1;
|
||||||
|
const nextSource = sourceFromText(state.source, text);
|
||||||
|
const now = Date.now();
|
||||||
|
const undoStack =
|
||||||
|
historyCanCoalesce && now - historyLastEditAt <= HISTORY_COALESCE_MS
|
||||||
|
? state.undoStack
|
||||||
|
: appendHistory(state.undoStack, currentStylesheet(state));
|
||||||
|
historyLastEditAt = now;
|
||||||
|
historyCanCoalesce = true;
|
||||||
|
patch({
|
||||||
|
source: nextSource,
|
||||||
|
editGeneration: generation,
|
||||||
|
colorTokens: [],
|
||||||
|
undoStack,
|
||||||
|
redoStack: [],
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
schedule({ generation, transition: "edit_source", source: nextSource });
|
||||||
|
},
|
||||||
|
setFocused(focused) {
|
||||||
|
patch({ focused });
|
||||||
|
if (focused || !deferredCanonical) return;
|
||||||
|
const canonical = deferredCanonical;
|
||||||
|
deferredCanonical = undefined;
|
||||||
|
const candidate = latestCandidate;
|
||||||
|
const hasLocalDraft =
|
||||||
|
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||||
|
replaceCanonical(canonical, hasLocalDraft);
|
||||||
|
if (hasLocalDraft && candidate) schedule(candidate);
|
||||||
|
else resetHistoryCoalescing();
|
||||||
|
},
|
||||||
|
activate() {
|
||||||
|
const state = store.getState();
|
||||||
|
if (state.restoreLocked || state.mode === "semantic") return;
|
||||||
|
const generation = state.editGeneration + 1;
|
||||||
|
resetHistoryCoalescing();
|
||||||
|
patch({
|
||||||
|
editGeneration: generation,
|
||||||
|
colorTokens: [],
|
||||||
|
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||||
|
redoStack: [],
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
schedule({ generation, transition: "activate", source: state.source });
|
||||||
|
},
|
||||||
|
deactivate() {
|
||||||
|
const state = store.getState();
|
||||||
|
if (state.restoreLocked || state.mode === "legacy") return;
|
||||||
|
const generation = state.editGeneration + 1;
|
||||||
|
resetHistoryCoalescing();
|
||||||
|
patch({
|
||||||
|
editGeneration: generation,
|
||||||
|
colorTokens: [],
|
||||||
|
undoStack: appendHistory(state.undoStack, currentStylesheet(state)),
|
||||||
|
redoStack: [],
|
||||||
|
canUndo: true,
|
||||||
|
canRedo: false,
|
||||||
|
});
|
||||||
|
queue({ generation, transition: "deactivate" });
|
||||||
|
},
|
||||||
|
undo() {
|
||||||
|
if (store.getState().restoreLocked) return;
|
||||||
|
restore(currentStylesheet(store.getState()), "undoStack");
|
||||||
|
},
|
||||||
|
redo() {
|
||||||
|
if (store.getState().restoreLocked) return;
|
||||||
|
restore(currentStylesheet(store.getState()), "redoStack");
|
||||||
|
},
|
||||||
|
refreshIntelligence() {
|
||||||
|
const state = store.getState();
|
||||||
|
const generation = state.editGeneration;
|
||||||
|
const source = structuredClone(state.source);
|
||||||
|
const requestEpoch = ++intelligenceEpoch;
|
||||||
|
void options
|
||||||
|
.compile(compileInput(resumeData, source, generation, editorMetadata.semanticTree))
|
||||||
|
.then((compiled) => {
|
||||||
|
const current = store.getState();
|
||||||
|
if (
|
||||||
|
destroyed ||
|
||||||
|
requestEpoch !== intelligenceEpoch ||
|
||||||
|
current.editGeneration !== generation ||
|
||||||
|
!sourcesEqual(current.source, source)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
patch({ diagnostics: compiled.diagnostics, colorTokens: compiled.colorTokens ?? [] });
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
store,
|
||||||
|
replaceResumeSnapshot(data: ResumeData, canonical: StylesheetCanonicalState) {
|
||||||
|
const candidate = latestCandidate;
|
||||||
|
resumeData = structuredClone(data);
|
||||||
|
editorMetadata = createEditorMetadata(resumeData);
|
||||||
|
patch({ editorMetadata });
|
||||||
|
const renderDataChanged = canonical.renderDataVersion > store.getState().renderDataVersion;
|
||||||
|
const preserveSource = store.getState().focused || isEditorFocused() || candidate !== undefined;
|
||||||
|
replaceCanonical(canonical, preserveSource);
|
||||||
|
if (renderDataChanged) {
|
||||||
|
validationEpoch += 1;
|
||||||
|
pending = undefined;
|
||||||
|
if (candidate) schedule(candidate);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
rebaseCanonical(canonical: StylesheetCanonicalState) {
|
||||||
|
const candidate = latestCandidate;
|
||||||
|
const hasLocalDraft =
|
||||||
|
candidate !== undefined && store.getState().source.text !== canonical.stylesheet.source.text;
|
||||||
|
const focused = store.getState().focused || isEditorFocused();
|
||||||
|
const sourceChanged = store.getState().source.text !== canonical.stylesheet.source.text;
|
||||||
|
if (focused && sourceChanged && canonical.revision >= store.getState().revision) deferredCanonical = canonical;
|
||||||
|
const preserveSource = (focused && sourceChanged) || hasLocalDraft;
|
||||||
|
replaceCanonical(canonical, preserveSource);
|
||||||
|
if (hasLocalDraft && candidate) schedule(candidate);
|
||||||
|
else if (!preserveSource) resetHistoryCoalescing();
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
abortController.abort();
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
timer = undefined;
|
||||||
|
pending = undefined;
|
||||||
|
latestCandidate = undefined;
|
||||||
|
deferredCanonical = undefined;
|
||||||
|
options.destroy?.();
|
||||||
|
store.setState(inactiveState());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStylesheetStore = create<StylesheetStoreState>(() => ({
|
||||||
|
...inactiveState(),
|
||||||
|
setSourceText: () => {},
|
||||||
|
setFocused: () => {},
|
||||||
|
activate: () => {},
|
||||||
|
deactivate: () => {},
|
||||||
|
undo: () => {},
|
||||||
|
redo: () => {},
|
||||||
|
refreshIntelligence: () => {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
let activeRuntime: ReturnType<typeof createStylesheetStoreRuntime> | undefined;
|
||||||
|
declare const stylesheetRuntimeTokenBrand: unique symbol;
|
||||||
|
export type StylesheetRuntimeToken = Readonly<{ [stylesheetRuntimeTokenBrand]: true }>;
|
||||||
|
let activeRuntimeToken: StylesheetRuntimeToken | undefined;
|
||||||
|
|
||||||
|
const compilerClient = () =>
|
||||||
|
createCompileWorkerClient(
|
||||||
|
() =>
|
||||||
|
new Worker(new URL("./stylesheet.worker.ts", import.meta.url), { type: "module", name: "semantic-css-compiler" }),
|
||||||
|
);
|
||||||
|
const preflightClient = () =>
|
||||||
|
createPreflightWorkerClient(
|
||||||
|
() =>
|
||||||
|
new Worker(new URL("./preflight.worker.ts", import.meta.url), { type: "module", name: "semantic-css-preflight" }),
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
export function initializeStylesheetStore(input: {
|
||||||
|
resumeId: string;
|
||||||
|
initial: StylesheetCanonicalState;
|
||||||
|
resumeData: ResumeData;
|
||||||
|
}) {
|
||||||
|
activeRuntime?.destroy();
|
||||||
|
const compiler = compilerClient();
|
||||||
|
const preflight = preflightClient();
|
||||||
|
preflight.warmup();
|
||||||
|
const runtime = createStylesheetStoreRuntime({
|
||||||
|
...input,
|
||||||
|
store: useStylesheetStore,
|
||||||
|
compile: compiler.compile,
|
||||||
|
preflight: preflight.preflight,
|
||||||
|
mutate: (mutation, signal) => orpc.resume.stylesheet.mutate.call(mutation, { signal }),
|
||||||
|
destroy: () => {
|
||||||
|
compiler.destroy();
|
||||||
|
preflight.destroy();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
activeRuntime = runtime;
|
||||||
|
activeRuntimeToken = {} as StylesheetRuntimeToken;
|
||||||
|
return () => {
|
||||||
|
if (activeRuntime?.store.getState().resumeId !== input.resumeId) return;
|
||||||
|
activeRuntime.destroy();
|
||||||
|
activeRuntime = undefined;
|
||||||
|
activeRuntimeToken = undefined;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockStylesheetStoreForRestore(resumeId: string): StylesheetRuntimeToken | undefined {
|
||||||
|
if (!activeRuntime || !activeRuntimeToken) return;
|
||||||
|
const state = activeRuntime.store.getState();
|
||||||
|
if (state.resumeId !== resumeId || state.restoreLocked) return;
|
||||||
|
activeRuntime.store.setState({ restoreLocked: true });
|
||||||
|
return activeRuntimeToken;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unlockStylesheetStoreAfterRestore(token: StylesheetRuntimeToken | undefined): boolean {
|
||||||
|
if (!activeRuntime || !token || activeRuntimeToken !== token) return false;
|
||||||
|
activeRuntime.store.setState({ restoreLocked: false });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function replaceStylesheetStoreAfterRestore(input: {
|
||||||
|
resumeId: string;
|
||||||
|
initial: StylesheetCanonicalState;
|
||||||
|
resumeData: ResumeData;
|
||||||
|
token: StylesheetRuntimeToken | undefined;
|
||||||
|
}): boolean {
|
||||||
|
if (
|
||||||
|
!activeRuntime ||
|
||||||
|
!input.token ||
|
||||||
|
activeRuntimeToken !== input.token ||
|
||||||
|
activeRuntime.store.getState().resumeId !== input.resumeId ||
|
||||||
|
!activeRuntime.store.getState().restoreLocked
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
initializeStylesheetStore(input);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshStylesheetStore(resumeId: string, resumeData?: ResumeData) {
|
||||||
|
if (!activeRuntime || activeRuntime.store.getState().resumeId !== resumeId) return;
|
||||||
|
const canonical = await orpc.resume.stylesheet.getState.call({ id: resumeId });
|
||||||
|
if (resumeData) activeRuntime.replaceResumeSnapshot(resumeData, canonical);
|
||||||
|
else activeRuntime.rebaseCanonical(canonical);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/// <reference lib="webworker" />
|
||||||
|
|
||||||
|
import type { CompileWorkerRequest, CompileWorkerResponse } from "./protocol";
|
||||||
|
import { analyzeStylesheet, compileStylesheet } from "@reactive-resume/resume/stylesheet";
|
||||||
|
import { collectCompiledColorTokens } from "./color-tokens";
|
||||||
|
|
||||||
|
self.addEventListener("message", ({ data }: MessageEvent<CompileWorkerRequest>) => {
|
||||||
|
if (data.type !== "compile") return;
|
||||||
|
const compiled = compileStylesheet(data.source);
|
||||||
|
const diagnostics = compiled.program
|
||||||
|
? [...compiled.diagnostics, ...analyzeStylesheet(compiled.program, data.semanticTree)]
|
||||||
|
: compiled.diagnostics;
|
||||||
|
const response: CompileWorkerResponse = {
|
||||||
|
type: "compile_result",
|
||||||
|
requestId: data.requestId,
|
||||||
|
editGeneration: data.editGeneration,
|
||||||
|
program: compiled.program,
|
||||||
|
diagnostics,
|
||||||
|
colorTokens: collectCompiledColorTokens(data.source.text, compiled.program),
|
||||||
|
};
|
||||||
|
self.postMessage(response);
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { t } from "@lingui/core/macro";
|
||||||
|
import {
|
||||||
|
ArrowCounterClockwiseIcon,
|
||||||
|
ArrowsInIcon,
|
||||||
|
ArrowsOutIcon,
|
||||||
|
ArrowUUpLeftIcon,
|
||||||
|
ArrowUUpRightIcon,
|
||||||
|
CopyIcon,
|
||||||
|
MagicWandIcon,
|
||||||
|
} from "@phosphor-icons/react";
|
||||||
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip";
|
||||||
|
import { copySourceToClipboard } from "./editor-extensions";
|
||||||
|
|
||||||
|
type ToolbarButtonProps = {
|
||||||
|
label: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onClick(): void;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ToolbarButton({ label, disabled, onClick, children }: ToolbarButtonProps) {
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger
|
||||||
|
render={
|
||||||
|
<Button type="button" size="icon-sm" variant="ghost" aria-label={label} disabled={disabled} onClick={onClick}>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<TooltipContent>{label}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type StylesheetToolbarProps = {
|
||||||
|
source: string;
|
||||||
|
canUndo: boolean;
|
||||||
|
canRedo: boolean;
|
||||||
|
focused: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
onUndo(): void;
|
||||||
|
onRedo(): void;
|
||||||
|
onFormat(): void;
|
||||||
|
onReset(): void;
|
||||||
|
onFocusToggle(): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function StylesheetToolbar({
|
||||||
|
source,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
focused,
|
||||||
|
disabled = false,
|
||||||
|
onUndo,
|
||||||
|
onRedo,
|
||||||
|
onFormat,
|
||||||
|
onReset,
|
||||||
|
onFocusToggle,
|
||||||
|
}: StylesheetToolbarProps) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap items-center gap-1" role="toolbar" aria-label={t`Stylesheet editor`}>
|
||||||
|
<ToolbarButton label={t`Undo stylesheet edit`} disabled={disabled || !canUndo} onClick={onUndo}>
|
||||||
|
<ArrowUUpLeftIcon data-icon="inline-start" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton label={t`Redo stylesheet edit`} disabled={disabled || !canRedo} onClick={onRedo}>
|
||||||
|
<ArrowUUpRightIcon data-icon="inline-start" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton label={t`Copy stylesheet`} onClick={() => void copySourceToClipboard(source)}>
|
||||||
|
<CopyIcon data-icon="inline-start" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton label={t`Format stylesheet`} disabled={disabled} onClick={onFormat}>
|
||||||
|
<MagicWandIcon data-icon="inline-start" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton label={t`Reset to applied stylesheet`} disabled={disabled} onClick={onReset}>
|
||||||
|
<ArrowCounterClockwiseIcon data-icon="inline-start" />
|
||||||
|
</ToolbarButton>
|
||||||
|
<ToolbarButton label={focused ? t`Exit focus mode` : t`Open focus mode`} onClick={onFocusToggle}>
|
||||||
|
{focused ? <ArrowsInIcon data-icon="inline-start" /> : <ArrowsOutIcon data-icon="inline-start" />}
|
||||||
|
</ToolbarButton>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,195 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { getPreflightTransferables } from "./protocol";
|
||||||
|
import { createCompileWorkerClient, createPreflightWorkerClient } from "./worker-client";
|
||||||
|
|
||||||
|
type Listener = (event: MessageEvent) => void;
|
||||||
|
|
||||||
|
function worker() {
|
||||||
|
const listeners = new Set<Listener>();
|
||||||
|
return {
|
||||||
|
postMessage: vi.fn(),
|
||||||
|
terminate: vi.fn(),
|
||||||
|
addEventListener: vi.fn((_type: string, listener: Listener) => listeners.add(listener)),
|
||||||
|
removeEventListener: vi.fn((_type: string, listener: Listener) => listeners.delete(listener)),
|
||||||
|
emit(data: unknown) {
|
||||||
|
for (const listener of listeners) listener(new MessageEvent("message", { data }));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("stylesheet worker clients", () => {
|
||||||
|
it("rejects stale compiler results by request id", async () => {
|
||||||
|
const fake = worker();
|
||||||
|
const client = createCompileWorkerClient(() => fake);
|
||||||
|
const first = client.compile({ editGeneration: 1 } as never);
|
||||||
|
const second = client.compile({ editGeneration: 2 } as never);
|
||||||
|
|
||||||
|
fake.emit({ type: "compile_result", requestId: 1, editGeneration: 1, program: null, diagnostics: [] });
|
||||||
|
fake.emit({ type: "compile_result", requestId: 2, editGeneration: 2, program: null, diagnostics: [] });
|
||||||
|
|
||||||
|
await expect(first).rejects.toThrow("stale");
|
||||||
|
await expect(second).resolves.toMatchObject({ requestId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates and recreates a timed-out preflight worker", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = worker();
|
||||||
|
const replacement = worker();
|
||||||
|
const createWorker = vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement);
|
||||||
|
const client = createPreflightWorkerClient(createWorker, 10);
|
||||||
|
|
||||||
|
const timedOut = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
first.emit({ type: "preflight_ready" });
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
await expect(timedOut).resolves.toMatchObject({
|
||||||
|
result: { ok: false, code: "STYLESHEET_PREFLIGHT_TIMEOUT" },
|
||||||
|
});
|
||||||
|
expect(first.terminate).toHaveBeenCalledOnce();
|
||||||
|
|
||||||
|
const next = client.preflight({ editGeneration: 2 } as never);
|
||||||
|
replacement.emit({ type: "preflight_ready" });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
replacement.emit({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 2,
|
||||||
|
editGeneration: 2,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||||
|
});
|
||||||
|
await expect(next).resolves.toMatchObject({ requestId: 2 });
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("warms the preflight worker before a request starts its deadline", () => {
|
||||||
|
const fake = worker();
|
||||||
|
const createWorker = vi.fn(() => fake);
|
||||||
|
const client = createPreflightWorkerClient(createWorker, 5_000);
|
||||||
|
|
||||||
|
client.warmup();
|
||||||
|
|
||||||
|
expect(createWorker).toHaveBeenCalledOnce();
|
||||||
|
expect(fake.addEventListener).toHaveBeenCalledOnce();
|
||||||
|
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("waits for readiness without consuming the request deadline", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fake = worker();
|
||||||
|
const client = createPreflightWorkerClient(() => fake, 5, 20);
|
||||||
|
const result = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
expect(fake.terminate).not.toHaveBeenCalled();
|
||||||
|
expect(fake.postMessage).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
fake.emit({ type: "preflight_ready" });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
expect(fake.postMessage).toHaveBeenCalledOnce();
|
||||||
|
await vi.advanceTimersByTimeAsync(5);
|
||||||
|
await expect(result).resolves.toMatchObject({ result: { code: "STYLESHEET_PREFLIGHT_TIMEOUT" } });
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects structured resume-data failures without waiting for the timeout", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fake = worker();
|
||||||
|
const client = createPreflightWorkerClient(() => fake, 5_000);
|
||||||
|
const pending = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
const outcome = pending.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
fake.emit({ type: "preflight_ready" });
|
||||||
|
await vi.advanceTimersByTimeAsync(0);
|
||||||
|
fake.emit({
|
||||||
|
type: "preflight_error",
|
||||||
|
requestId: 1,
|
||||||
|
editGeneration: 1,
|
||||||
|
cause: {
|
||||||
|
name: "ZodError",
|
||||||
|
message: "Invalid resume data",
|
||||||
|
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await outcome).toMatchObject({
|
||||||
|
name: "ZodError",
|
||||||
|
issues: [{ path: ["customSections", 0, "items", 0, "company"] }],
|
||||||
|
});
|
||||||
|
await vi.advanceTimersByTimeAsync(5_000);
|
||||||
|
expect(fake.terminate).not.toHaveBeenCalled();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds readiness, recreates once, and rejects after the retry also times out", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const first = worker();
|
||||||
|
const replacement = worker();
|
||||||
|
const client = createPreflightWorkerClient(
|
||||||
|
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||||
|
5,
|
||||||
|
10,
|
||||||
|
);
|
||||||
|
const result = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
const rejection = expect(result).rejects.toThrow("did not become ready");
|
||||||
|
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
expect(first.terminate).toHaveBeenCalledOnce();
|
||||||
|
await vi.advanceTimersByTimeAsync(10);
|
||||||
|
|
||||||
|
await rejection;
|
||||||
|
expect(replacement.terminate).toHaveBeenCalledOnce();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not recreate a warming worker after destroy", async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const fake = worker();
|
||||||
|
const createWorker = vi.fn(() => fake);
|
||||||
|
const client = createPreflightWorkerClient(createWorker, 5, 10);
|
||||||
|
client.warmup();
|
||||||
|
|
||||||
|
client.destroy();
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
|
||||||
|
expect(createWorker).toHaveBeenCalledOnce();
|
||||||
|
expect(fake.terminate).toHaveBeenCalledOnce();
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("terminates stale preflight work when a newer request starts", async () => {
|
||||||
|
const first = worker();
|
||||||
|
const replacement = worker();
|
||||||
|
const client = createPreflightWorkerClient(
|
||||||
|
vi.fn().mockReturnValueOnce(first).mockReturnValueOnce(replacement),
|
||||||
|
1_000,
|
||||||
|
);
|
||||||
|
const stale = client.preflight({ editGeneration: 1 } as never);
|
||||||
|
first.emit({ type: "preflight_ready" });
|
||||||
|
await Promise.resolve();
|
||||||
|
const staleOutcome = stale.catch((error: unknown) => error);
|
||||||
|
const current = client.preflight({ editGeneration: 2 } as never);
|
||||||
|
|
||||||
|
expect(first.terminate).toHaveBeenCalledOnce();
|
||||||
|
replacement.emit({ type: "preflight_ready" });
|
||||||
|
await Promise.resolve();
|
||||||
|
replacement.emit({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 2,
|
||||||
|
editGeneration: 2,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf: new ArrayBuffer(4) },
|
||||||
|
});
|
||||||
|
expect(await staleOutcome).toEqual(expect.objectContaining({ message: expect.stringContaining("stale") }));
|
||||||
|
await expect(current).resolves.toMatchObject({ requestId: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("transfers the generated PDF buffer", () => {
|
||||||
|
const pdf = new ArrayBuffer(4);
|
||||||
|
expect(
|
||||||
|
getPreflightTransferables({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: 1,
|
||||||
|
editGeneration: 1,
|
||||||
|
result: { ok: true, pageCount: 1, byteCount: 4, diagnostics: [], pdf },
|
||||||
|
}),
|
||||||
|
).toEqual([pdf]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import type {
|
||||||
|
CompileWorkerInput,
|
||||||
|
CompileWorkerRequest,
|
||||||
|
CompileWorkerResponse,
|
||||||
|
PreflightWorkerError,
|
||||||
|
PreflightWorkerInput,
|
||||||
|
PreflightWorkerReady,
|
||||||
|
PreflightWorkerRequest,
|
||||||
|
PreflightWorkerResponse,
|
||||||
|
} from "./protocol";
|
||||||
|
|
||||||
|
type WorkerListener = (event: MessageEvent<unknown>) => void;
|
||||||
|
|
||||||
|
export type StylesheetWorker = {
|
||||||
|
postMessage(message: unknown, transfer?: Transferable[]): void;
|
||||||
|
terminate(): void;
|
||||||
|
addEventListener(type: "message", listener: WorkerListener): void;
|
||||||
|
removeEventListener(type: "message", listener: WorkerListener): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Pending<T> = {
|
||||||
|
resolve(value: T): void;
|
||||||
|
reject(error: Error): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createCompileWorkerClient(createWorker: () => StylesheetWorker) {
|
||||||
|
const worker = createWorker();
|
||||||
|
const pending = new Map<number, Pending<CompileWorkerResponse>>();
|
||||||
|
let latestRequestId = 0;
|
||||||
|
|
||||||
|
const onMessage: WorkerListener = ({ data }) => {
|
||||||
|
const response = data as CompileWorkerResponse;
|
||||||
|
if (response?.type !== "compile_result") return;
|
||||||
|
const request = pending.get(response.requestId);
|
||||||
|
if (!request) return;
|
||||||
|
pending.delete(response.requestId);
|
||||||
|
if (response.requestId !== latestRequestId) {
|
||||||
|
request.reject(new Error("Discarded stale stylesheet compiler result."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
request.resolve(response);
|
||||||
|
};
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
|
||||||
|
return {
|
||||||
|
compile(input: CompileWorkerInput): Promise<CompileWorkerResponse> {
|
||||||
|
const requestId = ++latestRequestId;
|
||||||
|
const request: CompileWorkerRequest = { ...input, type: "compile", requestId };
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(requestId, { resolve, reject });
|
||||||
|
worker.postMessage(request);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
worker.terminate();
|
||||||
|
for (const request of pending.values()) request.reject(new Error("Stylesheet compiler worker was terminated."));
|
||||||
|
pending.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const timeoutResult = (request: PreflightWorkerRequest): PreflightWorkerResponse => ({
|
||||||
|
type: "preflight_result",
|
||||||
|
requestId: request.requestId,
|
||||||
|
editGeneration: request.editGeneration,
|
||||||
|
result: {
|
||||||
|
ok: false,
|
||||||
|
code: "STYLESHEET_PREFLIGHT_TIMEOUT",
|
||||||
|
message: "The PDF preflight exceeded its deadline.",
|
||||||
|
diagnostics: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export function createPreflightWorkerClient(
|
||||||
|
createWorker: () => StylesheetWorker,
|
||||||
|
timeoutMs: number,
|
||||||
|
readinessTimeoutMs = 10_000,
|
||||||
|
) {
|
||||||
|
let worker: StylesheetWorker | undefined;
|
||||||
|
let requestId = 0;
|
||||||
|
let ready = false;
|
||||||
|
let destroyed = false;
|
||||||
|
let readiness:
|
||||||
|
| (Pending<StylesheetWorker> & {
|
||||||
|
promise: Promise<StylesheetWorker>;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
})
|
||||||
|
| undefined;
|
||||||
|
const pending = new Map<number, Pending<PreflightWorkerResponse> & { timer?: ReturnType<typeof setTimeout> }>();
|
||||||
|
|
||||||
|
const onMessage: WorkerListener = ({ data }) => {
|
||||||
|
if ((data as PreflightWorkerReady)?.type === "preflight_ready") {
|
||||||
|
if (!worker || !readiness) return;
|
||||||
|
clearTimeout(readiness.timer);
|
||||||
|
ready = true;
|
||||||
|
readiness.resolve(worker);
|
||||||
|
readiness = undefined;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const workerError = data as PreflightWorkerError;
|
||||||
|
if (workerError?.type === "preflight_error") {
|
||||||
|
const request = pending.get(workerError.requestId);
|
||||||
|
if (!request) return;
|
||||||
|
if (request.timer) clearTimeout(request.timer);
|
||||||
|
pending.delete(workerError.requestId);
|
||||||
|
request.reject(
|
||||||
|
Object.assign(new Error(workerError.cause.message), {
|
||||||
|
name: workerError.cause.name,
|
||||||
|
issues: workerError.cause.issues,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const response = data as PreflightWorkerResponse;
|
||||||
|
if (response?.type !== "preflight_result") return;
|
||||||
|
const request = pending.get(response.requestId);
|
||||||
|
if (!request) return;
|
||||||
|
if (request.timer) clearTimeout(request.timer);
|
||||||
|
pending.delete(response.requestId);
|
||||||
|
request.resolve(response);
|
||||||
|
};
|
||||||
|
|
||||||
|
const terminate = () => {
|
||||||
|
if (!worker) return;
|
||||||
|
worker.removeEventListener("message", onMessage);
|
||||||
|
worker.terminate();
|
||||||
|
worker = undefined;
|
||||||
|
ready = false;
|
||||||
|
if (readiness) {
|
||||||
|
clearTimeout(readiness.timer);
|
||||||
|
readiness.reject(new Error("Stylesheet preflight worker did not become ready."));
|
||||||
|
readiness = undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getReadyWorker = () => {
|
||||||
|
if (destroyed) return Promise.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||||
|
if (worker && ready) return Promise.resolve(worker);
|
||||||
|
if (readiness) return readiness.promise;
|
||||||
|
worker = createWorker();
|
||||||
|
worker.addEventListener("message", onMessage);
|
||||||
|
let resolve!: (value: StylesheetWorker) => void;
|
||||||
|
let reject!: (error: Error) => void;
|
||||||
|
const promise = new Promise<StylesheetWorker>((resolvePromise, rejectPromise) => {
|
||||||
|
resolve = resolvePromise;
|
||||||
|
reject = rejectPromise;
|
||||||
|
});
|
||||||
|
const timer = setTimeout(() => terminate(), readinessTimeoutMs);
|
||||||
|
readiness = { promise, resolve, reject, timer };
|
||||||
|
return promise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const waitUntilReady = async () => {
|
||||||
|
try {
|
||||||
|
return await getReadyWorker();
|
||||||
|
} catch {
|
||||||
|
return await getReadyWorker();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
warmup() {
|
||||||
|
void waitUntilReady().catch(() => {});
|
||||||
|
},
|
||||||
|
preflight(input: PreflightWorkerInput): Promise<PreflightWorkerResponse> {
|
||||||
|
if (pending.size > 0) {
|
||||||
|
terminate();
|
||||||
|
for (const stale of pending.values()) {
|
||||||
|
if (stale.timer) clearTimeout(stale.timer);
|
||||||
|
stale.reject(new Error("Discarded stale stylesheet preflight result."));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
}
|
||||||
|
const request: PreflightWorkerRequest = { ...input, type: "preflight", requestId: ++requestId };
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
pending.set(request.requestId, { resolve, reject });
|
||||||
|
void waitUntilReady()
|
||||||
|
.then((readyWorker) => {
|
||||||
|
const current = pending.get(request.requestId);
|
||||||
|
if (!current) return;
|
||||||
|
current.timer = setTimeout(() => {
|
||||||
|
pending.delete(request.requestId);
|
||||||
|
terminate();
|
||||||
|
resolve(timeoutResult(request));
|
||||||
|
}, timeoutMs);
|
||||||
|
readyWorker.postMessage(request);
|
||||||
|
})
|
||||||
|
.catch((error: unknown) => {
|
||||||
|
const current = pending.get(request.requestId);
|
||||||
|
if (!current) return;
|
||||||
|
pending.delete(request.requestId);
|
||||||
|
reject(error instanceof Error ? error : new Error("Stylesheet preflight worker failed to start."));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true;
|
||||||
|
terminate();
|
||||||
|
for (const request of pending.values()) {
|
||||||
|
if (request.timer) clearTimeout(request.timer);
|
||||||
|
request.reject(new Error("Stylesheet preflight worker was terminated."));
|
||||||
|
}
|
||||||
|
pending.clear();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -17,6 +17,11 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@reactive-resume/ui/components/dropdown-menu";
|
} from "@reactive-resume/ui/components/dropdown-menu";
|
||||||
import { useResumeStore } from "@/features/resume/builder/draft";
|
import { useResumeStore } from "@/features/resume/builder/draft";
|
||||||
|
import {
|
||||||
|
lockStylesheetStoreForRestore,
|
||||||
|
replaceStylesheetStoreAfterRestore,
|
||||||
|
unlockStylesheetStoreAfterRestore,
|
||||||
|
} from "@/features/resume/stylesheet/store";
|
||||||
import { useConfirm } from "@/hooks/use-confirm";
|
import { useConfirm } from "@/hooks/use-confirm";
|
||||||
import { getResumeErrorMessage } from "@/libs/error-message";
|
import { getResumeErrorMessage } from "@/libs/error-message";
|
||||||
import { formatRelativeTime } from "@/libs/locale";
|
import { formatRelativeTime } from "@/libs/locale";
|
||||||
@@ -39,7 +44,7 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
|
|||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
|
|
||||||
const { mutate: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
|
const { mutateAsync: restoreVersion, isPending } = useMutation(orpc.resume.restoreVersion.mutationOptions());
|
||||||
|
|
||||||
const handleRestore = async (versionId: string) => {
|
const handleRestore = async (versionId: string) => {
|
||||||
const confirmed = await confirm(t`Restore this version?`, {
|
const confirmed = await confirm(t`Restore this version?`, {
|
||||||
@@ -48,18 +53,28 @@ export function BuilderVersionHistory({ resumeId }: BuilderVersionHistoryProps)
|
|||||||
|
|
||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
restoreVersion(
|
const token = lockStylesheetStoreForRestore(resumeId);
|
||||||
{ resumeId, versionId },
|
if (!token) return;
|
||||||
{
|
try {
|
||||||
onSuccess: (restored) => {
|
const restored = await restoreVersion({ resumeId, versionId });
|
||||||
replaceResumeFromServer(restored as Resume);
|
const applied = replaceStylesheetStoreAfterRestore({
|
||||||
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored);
|
resumeId,
|
||||||
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
resumeData: restored.resume.data,
|
||||||
toast.success(t`Your resume has been restored to the selected version.`);
|
initial: restored.stylesheetState,
|
||||||
},
|
token,
|
||||||
onError: (error) => toast.error(getResumeErrorMessage(error)),
|
});
|
||||||
},
|
if (!applied) {
|
||||||
);
|
unlockStylesheetStoreAfterRestore(token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
replaceResumeFromServer(restored.resume as Resume);
|
||||||
|
queryClient.setQueryData(orpc.resume.getById.queryOptions({ input: { id: resumeId } }).queryKey, restored.resume);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: orpc.resume.listVersions.queryKey({ input: { resumeId } }) });
|
||||||
|
toast.success(t`Your resume has been restored to the selected version.`);
|
||||||
|
} catch (error) {
|
||||||
|
unlockStylesheetStoreAfterRestore(token);
|
||||||
|
toast.error(getResumeErrorMessage(error));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { RightSidebarSection } from "@/libs/resume/section";
|
import type { RightSidebarSection } from "@/libs/resume/section";
|
||||||
|
import { useRouteContext } from "@tanstack/react-router";
|
||||||
import { Fragment, useCallback, useRef } from "react";
|
import { Fragment, useCallback, useRef } from "react";
|
||||||
import { match } from "ts-pattern";
|
import { match } from "ts-pattern";
|
||||||
import { Button } from "@reactive-resume/ui/components/button";
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
@@ -22,13 +23,13 @@ import { StatisticsSectionBuilder } from "./sections/statistics";
|
|||||||
import { TemplateSectionBuilder } from "./sections/template";
|
import { TemplateSectionBuilder } from "./sections/template";
|
||||||
import { TypographySectionBuilder } from "./sections/typography";
|
import { TypographySectionBuilder } from "./sections/typography";
|
||||||
|
|
||||||
function getSectionComponent(type: RightSidebarSection) {
|
function getSectionComponent(type: RightSidebarSection, semanticCssAuthoring: boolean) {
|
||||||
return match(type)
|
return match(type)
|
||||||
.with("template", () => <TemplateSectionBuilder />)
|
.with("template", () => <TemplateSectionBuilder />)
|
||||||
.with("layout", () => <LayoutSectionBuilder />)
|
.with("layout", () => <LayoutSectionBuilder />)
|
||||||
.with("typography", () => <TypographySectionBuilder />)
|
.with("typography", () => <TypographySectionBuilder />)
|
||||||
.with("design", () => <DesignSectionBuilder />)
|
.with("design", () => <DesignSectionBuilder />)
|
||||||
.with("styles", () => <CustomStylesSectionBuilder />)
|
.with("styles", () => <CustomStylesSectionBuilder authoringEnabled={semanticCssAuthoring} />)
|
||||||
.with("page", () => <PageSectionBuilder />)
|
.with("page", () => <PageSectionBuilder />)
|
||||||
.with("notes", () => <NotesSectionBuilder />)
|
.with("notes", () => <NotesSectionBuilder />)
|
||||||
.with("sharing", () => <SharingSectionBuilder />)
|
.with("sharing", () => <SharingSectionBuilder />)
|
||||||
@@ -41,6 +42,8 @@ function getSectionComponent(type: RightSidebarSection) {
|
|||||||
|
|
||||||
export function BuilderSidebarRight() {
|
export function BuilderSidebarRight() {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
const scrollAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const context = useRouteContext({ strict: false });
|
||||||
|
const semanticCssAuthoring = context.flags?.semanticCssAuthoring ?? false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -53,7 +56,7 @@ export function BuilderSidebarRight() {
|
|||||||
<div className="space-y-4 p-4">
|
<div className="space-y-4 p-4">
|
||||||
{rightSidebarSections.map((section) => (
|
{rightSidebarSections.map((section) => (
|
||||||
<Fragment key={section}>
|
<Fragment key={section}>
|
||||||
{getSectionComponent(section)}
|
{getSectionComponent(section, semanticCssAuthoring)}
|
||||||
<Separator />
|
<Separator />
|
||||||
</Fragment>
|
</Fragment>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+23
-2
@@ -40,8 +40,13 @@ vi.mock("@/features/resume/builder/draft", () => ({
|
|||||||
useUpdateResumeData: () => updateResumeData,
|
useUpdateResumeData: () => updateResumeData,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/features/resume/stylesheet/editor", () => ({
|
||||||
|
default: () => <div data-testid="semantic-css-editor-shell">Semantic CSS editor</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
const { CustomStylesSectionBuilder } = await import("./custom-styles");
|
const { CustomStylesSectionBuilder } = await import("./custom-styles");
|
||||||
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
|
const { getSectionIcon, getSectionTitle } = await import("@/libs/resume/section");
|
||||||
|
const { useStylesheetStore } = await import("@/features/resume/stylesheet/store");
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||||
@@ -49,12 +54,13 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
updateResumeData.mockClear();
|
updateResumeData.mockClear();
|
||||||
|
useStylesheetStore.setState({ mode: "legacy" });
|
||||||
});
|
});
|
||||||
|
|
||||||
const renderCustomStyles = () =>
|
const renderCustomStyles = (authoringEnabled = false) =>
|
||||||
render(
|
render(
|
||||||
<I18nProvider i18n={i18n}>
|
<I18nProvider i18n={i18n}>
|
||||||
<CustomStylesSectionBuilder />
|
<CustomStylesSectionBuilder authoringEnabled={authoringEnabled} />
|
||||||
</I18nProvider>,
|
</I18nProvider>,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -75,6 +81,21 @@ describe("CustomStylesSectionBuilder", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("loads the Semantic CSS shell only when authoring is enabled", async () => {
|
||||||
|
renderCustomStyles(true);
|
||||||
|
|
||||||
|
expect(await screen.findByTestId("semantic-css-editor-shell")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows a read-only notice for active Semantic CSS when authoring is disabled", () => {
|
||||||
|
useStylesheetStore.setState({ mode: "semantic" });
|
||||||
|
renderCustomStyles();
|
||||||
|
|
||||||
|
expect(screen.getByText(/semantic styles remain active/i)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Target Scope")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("renders structured style rule controls", async () => {
|
it("renders structured style rule controls", async () => {
|
||||||
renderCustomStyles();
|
renderCustomStyles();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import type { ReactNode } from "react";
|
|||||||
import type { ComboboxOption } from "@/components/ui/combobox";
|
import type { ComboboxOption } from "@/components/ui/combobox";
|
||||||
import { Trans } from "@lingui/react/macro";
|
import { Trans } from "@lingui/react/macro";
|
||||||
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
import { EyeIcon, EyeSlashIcon, PencilSimpleIcon, TrashSimpleIcon } from "@phosphor-icons/react";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
|
import { sectionTypeSchema } from "@reactive-resume/schema/resume/data";
|
||||||
import { Button } from "@reactive-resume/ui/components/button";
|
import { Button } from "@reactive-resume/ui/components/button";
|
||||||
import { Input } from "@reactive-resume/ui/components/input";
|
import { Input } from "@reactive-resume/ui/components/input";
|
||||||
@@ -20,9 +20,14 @@ import { cn } from "@reactive-resume/utils/style";
|
|||||||
import { ColorPicker } from "@/components/input/color-picker";
|
import { ColorPicker } from "@/components/input/color-picker";
|
||||||
import { Combobox } from "@/components/ui/combobox";
|
import { Combobox } from "@/components/ui/combobox";
|
||||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||||
|
import { SemanticStylesheetReadOnlyNotice } from "@/features/resume/stylesheet/legacy-banner";
|
||||||
|
import { useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||||
import { getSectionTitle } from "@/libs/resume/section";
|
import { getSectionTitle } from "@/libs/resume/section";
|
||||||
|
import { useSectionStore } from "../../../-store/section";
|
||||||
import { SectionBase } from "../shared/section-base";
|
import { SectionBase } from "../shared/section-base";
|
||||||
|
|
||||||
|
const StylesheetEditorShell = lazy(() => import("@/features/resume/stylesheet/editor"));
|
||||||
|
|
||||||
type TargetScope = StyleRuleTarget["scope"];
|
type TargetScope = StyleRuleTarget["scope"];
|
||||||
|
|
||||||
type StyleSlotOption = {
|
type StyleSlotOption = {
|
||||||
@@ -100,15 +105,36 @@ const exactFourControlGridClassName = "grid grid-cols-1 gap-3 @min-[20rem]:grid-
|
|||||||
const compactSpacingInputClassName =
|
const compactSpacingInputClassName =
|
||||||
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
|
"h-8 w-18 max-w-18 min-w-0 px-1.5 text-center text-xs tabular-nums placeholder:text-[0.68rem] placeholder:uppercase placeholder:tracking-wide";
|
||||||
|
|
||||||
export function CustomStylesSectionBuilder() {
|
export type CustomStylesSectionBuilderProps = {
|
||||||
|
authoringEnabled?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function CustomStylesSectionBuilder({ authoringEnabled = false }: CustomStylesSectionBuilderProps) {
|
||||||
|
const mode = useStylesheetStore((state) => state.mode);
|
||||||
|
const collapsed = useSectionStore((state) => state.sections.styles?.collapsed ?? false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionBase type="styles" className="space-y-4">
|
<SectionBase type="styles" className="space-y-4">
|
||||||
<CustomStylesSectionForm />
|
{authoringEnabled ? (
|
||||||
|
collapsed ? null : (
|
||||||
|
<Suspense
|
||||||
|
fallback={
|
||||||
|
<div role="status" className="h-72 animate-pulse rounded-md bg-muted" aria-label="Loading editor" />
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<StylesheetEditorShell />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
) : mode === "semantic" ? (
|
||||||
|
<SemanticStylesheetReadOnlyNotice />
|
||||||
|
) : (
|
||||||
|
<LegacyCustomStylesSectionForm />
|
||||||
|
)}
|
||||||
</SectionBase>
|
</SectionBase>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function CustomStylesSectionForm() {
|
function LegacyCustomStylesSectionForm() {
|
||||||
const resume = useCurrentResume();
|
const resume = useCurrentResume();
|
||||||
const data = resume.data;
|
const data = resume.data;
|
||||||
const updateResumeData = useUpdateResumeData();
|
const updateResumeData = useUpdateResumeData();
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ const resumeMock = vi.hoisted(() => ({
|
|||||||
slug: string;
|
slug: string;
|
||||||
data: typeof defaultResumeData;
|
data: typeof defaultResumeData;
|
||||||
},
|
},
|
||||||
|
stylesheet: {
|
||||||
|
resumeId: "r1" as string | undefined,
|
||||||
|
mode: "semantic" as "legacy" | "semantic",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||||
|
revision: 42,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type SectionBaseProps = {
|
type SectionBaseProps = {
|
||||||
@@ -42,6 +50,9 @@ vi.mock("@/libs/resume/section-title-locale", () => ({
|
|||||||
vi.mock("@/features/resume/builder/draft", () => ({
|
vi.mock("@/features/resume/builder/draft", () => ({
|
||||||
useResume: () => resumeMock.resume,
|
useResume: () => resumeMock.resume,
|
||||||
}));
|
}));
|
||||||
|
vi.mock("@/features/resume/stylesheet/store", () => ({
|
||||||
|
useStylesheetStore: (selector: (state: typeof resumeMock.stylesheet) => unknown) => selector(resumeMock.stylesheet),
|
||||||
|
}));
|
||||||
|
|
||||||
const { ExportSectionBuilder } = await import("./export");
|
const { ExportSectionBuilder } = await import("./export");
|
||||||
|
|
||||||
@@ -51,6 +62,14 @@ beforeAll(() => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
|
resumeMock.resume = { id: "r1", name: "My Resume", slug: "my-resume", data: defaultResumeData };
|
||||||
|
resumeMock.stylesheet = {
|
||||||
|
resumeId: "r1",
|
||||||
|
mode: "semantic",
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\nname {" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\nname { color: #123456; }\n" },
|
||||||
|
revision: 42,
|
||||||
|
renderDataVersion: 7,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -96,7 +115,7 @@ describe("ExportSectionBuilder", () => {
|
|||||||
expect(filename).toBe("My Resume.md");
|
expect(filename).toBe("My Resume.md");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("downloads a JSON blob when the JSON button is clicked", () => {
|
it("downloads canonical stylesheet content in JSON without concurrency metadata", async () => {
|
||||||
renderExport();
|
renderExport();
|
||||||
openDialog();
|
openDialog();
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
|
fireEvent.click(screen.getByRole("button", { name: "Download JSON" }));
|
||||||
@@ -107,6 +126,14 @@ describe("ExportSectionBuilder", () => {
|
|||||||
expect(blob).toBeInstanceOf(Blob);
|
expect(blob).toBeInstanceOf(Blob);
|
||||||
expect((blob as Blob).type).toBe("application/json");
|
expect((blob as Blob).type).toBe("application/json");
|
||||||
expect(filename).toBe("My Resume.json");
|
expect(filename).toBe("My Resume.json");
|
||||||
|
const exported = JSON.parse(await (blob as Blob).text());
|
||||||
|
expect(exported.metadata.stylesheet).toEqual({
|
||||||
|
mode: "semantic",
|
||||||
|
source: resumeMock.stylesheet.source,
|
||||||
|
applied: resumeMock.stylesheet.applied,
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(exported)).not.toContain("revision");
|
||||||
|
expect(JSON.stringify(exported)).not.toContain("renderDataVersion");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
it("calls buildDocx and downloads the resulting blob when DOCX is clicked", async () => {
|
||||||
@@ -128,6 +155,12 @@ describe("ExportSectionBuilder", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
expect(createResumePdfBlob).toHaveBeenCalledTimes(1);
|
||||||
|
expect(createResumePdfBlob).toHaveBeenCalledWith(defaultResumeData, undefined, undefined, {
|
||||||
|
stylesheet: {
|
||||||
|
mode: "semantic",
|
||||||
|
applied: resumeMock.stylesheet.applied,
|
||||||
|
},
|
||||||
|
});
|
||||||
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
expect(downloadWithAnchor).toHaveBeenCalledTimes(1);
|
||||||
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
expect(downloadWithAnchor.mock.calls[0]?.[1]).toBe("My Resume.pdf");
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import type { BuilderLayout } from "./-store/sidebar";
|
import type { BuilderLayout } from "./-store/sidebar";
|
||||||
import { useSuspenseQuery } from "@tanstack/react-query";
|
import { useSuspenseQuery } from "@tanstack/react-query";
|
||||||
import { createFileRoute, redirect } from "@tanstack/react-router";
|
import { createFileRoute, redirect } from "@tanstack/react-router";
|
||||||
import { useEffect } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useMediaQuery } from "usehooks-ts";
|
import { useMediaQuery } from "usehooks-ts";
|
||||||
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
import { useBuilderResumeUpdateSubscription, useResumeCleanup, useResumeStore } from "@/features/resume/builder/draft";
|
||||||
|
import { initializeStylesheetStore, useStylesheetStore } from "@/features/resume/stylesheet/store";
|
||||||
import { orpc } from "@/libs/orpc/client";
|
import { orpc } from "@/libs/orpc/client";
|
||||||
import { createNoindexFollowMeta } from "@/libs/seo";
|
import { createNoindexFollowMeta } from "@/libs/seo";
|
||||||
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
|
import { DesktopBuilderShell } from "./-components/desktop-builder-shell";
|
||||||
@@ -20,6 +21,9 @@ export const Route = createFileRoute("/builder/$resumeId")({
|
|||||||
const [layout, resume] = await Promise.all([
|
const [layout, resume] = await Promise.all([
|
||||||
getBuilderLayout(),
|
getBuilderLayout(),
|
||||||
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions({ input: { id: params.resumeId } })),
|
||||||
|
context.queryClient.ensureQueryData(
|
||||||
|
orpc.resume.stylesheet.getState.queryOptions({ input: { id: params.resumeId } }),
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { layout, name: resume.name };
|
return { layout, name: resume.name };
|
||||||
@@ -36,11 +40,17 @@ function RouteComponent() {
|
|||||||
|
|
||||||
const { resumeId } = Route.useParams();
|
const { resumeId } = Route.useParams();
|
||||||
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
const { data: resume } = useSuspenseQuery(orpc.resume.getById.queryOptions({ input: { id: resumeId } }));
|
||||||
|
const { data: stylesheet } = useSuspenseQuery(
|
||||||
|
orpc.resume.stylesheet.getState.queryOptions({ input: { id: resumeId } }),
|
||||||
|
);
|
||||||
const initializeResumeStore = useResumeStore((state) => state.initialize);
|
const initializeResumeStore = useResumeStore((state) => state.initialize);
|
||||||
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
|
const mergeResumeMetadata = useResumeStore((state) => state.mergeResumeMetadata);
|
||||||
const isReady = useResumeStore((state) => state.isReady);
|
const isReady = useResumeStore((state) => state.isReady);
|
||||||
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
const initializedResumeId = useResumeStore((state) => state.resumeId);
|
||||||
const isInitialized = isReady && initializedResumeId === resumeId;
|
const isInitialized = isReady && initializedResumeId === resumeId;
|
||||||
|
const isStylesheetInitialized = useStylesheetStore((state) => state.resumeId === resumeId);
|
||||||
|
const stylesheetInitialization = useRef({ resume, stylesheet });
|
||||||
|
stylesheetInitialization.current = { resume, stylesheet };
|
||||||
|
|
||||||
useResumeCleanup();
|
useResumeCleanup();
|
||||||
useBuilderResumeUpdateSubscription();
|
useBuilderResumeUpdateSubscription();
|
||||||
@@ -50,6 +60,16 @@ function RouteComponent() {
|
|||||||
initializeResumeStore(resume);
|
initializeResumeStore(resume);
|
||||||
}, [initializeResumeStore, isInitialized, resume]);
|
}, [initializeResumeStore, isInitialized, resume]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialized) return;
|
||||||
|
const initial = stylesheetInitialization.current;
|
||||||
|
return initializeStylesheetStore({
|
||||||
|
resumeId,
|
||||||
|
initial: initial.stylesheet,
|
||||||
|
resumeData: initial.resume.data,
|
||||||
|
});
|
||||||
|
}, [isInitialized, resumeId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
mergeResumeMetadata(resume);
|
mergeResumeMetadata(resume);
|
||||||
}, [
|
}, [
|
||||||
@@ -65,7 +85,7 @@ function RouteComponent() {
|
|||||||
resume,
|
resume,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!isInitialized) return null;
|
if (!isInitialized || !isStylesheetInitialized) return null;
|
||||||
|
|
||||||
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
return <BuilderLayoutShell initialLayout={initialLayout} />;
|
||||||
}
|
}
|
||||||
|
|||||||
+23
-1
@@ -109,5 +109,27 @@
|
|||||||
"parser": {
|
"parser": {
|
||||||
"tailwindDirectives": true
|
"tailwindDirectives": true
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"includes": ["docs/spec.json"],
|
||||||
|
"files": {
|
||||||
|
"maxSize": 2097152
|
||||||
|
},
|
||||||
|
"json": {
|
||||||
|
"formatter": {
|
||||||
|
"expand": "always"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"includes": ["packages/pdf/src/semantic/__fixtures__/**/*.css"],
|
||||||
|
"formatter": {
|
||||||
|
"enabled": false
|
||||||
|
},
|
||||||
|
"linter": {
|
||||||
|
"enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,338 @@
|
|||||||
|
---
|
||||||
|
title: "Applying Custom Styles"
|
||||||
|
description: "Use Reactive Resume Semantic CSS to make safe, targeted, and portable changes to your resume PDF."
|
||||||
|
---
|
||||||
|
|
||||||
|
Custom Styles let you make focused changes that are not available in the regular **Design**, **Typography**, **Layout**,
|
||||||
|
**Page**, and **Picture** settings. They use Semantic CSS, a CSS-like language designed for
|
||||||
|
resume PDFs.
|
||||||
|
|
||||||
|
<Note>
|
||||||
|
Semantic CSS styles the PDF output, not the browser interface. It cannot load fonts, images, scripts, or other resources, and
|
||||||
|
it cannot create new resume content.
|
||||||
|
</Note>
|
||||||
|
|
||||||
|
## Convert existing Custom Styles
|
||||||
|
|
||||||
|
If a resume still uses the previous Custom Styles form, Reactive Resume creates a converted stylesheet draft. Your
|
||||||
|
current rules remain active while you review it.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Open Custom Styles">
|
||||||
|
Open the resume in the builder, select **Design**, then select **Custom Styles**.
|
||||||
|
</Step>
|
||||||
|
<Step title="Review the converted draft">
|
||||||
|
Check the preview and warnings below the editor. The draft starts with `@version 1;`.
|
||||||
|
</Step>
|
||||||
|
<Step title="Activate Semantic CSS">
|
||||||
|
Select **Activate Semantic CSS** only after the preview matches the legacy result. Reactive Resume never applies both
|
||||||
|
systems at once, and keeps the original legacy rules available for rollback.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Make your first change
|
||||||
|
|
||||||
|
Open the resume you want to style, select **Design**, then select **Custom Styles**. Start with a complete stylesheet:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
section[type="experience"] > section-heading {
|
||||||
|
color: #0f766e;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The first line tells Reactive Resume which language version the stylesheet uses. Keep `@version 1;` at the start of
|
||||||
|
every stylesheet.
|
||||||
|
|
||||||
|
<Steps>
|
||||||
|
<Step title="Paste one focused rule">
|
||||||
|
Add the stylesheet to the editor. Start with one visual change so it is easy to review in the preview.
|
||||||
|
</Step>
|
||||||
|
<Step title="Wait for Applied">
|
||||||
|
Reactive Resume checks the source and the PDF result. When the status changes to **Applied**, compare the preview
|
||||||
|
and export if you are ready to share the resume.
|
||||||
|
</Step>
|
||||||
|
<Step title="Build on the working rule">
|
||||||
|
Add one related change at a time. The editor keeps your draft, undo history, and last valid stylesheet separately.
|
||||||
|
</Step>
|
||||||
|
</Steps>
|
||||||
|
|
||||||
|
## Target the right part of your resume
|
||||||
|
|
||||||
|
Semantic CSS selectors describe resume content rather than a template's internal HTML. Selector and attribute names are
|
||||||
|
lowercase and case-sensitive. Prefer semantic selectors when you want a style to work across resumes and templates.
|
||||||
|
|
||||||
|
### Start with the resume structure
|
||||||
|
|
||||||
|
| Selector | Targets | Typical use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `resume` | The complete resume | Scope a rule to one template. |
|
||||||
|
| `page` | A rendered PDF page | Set a page size. |
|
||||||
|
| `region` | Header, main, sidebar, or featured region | Style a layout area. |
|
||||||
|
| `header` | The resume header | Style the identity and contact area. |
|
||||||
|
| `section` | A resume section | Target a section type or placement. |
|
||||||
|
| `section-heading` | A section title | Change heading typography or decoration. |
|
||||||
|
| `section-items` | The items in a section | Adjust item layout and gaps. |
|
||||||
|
| `item` | One resume item | Control spacing or pagination for an experience, project, or similar item. |
|
||||||
|
| `item-header` | An item's summary row | Align the title, company, dates, or similar details. |
|
||||||
|
|
||||||
|
### Target header and item content
|
||||||
|
|
||||||
|
| Selector | Targets | Typical use |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `picture` | The profile picture | Change dimensions, crop, border, or picture shadow. |
|
||||||
|
| `name`, `headline` | Header name and headline | Change the main identity typography. |
|
||||||
|
| `contact-list`, `contact-item` | Header contact details | Space or restyle contact details. |
|
||||||
|
| `combined-text` | A template-combined value | Style an item value that combines fields. |
|
||||||
|
| `field` | A named content field | Target a position, company, date, or other field. |
|
||||||
|
| `link` | A structured link | Change linked text or layout. |
|
||||||
|
| `icon`, `level` | An icon or level indicator | Restyle decorative elements. |
|
||||||
|
|
||||||
|
### Target rich text and lists
|
||||||
|
|
||||||
|
| Selector | Targets |
|
||||||
|
| --- | --- |
|
||||||
|
| `rich-text`, `rich-heading`, `blockquote`, `paragraph` | Rich-text blocks in descriptions and summaries. |
|
||||||
|
| `list`, `list-item`, `list-marker`, `list-item-content` | Lists, the outer item row, its bullet or number, and its content. |
|
||||||
|
| `strong`, `emphasis`, `underline`, `strike`, `code`, `text-span`, `mark` | Inline rich-text formatting. |
|
||||||
|
| `hard-break`, `horizontal-rule` | A forced line break or horizontal rule. |
|
||||||
|
| `template-part` | A template-provided extension point. Use only with a template guard. |
|
||||||
|
|
||||||
|
### Narrow a selector with attributes
|
||||||
|
|
||||||
|
Use attributes to make a rule specific without relying on a template layout.
|
||||||
|
|
||||||
|
| Attribute | Use it with | Example |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `type` | `section` | `section[type="experience"]` |
|
||||||
|
| `placement` | `region` and `section` | `region[placement="sidebar"]` |
|
||||||
|
| `region` | `region` | `region[region="sidebar"]` |
|
||||||
|
| `origin` | `section` | `section[origin="main"]` |
|
||||||
|
| `part` | `region`, `section`, `contact-item`, and `item-header` | `region[part~="sidebar-background"]` |
|
||||||
|
| `template` | `resume` | `resume[template="azurill"]` |
|
||||||
|
| `name` | `field` and `template-part` | `field[name="position"]` |
|
||||||
|
| `level` | `rich-heading` | `rich-heading[level="2"]` |
|
||||||
|
| `direction` | `list-item-content` | `list-item-content[direction="rtl"]` |
|
||||||
|
| `id` | Any semantic node when present | `section[id="projects"]` |
|
||||||
|
| `role` | Any semantic node when present | `field[role~="secondary-text"]` |
|
||||||
|
|
||||||
|
Semantic CSS supports selector lists, descendant (` `), child (`>`), adjacent sibling (`+`), and general sibling (`~`)
|
||||||
|
combinators. It also supports `:root`, `:first-child`, `:last-child`, `:only-child`, `:is()`, `:where()`, `:not()`,
|
||||||
|
`:nth-child()`, and `:nth-of-type()`.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
section[type="experience"] > section-heading {
|
||||||
|
border-bottom: 1pt solid #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
region[placement="sidebar"] {
|
||||||
|
background-color: #f8fafc;
|
||||||
|
padding: 18pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
section[id="projects"] {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use an exact `id` only for a resume-specific adjustment. A type, placement, role, or field name is usually a better
|
||||||
|
choice when you expect to copy the stylesheet to another resume.
|
||||||
|
|
||||||
|
## Reuse your builder settings
|
||||||
|
|
||||||
|
Semantic CSS exposes the resolved builder settings as read-only `--resume-*` variables. Define your own variables in `:root`, then
|
||||||
|
reuse the builder values instead of duplicating colors or dimensions.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--accent: var(--resume-primary-color);
|
||||||
|
--rule: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
section-heading {
|
||||||
|
color: var(--accent);
|
||||||
|
border-bottom: 1pt solid var(--rule);
|
||||||
|
font-size: 11pt;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.4pt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Changing the primary color or related setting in the builder updates the corresponding variable automatically. Do not
|
||||||
|
assign a value to a `--resume-*` variable; create an author variable such as `--accent` instead.
|
||||||
|
|
||||||
|
| Builder setting | Read-only variables |
|
||||||
|
| --- | --- |
|
||||||
|
| Colors | `--resume-primary-color`, `--resume-text-color`, `--resume-background-color` |
|
||||||
|
| Typography | `--resume-body-font-size`, `--resume-body-line-height`, `--resume-heading-font-size`, `--resume-heading-line-height` |
|
||||||
|
| Page and layout | `--resume-page-gap-x`, `--resume-page-gap-y`, `--resume-page-margin-x`, `--resume-page-margin-y`, `--resume-page-width`, `--resume-page-height`, `--resume-sidebar-width` |
|
||||||
|
| Picture | `--resume-picture-size`, `--resume-picture-rotation`, `--resume-picture-aspect-ratio`, `--resume-picture-border-radius`, `--resume-picture-border-width`, `--resume-picture-border-color`, `--resume-picture-shadow-width`, `--resume-picture-shadow-color` |
|
||||||
|
|
||||||
|
Use `pt` for predictable PDF spacing and type sizes. Semantic CSS also accepts `px`, `in`, `mm`, `cm`, `%`, `vw`, `vh`, `em`,
|
||||||
|
and `rem` where the property supports a length.
|
||||||
|
|
||||||
|
## Style common resume content
|
||||||
|
|
||||||
|
The most useful declarations usually fall into a few groups:
|
||||||
|
|
||||||
|
| Goal | Common declarations |
|
||||||
|
| --- | --- |
|
||||||
|
| Typography | `color`, `font-size`, `font-style`, `font-weight`, `letter-spacing`, `line-height`, `text-align`, `text-decoration`, `text-transform` |
|
||||||
|
| Spacing and layout | `margin`, `padding`, `gap`, `width`, `height`, `display`, `flex`, `flex-direction`, `justify-content`, `align-items`, `order` |
|
||||||
|
| Visual treatment | `background-color`, `border`, `border-radius`, `opacity`, `transform` |
|
||||||
|
| Picture treatment | `object-fit`, `object-position`, `-resume-shadow-color`, `-resume-shadow-width` |
|
||||||
|
| PDF structure | `break-before`, `break-inside`, `orphans`, `widows`, `-resume-min-presence-ahead`, `size` |
|
||||||
|
|
||||||
|
Use `display: none` only to hide an existing semantic node. Semantic CSS cannot add, remove, duplicate, or re-parent resume
|
||||||
|
data.
|
||||||
|
|
||||||
|
### Style rich-text lists
|
||||||
|
|
||||||
|
`list-item` is the outer row that holds a marker and its content. Use it for row layout and spacing. Use `list-marker`
|
||||||
|
for the bullet or number, and `list-item-content` for the text flow.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
rich-text list-item {
|
||||||
|
gap: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
list-marker {
|
||||||
|
color: var(--resume-primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
list-item-content {
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Style fields inside an item
|
||||||
|
|
||||||
|
Named fields let you make a focused change without styling every item value. Use the selector only where that field
|
||||||
|
exists in the selected resume and template.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
section[type="experience"] field[name="position"] {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
section[type="experience"] field[name="company"] {
|
||||||
|
color: var(--resume-primary-color);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Use template-specific parts carefully
|
||||||
|
|
||||||
|
Template parts expose optional visual details that are not shared by every template. Always guard a template-part rule
|
||||||
|
with `resume[template="..."]`; otherwise the selector may match nothing after a template change.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
resume[template="azurill"] template-part[name="timeline-line"] {
|
||||||
|
background-color: #94a3b8;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Some template parts are wrappers, while others are attributes on an existing semantic node. Use the matching selector
|
||||||
|
below.
|
||||||
|
|
||||||
|
| Template | Available selectors |
|
||||||
|
| --- | --- |
|
||||||
|
| Azurill | `template-part[name="timeline-content"]`, `template-part[name="timeline-dot"]`, `template-part[name="timeline-line"]`, `template-part[name="timeline-marker"]` |
|
||||||
|
| Bronzor | `section[part~="interleaved-section-row"]` |
|
||||||
|
| Chikorita | `template-part[name="contact-row-primary"]`, `template-part[name="contact-row-secondary"]` |
|
||||||
|
| Ditgar | `template-part[name="featured-summary"]`, `item-header[part~="item-header-border"]`, `region[part~="sidebar-background"]` |
|
||||||
|
| Ditto | `template-part[name="contact-offset"]`, `template-part[name="header-band"]`, `template-part[name="picture-anchor"]` |
|
||||||
|
| Gengar | `template-part[name="featured-summary"]`, `region[part~="sidebar-background"]` |
|
||||||
|
| Glalie | `region[part~="sidebar-background"]` |
|
||||||
|
| Leafish | `template-part[name="header-body"]`, `template-part[name="header-contact-band"]`, `template-part[name="header-intro"]` |
|
||||||
|
| Meowth | `template-part[name="education-grade-row"]`, `template-part[name="inline-item-header-leading"]`, `template-part[name="inline-item-header-middle"]`, `template-part[name="inline-item-header-trailing"]` |
|
||||||
|
| Pikachu | `template-part[name="header-divider"]` |
|
||||||
|
| Rhyhorn | `template-part[name="contact-item-content"]`, `contact-item[part~="contact-item-last"]` |
|
||||||
|
| Scizor | `template-part[name="header-name-rule"]` |
|
||||||
|
|
||||||
|
Kakuna, Lapras, and Onyx do not expose template-specific parts. Use shared semantic selectors for portable styles.
|
||||||
|
|
||||||
|
## Control pagination and PDF dimensions
|
||||||
|
|
||||||
|
Use structural declarations sparingly and review the exported PDF after each change. You can keep an item together,
|
||||||
|
leave space before a section, or set a custom page size.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
page {
|
||||||
|
size: 210mm 297mm;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
-resume-min-presence-ahead: 72pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
item {
|
||||||
|
break-inside: avoid;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`size` applies only to `page` and must be outside `@media`. PDF media queries use the authored PDF dimensions, not the
|
||||||
|
browser viewport.
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
|
||||||
|
@media (max-width: 600pt) {
|
||||||
|
region[placement="sidebar"] {
|
||||||
|
padding: 12pt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported media features are `width`, `min-width`, `max-width`, `height`, `min-height`, `max-height`, and
|
||||||
|
`orientation: portrait` or `orientation: landscape`.
|
||||||
|
|
||||||
|
## Apply, diagnose, and recover safely
|
||||||
|
|
||||||
|
The editor saves your draft even when it has an error. The preview and PDF export continue using the last stylesheet
|
||||||
|
that compiled and passed PDF checks, so a mistake does not replace a working result.
|
||||||
|
|
||||||
|
If a rule does not work:
|
||||||
|
|
||||||
|
1. Read the status below the editor. Errors include a line and column number when available.
|
||||||
|
2. Check the selector's spelling, attribute value, placement, and template guard. A **selector matches nothing**
|
||||||
|
warning usually means the resume does not contain that semantic node.
|
||||||
|
3. Simplify the rule to one selector and one declaration, then wait for **Applied** before adding more.
|
||||||
|
4. Use **Reset to applied stylesheet** to discard the current draft, or use the stylesheet undo and redo controls to
|
||||||
|
restore an earlier source and applied pair.
|
||||||
|
|
||||||
|
Select **Open focus mode** when you need a taller editor. On mobile, it opens a full-width sheet; switch to
|
||||||
|
**Preview** to inspect the result.
|
||||||
|
|
||||||
|
<Warning>
|
||||||
|
Review the PDF preview before exporting or sharing a resume with Custom Styles. PDF pagination and template-specific
|
||||||
|
details can make a valid stylesheet look different from what you intended.
|
||||||
|
</Warning>
|
||||||
|
|
||||||
|
## Keep styles portable
|
||||||
|
|
||||||
|
When you copy a stylesheet to another resume, semantic section types, placements, roles, and fields are the safest
|
||||||
|
starting point. Exact IDs and template parts are intentionally specific to a resume or template.
|
||||||
|
|
||||||
|
1. Select **Copy stylesheet** in the source resume.
|
||||||
|
2. Open **Design -> Custom Styles** in the destination resume.
|
||||||
|
3. Paste the stylesheet and review any warnings.
|
||||||
|
4. Replace or remove exact IDs and template-part rules that do not apply.
|
||||||
|
5. Wait for **Applied**, then compare the preview and exported PDF.
|
||||||
|
|
||||||
|
Semantic CSS does not support classes, pseudo-elements, CSS Grid, arbitrary at-rules, `@import`, `@font-face`, `url()`,
|
||||||
|
browser APIs, animations, filters, gradients, general box shadows, or external assets. Use the normal builder settings
|
||||||
|
when you need a font, image, or broader layout change.
|
||||||
+11
-1
@@ -4,6 +4,16 @@
|
|||||||
"name": "Reactive Resume",
|
"name": "Reactive Resume",
|
||||||
"favicon": "/favicon.svg",
|
"favicon": "/favicon.svg",
|
||||||
"description": "A privacy-minded resume builder that is customizable, portable, open-source, and free to use.",
|
"description": "A privacy-minded resume builder that is customizable, portable, open-source, and free to use.",
|
||||||
|
"redirects": [
|
||||||
|
{
|
||||||
|
"source": "/guides/using-custom-styles",
|
||||||
|
"destination": "/applying-custom-styles"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"source": "/guides/semantic-css-reference",
|
||||||
|
"destination": "/applying-custom-styles"
|
||||||
|
}
|
||||||
|
],
|
||||||
"seo": {
|
"seo": {
|
||||||
"indexing": "navigable",
|
"indexing": "navigable",
|
||||||
"metatags": {
|
"metatags": {
|
||||||
@@ -61,7 +71,7 @@
|
|||||||
"guides/adding-a-cover-letter",
|
"guides/adding-a-cover-letter",
|
||||||
"guides/using-the-builder-dock",
|
"guides/using-the-builder-dock",
|
||||||
"guides/undoing-changes-and-version-history",
|
"guides/undoing-changes-and-version-history",
|
||||||
"guides/using-custom-styles",
|
"applying-custom-styles",
|
||||||
"guides/using-ai-in-the-builder",
|
"guides/using-ai-in-the-builder",
|
||||||
"guides/using-ai-agent",
|
"guides/using-ai-agent",
|
||||||
"guides/using-private-notes",
|
"guides/using-private-notes",
|
||||||
|
|||||||
+2078
-374
File diff suppressed because it is too large
Load Diff
@@ -1,337 +0,0 @@
|
|||||||
---
|
|
||||||
title: "Using Custom Styles"
|
|
||||||
description: "Learn how to use Custom Styles to fine-tune section layouts, text, rich text, lists, links, spacing, borders, and other resume presentation details."
|
|
||||||
---
|
|
||||||
|
|
||||||
Custom Styles let you fine-tune the visual details of your resume after you choose a template. Instead of writing CSS, you create structured style rules that target resume sections and semantic parts of those sections, such as section headings, item containers, normal text, links, rich-text paragraphs, and list rows.
|
|
||||||
|
|
||||||
Use Custom Styles when the regular **Design**, **Typography**, **Layout**, and **Page** settings are too broad. For example, you can make only your Experience headings uppercase, add a border around Projects, tighten the spacing inside rich-text bullet lists, or change how inline links appear in descriptions.
|
|
||||||
|
|
||||||
<Frame caption="Screenshot of the Custom Styles section in the resume builder right sidebar">
|
|
||||||
<img
|
|
||||||
src="/images/guides/using-custom-styles/screenshot-1.webp"
|
|
||||||
alt="Custom Styles section in the right sidebar with target scope, style slot, style controls, and applied rules"
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
<Warning>
|
|
||||||
Custom Styles are powerful layout controls. Small changes can improve polish, but large negative margins, heavy
|
|
||||||
borders, or oversized text can make a resume harder to read or cause content to overflow.
|
|
||||||
</Warning>
|
|
||||||
|
|
||||||
## When to use Custom Styles
|
|
||||||
|
|
||||||
Start with the normal builder settings first:
|
|
||||||
|
|
||||||
| Need | Use this first |
|
|
||||||
| --- | --- |
|
|
||||||
| Change the overall color palette | **Design** |
|
|
||||||
| Change body or heading fonts | **Typography** |
|
|
||||||
| Change page size, margins, or section gaps | **Page** |
|
|
||||||
| Move sections between columns or pages | **Layout** |
|
|
||||||
| Hide, reorder, or edit section content | The section controls in the left sidebar |
|
|
||||||
|
|
||||||
Use **Custom Styles** when you need a targeted adjustment, such as:
|
|
||||||
|
|
||||||
- Styling one section differently from the rest of the resume.
|
|
||||||
- Adding padding, background, or border treatment to section items.
|
|
||||||
- Adjusting the spacing between rich-text list bullets and their text.
|
|
||||||
- Making rich-text links, bold text, or highlights stand out.
|
|
||||||
- Tightening rich-text paragraphs or lists in a long section without changing the whole resume.
|
|
||||||
|
|
||||||
## Create a style rule
|
|
||||||
|
|
||||||
<Steps>
|
|
||||||
<Step title="Open your resume in the builder">
|
|
||||||
From the Dashboard, open the resume you want to customize.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Open the right sidebar">
|
|
||||||
The right sidebar contains the resume-wide presentation controls.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Open Custom Styles">
|
|
||||||
Select **Custom Styles** from the right sidebar.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Choose a Target Scope">
|
|
||||||
Choose where the rule should apply: **All sections**, a **Section type**, or a **Specific section**.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Choose a Style Slot">
|
|
||||||
Choose which part of the target should receive the style, such as **Section heading**, **Item container**, **Primary
|
|
||||||
text**, **Paragraph**, or **List item row**.
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Set the style values">
|
|
||||||
Use the **Color**, **Text**, **Spacing**, and **Border** controls. Empty fields mean "use the template default."
|
|
||||||
</Step>
|
|
||||||
|
|
||||||
<Step title="Review the preview">
|
|
||||||
The resume preview updates as the rule changes. Exported PDFs use the same rendering path as the preview, so the
|
|
||||||
exported PDF should match what you see.
|
|
||||||
</Step>
|
|
||||||
</Steps>
|
|
||||||
|
|
||||||
<Frame caption="Screenshot of the Target Scope and Style Slot selectors">
|
|
||||||
<img
|
|
||||||
src="/images/guides/using-custom-styles/screenshot-2.webp"
|
|
||||||
alt="Target Scope and Style Slot selectors showing All sections, Section type, Specific section, and grouped style slots"
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## How style rules work
|
|
||||||
|
|
||||||
A Custom Style rule has three parts:
|
|
||||||
|
|
||||||
| Part | What it means | Example |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Target Scope** | Where the rule applies | All sections, every Experience section, or one custom Projects section |
|
|
||||||
| **Style Slot** | Which semantic element receives the style | Section heading, Item container, Paragraph, List item row |
|
|
||||||
| **Style values** | The visual properties to apply | Text color, font size, padding, row gap, border width |
|
|
||||||
|
|
||||||
Rules are layered on top of the selected template. The template still provides the base design, and Custom Styles override only the values you set.
|
|
||||||
|
|
||||||
If multiple rules affect the same slot, the more specific rule wins:
|
|
||||||
|
|
||||||
1. **All sections** applies first.
|
|
||||||
2. **Section type** overrides matching All sections values.
|
|
||||||
3. **Specific section** overrides matching Section type and All sections values.
|
|
||||||
|
|
||||||
For example, you can make all section headings green, then make only Experience headings black, then make one specific custom Experience section red.
|
|
||||||
|
|
||||||
<Info>
|
|
||||||
Disabled rules are ignored. Deleted or hidden sections do not render, so their rules have nothing to affect until the
|
|
||||||
section is visible again.
|
|
||||||
</Info>
|
|
||||||
|
|
||||||
## Target scopes
|
|
||||||
|
|
||||||
Target Scope decides how broad a rule should be.
|
|
||||||
|
|
||||||
| Target Scope | What it affects | Useful when |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **All sections** | Every rendered section where the selected slot exists. This includes built-in sections and custom sections. | You want a resume-wide default, such as all section headings using the same color or all rich-text lists using tighter spacing. |
|
|
||||||
| **Section type** | Every section with that content type. This includes matching custom sections. For example, a Projects-style custom section is affected by a Projects section-type rule. | You want every Experience section, every Skills section, or every Summary-style section to share a treatment. |
|
|
||||||
| **Specific section** | One actual section in this resume. | You have duplicate or custom sections and want only one of them to look different. |
|
|
||||||
|
|
||||||
<Frame caption="Screenshot of selecting a Section type target">
|
|
||||||
<img
|
|
||||||
src="/images/guides/using-custom-styles/screenshot-3.webp"
|
|
||||||
alt="Custom Styles target controls with Section type selected and Experience chosen as the target"
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
## Style property groups
|
|
||||||
|
|
||||||
The style editor is grouped by property type. Not every property is meaningful on every slot. Text properties work best on text-facing slots, while spacing, background, and border properties work best on containers.
|
|
||||||
|
|
||||||
<Frame caption="Screenshot of the Color, Text, Spacing, and Border controls">
|
|
||||||
<img
|
|
||||||
src="/images/guides/using-custom-styles/screenshot-4.webp"
|
|
||||||
alt="Custom Styles controls grouped into Color, Text, Spacing, and Border panels"
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
### Color
|
|
||||||
|
|
||||||
| Control | What it changes | Notes |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Text Color** | Text color on text-facing slots. | Most reliable on heading, text, secondary text, link, and rich-text slots. |
|
|
||||||
| **Background** | Background color behind the selected slot. | Useful on section containers, item containers, paragraphs, list rows, and highlights. |
|
|
||||||
| **Text Decoration Color** | Underline or line-through color. | Use with **Text Decoration**. |
|
|
||||||
| **Opacity** | Transparency of the selected slot. | Values range from 0 to 1. |
|
|
||||||
|
|
||||||
Colors are stored as `rgba(r, g, b, a)` values. Use the color picker when possible.
|
|
||||||
|
|
||||||
### Text
|
|
||||||
|
|
||||||
| Control | What it changes |
|
|
||||||
| --- | --- |
|
|
||||||
| **Font Size** | Size in points. |
|
|
||||||
| **Font Weight** | Weight from 100 to 900. |
|
|
||||||
| **Font Style** | Normal or italic. |
|
|
||||||
| **Line Height** | Line-height multiplier. |
|
|
||||||
| **Letter Spacing** | Space between letters. |
|
|
||||||
| **Text Decoration** | None, underline, or line-through. |
|
|
||||||
| **Decoration Style** | Solid, dashed, or dotted decoration line. |
|
|
||||||
| **Text Align** | Left, center, right, or justify. |
|
|
||||||
| **Text Transform** | None, uppercase, lowercase, or capitalize. |
|
|
||||||
|
|
||||||
Use text controls sparingly. Resume text should stay readable, especially when exported to PDF or parsed by hiring systems.
|
|
||||||
|
|
||||||
### Spacing
|
|
||||||
|
|
||||||
| Control | What it changes | Useful for |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Padding** | Space inside the selected slot. | Creating breathing room inside boxes, highlighted paragraphs, or section items. |
|
|
||||||
| **Margin** | Space outside the selected slot. | Moving headings, paragraphs, or items closer together or farther apart. |
|
|
||||||
| **Row Gap** | Vertical gap between children when the selected slot lays out multiple rows. | Increasing or tightening list spacing and stacked item content. |
|
|
||||||
| **Column Gap** | Horizontal gap between children when the selected slot lays out multiple columns or row children. | Increasing or decreasing the space between a bullet marker and bullet text on **List item row**. |
|
|
||||||
|
|
||||||
Spacing values are points. Negative values are allowed for some spacing controls, but they can make content overlap. Prefer small adjustments first.
|
|
||||||
|
|
||||||
### Border
|
|
||||||
|
|
||||||
| Control | What it changes |
|
|
||||||
| --- | --- |
|
|
||||||
| **Border Style** | Solid, dashed, or dotted. |
|
|
||||||
| **Border Width** | Border thickness in points. |
|
|
||||||
| **Border Radius** | Corner roundness in points. |
|
|
||||||
| **Border Color** | Border color. |
|
|
||||||
|
|
||||||
Borders are most useful on container slots such as **Section container**, **Item container**, **Paragraph**, and **List item row**.
|
|
||||||
|
|
||||||
## Style Slots reference
|
|
||||||
|
|
||||||
Style Slots are semantic targets. They describe the part of a section that receives the style.
|
|
||||||
|
|
||||||
### Section slots
|
|
||||||
|
|
||||||
Section slots affect the structured fields of a resume section, such as titles, item headers, dates, keywords, profile links, and level indicators.
|
|
||||||
|
|
||||||
| Style Slot | What it affects | Useful examples |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Section container** | The outer wrapper for a section, including the heading and section content. | Add a background tint behind a whole section, add section padding, or place a border around one custom section. |
|
|
||||||
| **Section heading** | The section title, such as Experience, Education, Projects, or a custom section title. | Make all headings uppercase, add extra margin below headings, or use a different color for Skills headings. |
|
|
||||||
| **Item container** | Each item inside a section, such as one job, one school, one project, one skill, or one summary item. | Add padding around each Project, create card-like Education entries, or increase the vertical gap inside Skill items. |
|
|
||||||
| **Primary text** | Normal section text and bold item titles rendered by the template, such as company names, roles, schools, dates, periods, and labels. | Make Experience body text slightly smaller, change date text color in a section type, or align normal text in a custom section. |
|
|
||||||
| **Secondary text** | Smaller supporting text rendered as secondary content, such as skill keywords or interest keywords. | Make skill keywords lighter, reduce keyword font size, or increase opacity for muted metadata. |
|
|
||||||
| **Link** | Structured links outside rich-text descriptions, such as item website links and linked item titles. | Underline project links, change website link color, or make all profile links use the primary color. |
|
|
||||||
| **Icon** | Section-content icons, such as profile, skill, interest, and custom-field icons rendered inside sections. Icon-based level indicators also use the shared icon styling. | Change icon color in Skills, reduce icon opacity in Interests, or use a softer color so icons do not compete with the text. |
|
|
||||||
| **Level indicator** | The wrapper around proficiency indicators used by Skills and Languages. | Add space above level indicators, reduce opacity for less prominent levels, or place a light border around the whole scale. |
|
|
||||||
|
|
||||||
<Info>
|
|
||||||
Custom Styles currently target sections and rich-text content. The resume header, profile picture, name, headline, and
|
|
||||||
contact area are controlled by template, Design, Typography, Page, and Picture settings instead of these section
|
|
||||||
slots.
|
|
||||||
</Info>
|
|
||||||
|
|
||||||
### Rich-text slots
|
|
||||||
|
|
||||||
Rich-text slots affect content entered in rich-text editors, such as Summary content, Experience descriptions, Education descriptions, Project descriptions, Awards, Certifications, Publications, Volunteer, References, cover letters, and summary-style custom sections.
|
|
||||||
|
|
||||||
They do not affect structured fields like company name, school name, date, or website unless those values are inside a rich-text description.
|
|
||||||
|
|
||||||
| Style Slot | What it affects | Useful examples |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| **Paragraph** | Paragraph blocks inside rich-text content. | Tighten long summaries with a smaller line height, add margin between cover letter paragraphs, or add a subtle background behind summary paragraphs. |
|
|
||||||
| **List** | Ordered and unordered list containers inside rich text. | Increase **Row Gap** to add space between bullet items, or reduce **Row Gap** to fit more achievements on a page. |
|
|
||||||
| **List item row** | The outer row for each rich-text list item, including the bullet or number marker and the text content. | Increase **Column Gap** to add more space between the bullet icon and the text, reduce **Column Gap** for compact lists, or add padding/background around each bullet row. |
|
|
||||||
| **List item content** | The text/content area of each rich-text list item after the bullet or number marker. | Change bullet text line height, make only list content smaller, or apply text color without changing the bullet row layout. |
|
|
||||||
| **Inline link** | Links inside rich-text descriptions. This is separate from the **Link** slot used by structured website fields. | Underline links in descriptions, change inline link color, or make links use a dotted underline. |
|
|
||||||
| **Bold text** | Bold or strong text inside rich-text descriptions. | Make bold achievements use the primary color, increase bold font weight, or remove extra emphasis by lowering the weight. |
|
|
||||||
| **Highlight** | Highlighted text inside rich-text descriptions. | Change the default highlight background, make highlighted metrics use a different text color, or reduce highlight opacity. |
|
|
||||||
|
|
||||||
<Tip>
|
|
||||||
**List item row** and **List item content** are intentionally separate. Use **List item row** for layout and chrome,
|
|
||||||
such as padding, background, border, opacity, and the marker-to-text **Column Gap**. Use **List item content** for the
|
|
||||||
bullet text itself, such as color, font size, font weight, line height, text decoration, and text transform.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
## Practical examples
|
|
||||||
|
|
||||||
### Increase the space between bullet markers and text
|
|
||||||
|
|
||||||
Use this when bullet text feels too close to the bullet icon or number.
|
|
||||||
|
|
||||||
1. Set **Target Scope** to **All sections** or choose a specific section type, such as **Experience**.
|
|
||||||
2. Set **Style Slot** to **List item row**.
|
|
||||||
3. In **Spacing**, increase **Column Gap**.
|
|
||||||
4. Review the preview and adjust in small increments.
|
|
||||||
|
|
||||||
### Make section headings more distinct
|
|
||||||
|
|
||||||
Use this when your template headings need more contrast.
|
|
||||||
|
|
||||||
1. Set **Target Scope** to **All sections**.
|
|
||||||
2. Set **Style Slot** to **Section heading**.
|
|
||||||
3. Set **Text Color** to your primary brand color.
|
|
||||||
4. Set **Text Transform** to **Uppercase** if you want a stronger heading style.
|
|
||||||
5. Add a small **Margin Bottom** value if headings feel too close to the content.
|
|
||||||
|
|
||||||
### Create card-like project items
|
|
||||||
|
|
||||||
Use this when you want one section to feel visually grouped without changing the whole resume.
|
|
||||||
|
|
||||||
1. Set **Target Scope** to **Specific section**.
|
|
||||||
2. Choose your Projects section.
|
|
||||||
3. Set **Style Slot** to **Item container**.
|
|
||||||
4. Add **Padding** on each side.
|
|
||||||
5. Set a light **Background** color.
|
|
||||||
6. Add **Border Width**, **Border Color**, and a small **Border Radius** if the template supports the look.
|
|
||||||
|
|
||||||
### Tighten long descriptions
|
|
||||||
|
|
||||||
Use this when descriptions or bullet lists take too much vertical space.
|
|
||||||
|
|
||||||
1. Set **Target Scope** to the long section type, such as **Experience**.
|
|
||||||
2. Set **Style Slot** to **Paragraph** and reduce **Line Height** slightly.
|
|
||||||
3. Set **Style Slot** to **List** and reduce **Row Gap**.
|
|
||||||
4. Set **Style Slot** to **List item content** and reduce **Line Height** if bullet text still feels loose.
|
|
||||||
|
|
||||||
<Warning>
|
|
||||||
Avoid reducing line height so far that letters collide or text becomes hard to scan. If the resume still overflows,
|
|
||||||
cut content before making the typography cramped.
|
|
||||||
</Warning>
|
|
||||||
|
|
||||||
### Muting skill keywords
|
|
||||||
|
|
||||||
Use this when skill keywords or interest keywords compete with the main labels.
|
|
||||||
|
|
||||||
1. Set **Target Scope** to **Section type**.
|
|
||||||
2. Choose **Skills** or **Interests**.
|
|
||||||
3. Set **Style Slot** to **Secondary text**.
|
|
||||||
4. Lower **Opacity** or choose a softer **Text Color**.
|
|
||||||
|
|
||||||
## Manage applied rules
|
|
||||||
|
|
||||||
Every rule you create appears in **Applied Rules**. Each rule shows its target, style slot, and a compact summary of the properties you set.
|
|
||||||
|
|
||||||
<Frame caption="Screenshot of the Applied Rules list">
|
|
||||||
<img
|
|
||||||
src="/images/guides/using-custom-styles/screenshot-5.webp"
|
|
||||||
alt="Applied Rules list showing enabled and disabled custom style rules with edit and delete actions"
|
|
||||||
/>
|
|
||||||
</Frame>
|
|
||||||
|
|
||||||
Use the rule actions to:
|
|
||||||
|
|
||||||
- **Disable or enable** a rule without deleting it.
|
|
||||||
- **Edit** a rule by loading its target and slot back into the style editor.
|
|
||||||
- **Delete** a rule permanently.
|
|
||||||
- **Reset Style** to remove the rule for the currently selected target and slot.
|
|
||||||
|
|
||||||
<Tip>
|
|
||||||
If a style change looks wrong, disable the rule first. If the resume looks correct again, edit or delete that rule
|
|
||||||
instead of changing unrelated settings.
|
|
||||||
</Tip>
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### The style did not change anything
|
|
||||||
|
|
||||||
Check that the selected slot exists in the selected target.
|
|
||||||
|
|
||||||
Common mismatches:
|
|
||||||
|
|
||||||
- Using **Paragraph** for company names or dates. Use **Primary text** instead.
|
|
||||||
- Using **Link** for links inside a description. Use **Inline link** instead.
|
|
||||||
- Using **Secondary text** in a section that does not render secondary text.
|
|
||||||
- Styling **Level indicator** in a section with no skill or language level values.
|
|
||||||
|
|
||||||
### A section-specific rule is overriding my global rule
|
|
||||||
|
|
||||||
This is expected. More specific rules override broader rules for the same property and slot. Check **Applied Rules** for matching Section type or Specific section rules.
|
|
||||||
|
|
||||||
### The resume looks cramped or content overlaps
|
|
||||||
|
|
||||||
Disable the most recent spacing rule and review the preview again. Large negative margins, very small line height, and high border widths are the most common causes.
|
|
||||||
|
|
||||||
### The PDF does not match the preview
|
|
||||||
|
|
||||||
Refresh the builder and export again. The preview and PDF export use the same resume rendering path, so persistent differences usually come from stale preview state or font loading.
|
|
||||||
|
|
||||||
### I want to write custom CSS
|
|
||||||
|
|
||||||
Custom Styles do not accept raw CSS. Reactive Resume renders final resumes through a PDF renderer, so Custom Styles use structured style rules that can be safely translated to PDF styles.
|
|
||||||
+22273
-979
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,667 @@
|
|||||||
|
# Semantic CSS Stylesheet Design
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft for user review. The product behavior in this document has been approved conversationally; the written
|
||||||
|
architecture still requires review before implementation planning.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Reactive Resume renders its templates with React PDF rather than browser HTML. React PDF accepts style objects on a
|
||||||
|
known component tree and supports a broad CSS-like property set, but it does not provide a browser DOM or a general
|
||||||
|
selector engine.
|
||||||
|
|
||||||
|
The current customization system stores constrained rules in `metadata.styleRules`. Each rule targets all sections, a
|
||||||
|
section type, or a section ID and applies an intent to one semantic slot. That design is safe and portable, but its form
|
||||||
|
UI is cumbersome to reproduce or share, and its target model cannot reach headers, individual items or fields, page
|
||||||
|
regions, or template-specific visual parts.
|
||||||
|
|
||||||
|
Semantic CSS replaces the form with a familiar text language. It retains typed compilation and semantic targets rather
|
||||||
|
than promising that arbitrary browser CSS can run inside React PDF.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Provide one copy-pastable text stylesheet for all PDF-specific visual customization.
|
||||||
|
- Keep Design, Typography, Layout, Page, and Picture controls as base settings.
|
||||||
|
- Let the stylesheet override those base visuals wherever an exposed semantic PDF node permits it.
|
||||||
|
- Target all sections, groups of section types, one section, one item, one field, structural regions, header content,
|
||||||
|
rich text, and documented template-specific parts.
|
||||||
|
- Support portable theme rules and optional resume-specific rules based on stable IDs.
|
||||||
|
- Support nearly all style properties that the pinned React PDF renderer can safely implement.
|
||||||
|
- Preserve invalid user text while rendering the last valid stylesheet.
|
||||||
|
- Produce identical behavior in browser preview, browser export, public PDF views, and server PDF export.
|
||||||
|
- Convert existing structured style rules without changing their rendered appearance.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- The stylesheet does not edit resume content or mutate builder layout metadata.
|
||||||
|
- The stylesheet does not apply to DOCX or Markdown exports.
|
||||||
|
- It does not expose a browser DOM, JavaScript, arbitrary renderer objects, or executable expressions.
|
||||||
|
- It does not support animations, transitions, interactive pseudo-classes, CSS Grid, generated content, or browser-only
|
||||||
|
properties.
|
||||||
|
- It does not load fonts, images, imports, or any other remote or embedded asset.
|
||||||
|
- Font-family selection remains owned by the Typography section.
|
||||||
|
- Picture source, upload, crop, and visibility data remain owned by the Picture section. The rendered picture node can
|
||||||
|
still be sized, positioned, transformed, or hidden by the stylesheet.
|
||||||
|
|
||||||
|
## Product Model
|
||||||
|
|
||||||
|
The existing visual controls remain the base layer. Semantic CSS is the final author-controlled layer:
|
||||||
|
|
||||||
|
1. Builder visual settings and template defaults.
|
||||||
|
2. Template-specific computed styles.
|
||||||
|
3. Semantic CSS declarations.
|
||||||
|
4. Minimal crash-prevention invariants.
|
||||||
|
|
||||||
|
The stylesheet may visually hide, reorder, resize, or position existing output. These changes affect only PDF
|
||||||
|
presentation. They do not rewrite content, section ordering, page assignments, or other builder data.
|
||||||
|
|
||||||
|
## Persisted Data
|
||||||
|
|
||||||
|
Resume metadata gains a versioned stylesheet value:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type StylesheetSource = {
|
||||||
|
languageVersion: number;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SemanticStylesheet = {
|
||||||
|
mode: "legacy" | "semantic";
|
||||||
|
source: StylesheetSource;
|
||||||
|
applied: StylesheetSource;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StylesheetMutationState = {
|
||||||
|
revision: number;
|
||||||
|
stylesheet: SemanticStylesheet;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
- `source.text` is the exact editable text and may be invalid.
|
||||||
|
- `applied.text` is the most recent valid text and is the only text used for rendering.
|
||||||
|
- Each value carries its own `languageVersion`, allowing an invalid source written for a future language version to
|
||||||
|
preserve and render an older valid program.
|
||||||
|
- `mode` is the persisted rendering discriminator. A missing stylesheet is interpreted as `legacy`.
|
||||||
|
- `revision` is server-owned concurrency metadata, not resume content. It is stored in a dedicated database column and
|
||||||
|
returned only in the stylesheet mutation envelope.
|
||||||
|
|
||||||
|
The compiled AST or intermediate representation is not persisted. Browser and server compilation is a pure operation
|
||||||
|
cached by language version, source hash, compiler build, semantic registry fingerprint, and PDF adapter fingerprint.
|
||||||
|
Caches are bounded and process-local; they are never treated as durable state.
|
||||||
|
|
||||||
|
Stylesheet state is owned by a dedicated authenticated mutation rather than the existing full-document autosave
|
||||||
|
mutation. It accepts an expected stylesheet revision and resume render-data version. The generic `resume.update` path
|
||||||
|
must preserve the database's stylesheet value instead of replacing it from submitted resume data. This preservation
|
||||||
|
behavior must deploy before clients can send Semantic CSS data.
|
||||||
|
|
||||||
|
Compilation and PDF preflight never run while holding a database lock. The mutation reads an immutable resume snapshot,
|
||||||
|
compiles and preflights against that snapshot, then performs a short transaction that compare-and-swaps both the
|
||||||
|
stylesheet revision and resume render-data version. If either changed, it returns a conflict without writing; the client
|
||||||
|
rebases its unsaved source onto the new snapshot and retries. This prevents promotion against content or base settings
|
||||||
|
that differ from those preflighted.
|
||||||
|
|
||||||
|
The server defines separate state transitions. A source can replace `applied` only after compilation and a bounded PDF
|
||||||
|
render preflight against the current resume succeed:
|
||||||
|
|
||||||
|
- **Edit source:** ignore client-applied text. Store the candidate in `source`. In semantic mode, also store it in
|
||||||
|
`applied` only when compilation and preflight succeed; otherwise preserve the row's current `applied`. In legacy mode,
|
||||||
|
edits remain an inactive draft.
|
||||||
|
- **Activate converted source:** require successful compilation, set `mode` to `semantic`, and store the candidate in
|
||||||
|
both source values after preflight. This requires an explicit **Activate Semantic CSS** action. Merely opening,
|
||||||
|
editing, or autosaving a legacy draft does not activate it.
|
||||||
|
- **Editor undo or redo:** independently compile the historical applied value carried by the local history entry, then
|
||||||
|
preflight it and atomically restore the historical source/applied pair. Reject the transition if the applied value is
|
||||||
|
invalid.
|
||||||
|
- **Import:** compile imported source. If it is invalid, independently validate the imported applied value and retain it
|
||||||
|
only after preflight; otherwise use an empty supported applied source.
|
||||||
|
- **Duplicate:** copy the server-owned stylesheet content while initializing a fresh concurrency revision for the new
|
||||||
|
resume.
|
||||||
|
- **Restore version:** restore the server-owned source/applied pair from the selected snapshot after validating the
|
||||||
|
applied value with its versioned compiler and preflight.
|
||||||
|
|
||||||
|
Every successful transition increments `revision` and returns the canonical state plus diagnostics. Worker jobs and
|
||||||
|
network requests carry the local edit generation and expected revision. The client serializes stylesheet mutations:
|
||||||
|
only one request is in flight, and later edits replace one queued candidate. Every acknowledgement advances the local
|
||||||
|
revision; its source/applied payload updates editor state only when its generation is still current. The queued candidate
|
||||||
|
then submits with the acknowledged revision. Warnings do not block application.
|
||||||
|
|
||||||
|
Concurrency revisions are excluded from JSON export and version snapshots. Import and duplicate initialize a fresh
|
||||||
|
revision; version restore increments the current resume's revision rather than restoring historical concurrency
|
||||||
|
metadata.
|
||||||
|
|
||||||
|
## Compiler Architecture
|
||||||
|
|
||||||
|
The compiler is a universal, environment-neutral package used by the web app, API, and PDF renderer:
|
||||||
|
|
||||||
|
```text
|
||||||
|
source
|
||||||
|
-> CSS tokenizer/parser
|
||||||
|
-> syntax AST
|
||||||
|
-> restricted-language validation
|
||||||
|
-> selector and value compilation
|
||||||
|
-> versioned StyleProgram + diagnostics
|
||||||
|
```
|
||||||
|
|
||||||
|
`StyleProgram` contains normalized selectors, declaration values, source locations, specificity, media conditions, and
|
||||||
|
structural directives. It contains no React or React PDF values. A PDF adapter translates resolved declarations into
|
||||||
|
React PDF styles and primitive props.
|
||||||
|
|
||||||
|
The parser should use a standards-compatible CSS parser rather than a hand-written partial tokenizer. Semantic CSS
|
||||||
|
validation sits on top of that parser and rejects unsupported CSS constructs explicitly.
|
||||||
|
|
||||||
|
Compilation and selector matching must remain deterministic. Diagnostics include severity, code, message, and exact
|
||||||
|
source range.
|
||||||
|
|
||||||
|
Source compilation reports syntax and language-contract diagnostics without needing a resume. A separate semantic
|
||||||
|
analysis pass evaluates a compiled program against the current resume's virtual tree and reports context-dependent
|
||||||
|
warnings such as valid selectors that match no node. Both passes use shared diagnostic types and codes.
|
||||||
|
|
||||||
|
Language versions are positive integers. A compiler implementation for a released version is immutable. Unsupported
|
||||||
|
source versions are preserved as opaque editable text but cannot replace `applied`; rendering continues with the
|
||||||
|
supported applied version or base styles when no supported applied value exists.
|
||||||
|
|
||||||
|
Every compiler version referenced by persisted `applied` data remains available. A compiler can be retired only after a
|
||||||
|
transactional migration recompiles and preflights every affected applied stylesheet with a newer version and no stored
|
||||||
|
resume references the old version.
|
||||||
|
|
||||||
|
## Virtual Semantic Tree
|
||||||
|
|
||||||
|
Selectors match a versioned, immutable virtual resume tree, not React component names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
resume
|
||||||
|
page
|
||||||
|
region
|
||||||
|
header
|
||||||
|
picture
|
||||||
|
name
|
||||||
|
headline
|
||||||
|
contact-list
|
||||||
|
contact-item
|
||||||
|
section
|
||||||
|
section-heading
|
||||||
|
section-items
|
||||||
|
item
|
||||||
|
item-header
|
||||||
|
field
|
||||||
|
link
|
||||||
|
icon
|
||||||
|
level
|
||||||
|
rich-text
|
||||||
|
paragraph
|
||||||
|
list
|
||||||
|
list-item
|
||||||
|
list-marker
|
||||||
|
```
|
||||||
|
|
||||||
|
Template-owned chrome is exposed as `template-part` nodes. Every part name must be registered, documented, and stable.
|
||||||
|
Examples include `timeline-line`, `timeline-dot`, `featured-summary`, `sidebar-background`, and
|
||||||
|
`item-header-border`.
|
||||||
|
|
||||||
|
Each node carries only documented semantic attributes, including the applicable subset of:
|
||||||
|
|
||||||
|
- `id`: stable section or item ID.
|
||||||
|
- `type`: canonical section type.
|
||||||
|
- `name`: field, contact, or template-part name.
|
||||||
|
- `template`: selected template on the root.
|
||||||
|
- `placement`: `main` or `sidebar`.
|
||||||
|
- `region`: `header`, `main`, `sidebar`, `featured`, or another registered region.
|
||||||
|
- `page-number`: one-based layout page number.
|
||||||
|
- `role`: one or more stable roles such as `primary-text`, `secondary-text`, or `structured-link`.
|
||||||
|
|
||||||
|
Custom classes are not supported because resume data has no class-authoring surface. Groups are expressed through
|
||||||
|
selector lists, attributes, `:is()`, and `:where()`.
|
||||||
|
|
||||||
|
All shared primitives and all 15 templates must register their semantic nodes before Semantic CSS becomes the default.
|
||||||
|
Known semantic nodes that are absent from the current template are valid no-ops and produce warnings.
|
||||||
|
|
||||||
|
The normative node contract is:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type SemanticNode = {
|
||||||
|
key: string;
|
||||||
|
kind: SemanticNodeKind;
|
||||||
|
id?: string;
|
||||||
|
attributes: Readonly<Record<string, string>>;
|
||||||
|
roles: readonly string[];
|
||||||
|
children: readonly SemanticNode[];
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
Each template builds one authoritative descriptor tree from `ResumeData`, template configuration, normalized rich-text
|
||||||
|
content, and the typed semantic registries. Selector matching, context-dependent diagnostics, inheritance, structural
|
||||||
|
resolution, and React rendering all consume that same tree. React components must not create unregistered semantic
|
||||||
|
children independently.
|
||||||
|
|
||||||
|
The registries normatively define allowed parentage, cardinality, field names, role names, stable keys, and
|
||||||
|
template-part placement. Experience roles, custom fields, rich-text nodes, featured summaries, and template-specific
|
||||||
|
header structures are explicitly represented rather than inferred from React children.
|
||||||
|
|
||||||
|
## Selector Language
|
||||||
|
|
||||||
|
Semantic CSS supports:
|
||||||
|
|
||||||
|
- Type selectors and the universal selector.
|
||||||
|
- ID and attribute selectors.
|
||||||
|
- Selector lists separated by commas.
|
||||||
|
- Descendant, child, adjacent-sibling, and general-sibling combinators.
|
||||||
|
- `:is()`, `:where()`, and `:not()`.
|
||||||
|
- Static structural pseudo-classes such as `:first-child`, `:last-child`, `:only-child`, `:nth-child()`, and
|
||||||
|
`:nth-of-type()`.
|
||||||
|
|
||||||
|
Interactive or browser-state pseudo-classes are errors.
|
||||||
|
|
||||||
|
`SemanticNode.id` is reflected to both `#id` and `[id="…"]`. `roles` is reflected as a space-separated `role`
|
||||||
|
attribute and matched with `[role~="token"]`. Other entries in `attributes` are exposed by their registered names.
|
||||||
|
Presence, `=`, `~=`, `|=`, `^=`, `$=`, and `*=` attribute operators are supported. Semantic element, attribute, role,
|
||||||
|
and registered keyword names are lowercase and ASCII case-sensitive. Values and IDs are case-sensitive. Selectors use
|
||||||
|
standard CSS escaping; quoted `[id="…"]` is the recommended syntax for UUIDs that would require identifier escapes.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--accent: #2563eb;
|
||||||
|
--compact-gap: 4pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
section:is([type="experience"], [type="education"]) {
|
||||||
|
margin-bottom: 8pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
section#experience > section-heading {
|
||||||
|
color: var(--accent);
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
region[placement="sidebar"] section,
|
||||||
|
section#skills {
|
||||||
|
background-color: rgba(20, 30, 40, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
item[id="f27be2d2-13a9-4f16-8248-c8735a27dd1c"] field[name="period"] {
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
resume[template="azurill"] template-part[name="timeline-dot"] {
|
||||||
|
background-color: var(--accent);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Portable styles should prefer section types, roles, placements, regions, and template attributes. Exact section and item
|
||||||
|
IDs are available when a rule intentionally belongs to one resume.
|
||||||
|
|
||||||
|
## Cascade and Inheritance
|
||||||
|
|
||||||
|
Semantic CSS follows familiar author-style cascade rules:
|
||||||
|
|
||||||
|
- `!important` declarations outrank normal declarations.
|
||||||
|
- Specificity compares IDs, then attributes and pseudo-classes, then element names.
|
||||||
|
- `:where()` contributes zero specificity.
|
||||||
|
- Equal specificity is resolved by source order.
|
||||||
|
- Custom properties cascade and inherit.
|
||||||
|
- Cyclic or unresolved variables are errors unless a valid fallback exists.
|
||||||
|
|
||||||
|
Only properties marked inheritable in the property registry inherit through the semantic tree. Box and layout
|
||||||
|
properties never inherit implicitly. The language supports `inherit`, `initial`, `unset`, and `revert`; `revert`
|
||||||
|
removes the winning Semantic CSS declaration at that node and exposes its builder/template base value. If the property
|
||||||
|
is inheritable and the semantic parent has a computed Semantic CSS value, normal inheritance can still supply that
|
||||||
|
parent value. `initial` uses the property registry's initial value, `inherit` uses the semantic parent's computed value,
|
||||||
|
and `unset` chooses `inherit` for inheritable properties and `initial` otherwise. `revert-layer` is unsupported.
|
||||||
|
|
||||||
|
Declarations are resolved after template styles. Existing cosmetic safety defaults such as text shrinking must move
|
||||||
|
below the stylesheet in precedence. Only constraints required to prevent renderer failure may remain above user
|
||||||
|
declarations, and each such constraint must be documented.
|
||||||
|
|
||||||
|
Resolution uses one immutable source-tree snapshot:
|
||||||
|
|
||||||
|
1. Match all selectors against original parentage and sibling order.
|
||||||
|
2. Calculate selector specificity according to CSS rules: `:is()` and `:not()` take their most specific argument,
|
||||||
|
while `:where()` has zero specificity.
|
||||||
|
3. Cascade declarations and custom properties, then calculate inherited values.
|
||||||
|
4. Resolve structural declarations once.
|
||||||
|
5. Omit `display: none` subtrees and stable-sort remaining siblings by `order`, using original sibling order for ties.
|
||||||
|
6. Render the resolved tree.
|
||||||
|
|
||||||
|
Hidden and reordered nodes never change which selectors match, positional pseudo-classes, sibling combinators, or
|
||||||
|
inheritance. Structural declarations cannot trigger a second selector pass.
|
||||||
|
|
||||||
|
## Properties, Values, and Units
|
||||||
|
|
||||||
|
The property registry exposes the applicable React PDF surface under familiar kebab-case names:
|
||||||
|
|
||||||
|
- Flexbox layout, including gaps and `order`.
|
||||||
|
- Width, height, minimum and maximum dimensions.
|
||||||
|
- Relative and absolute positioning, overflow, stacking, and display.
|
||||||
|
- Color, background color, and opacity.
|
||||||
|
- Text size, weight, style, line height, spacing, alignment, decoration, transform, indentation, overflow, and line
|
||||||
|
limits.
|
||||||
|
- Margins, padding, borders, radii, and supported transforms.
|
||||||
|
- Supported image sizing and object-fit behavior on the existing picture node.
|
||||||
|
|
||||||
|
`font-family` is rejected. Asset-bearing properties and functions such as `background-image`, `src`, and `url()` are
|
||||||
|
rejected.
|
||||||
|
|
||||||
|
Common shorthands such as `margin`, `padding`, `border`, `gap`, `flex`, and `transform` compile into normalized values.
|
||||||
|
Supported units are `pt`, `in`, `mm`, `cm`, `%`, `vw`, `vh`, `em`, and `rem`. Unitless PDF dimensions are interpreted
|
||||||
|
as points. `px` is accepted for familiarity and converted from 96 DPI to 72-DPI PDF points.
|
||||||
|
|
||||||
|
`rem` resolves against the root body font size from Typography. For `font-size`, `em` resolves against the semantic
|
||||||
|
parent's computed font size. For all other properties, it resolves against the target node's computed font size.
|
||||||
|
Relative-unit cycles are errors.
|
||||||
|
|
||||||
|
Media queries use standard syntax and support page width, page height, and orientation:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@media (max-width: 500pt) {
|
||||||
|
region[placement="sidebar"] {
|
||||||
|
width: 30%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Page and pagination behavior uses standard properties where possible and namespaced extensions where React PDF exposes
|
||||||
|
primitive props rather than style properties:
|
||||||
|
|
||||||
|
```css
|
||||||
|
section[type="experience"] {
|
||||||
|
break-inside: avoid;
|
||||||
|
-resume-min-presence-ahead: 24pt;
|
||||||
|
}
|
||||||
|
|
||||||
|
page {
|
||||||
|
size: A4;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
-resume-fixed: true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported structural declarations include:
|
||||||
|
|
||||||
|
- `display: none` to omit a semantic node.
|
||||||
|
- `order` to reorder siblings before React rendering.
|
||||||
|
- `break-before: page`.
|
||||||
|
- `break-inside: avoid`.
|
||||||
|
- `orphans` and `widows`.
|
||||||
|
- `-resume-fixed`.
|
||||||
|
- `-resume-min-presence-ahead`.
|
||||||
|
- `size` on page nodes.
|
||||||
|
|
||||||
|
Structural declarations are resolved while preparing semantic child descriptors, before the React component tree is
|
||||||
|
created. CSS cannot move a node to a different parent; absolute positioning can only change its visual placement.
|
||||||
|
|
||||||
|
`page-number` identifies the one-based authored `metadata.layout.pages` entry. React PDF may wrap one authored page into
|
||||||
|
multiple physical subpages; those physical subpages are not independently selectable. They inherit the authored page
|
||||||
|
context, and fixed nodes repeat on physical subpages created from that authored page.
|
||||||
|
|
||||||
|
Page sizing is evaluated in a non-circular phase. Non-media `size` declarations resolve first against builder defaults.
|
||||||
|
Media conditions then evaluate against that final authored page size. `size` inside `@media` is an error.
|
||||||
|
|
||||||
|
Values must be finite. Very large, negative, or overlap-prone values produce warnings rather than cosmetic clamping.
|
||||||
|
Hard technical limits exist only to prevent crashes, pathological allocations, or denial of service.
|
||||||
|
|
||||||
|
## Editor Experience
|
||||||
|
|
||||||
|
The Custom Styles right-sidebar section becomes a monospaced stylesheet editor. It also offers an expanded mode with
|
||||||
|
more editing space while retaining the live preview.
|
||||||
|
|
||||||
|
Editor capabilities include:
|
||||||
|
|
||||||
|
- CSS syntax highlighting.
|
||||||
|
- Line and column diagnostics with error and warning severity.
|
||||||
|
- Selector, attribute, property, keyword, and variable completion.
|
||||||
|
- Hover documentation generated from semantic and property registries.
|
||||||
|
- Color previews.
|
||||||
|
- Search and replace.
|
||||||
|
- Explicit formatting.
|
||||||
|
- Standard copy and paste.
|
||||||
|
- A clear applied state.
|
||||||
|
|
||||||
|
The editor preserves source text and formatting exactly unless the user explicitly formats it.
|
||||||
|
|
||||||
|
Compilation runs after a short debounce in a web worker. The status must distinguish:
|
||||||
|
|
||||||
|
- `Applied`.
|
||||||
|
- Applied with warnings.
|
||||||
|
- Errors, with an explicit message that preview and export use the last valid version.
|
||||||
|
|
||||||
|
The editor maintains source state separately from full-resume autosave. It runs a browser render preflight for a
|
||||||
|
compiled candidate and sends serialized, debounced, revisioned stylesheet mutations. It always consumes response
|
||||||
|
revisions, but replaces visible source/applied state only for the current edit generation. Existing coalesced undo and
|
||||||
|
redo behavior includes both stylesheet values and uses the explicit restore transition, so undo restores matching text
|
||||||
|
and rendered output.
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
Errors prevent a new source from becoming applied:
|
||||||
|
|
||||||
|
- Invalid CSS syntax.
|
||||||
|
- Unknown semantic element or attribute.
|
||||||
|
- Unknown or unsupported property.
|
||||||
|
- Invalid value, unit, selector, pseudo-class, at-rule, or variable cycle.
|
||||||
|
- Disallowed font or asset access.
|
||||||
|
- Exceeded source, rule, nesting, or selector-complexity limit.
|
||||||
|
|
||||||
|
Warnings do not prevent application:
|
||||||
|
|
||||||
|
- A known selector matches no node in the current resume or template.
|
||||||
|
- A property is valid but ineffective on the selected semantic node.
|
||||||
|
- An extreme value is likely to cause overlap, clipping, or unreadable output.
|
||||||
|
|
||||||
|
The server returns compiler diagnostics for save responses. Browser diagnostics remain immediate and use the same
|
||||||
|
compiler, semantic analyzer, and diagnostic codes.
|
||||||
|
|
||||||
|
Editable source, source locations, comments, and diagnostics are owner-only data. Public resume responses exclude both
|
||||||
|
stylesheet source values. They contain a fully resolved projection:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type PublicStyleProjection = {
|
||||||
|
formatVersion: 1;
|
||||||
|
languageVersion: number;
|
||||||
|
semanticTreeVersion: number;
|
||||||
|
registryFingerprint: string;
|
||||||
|
adapterFingerprint: string;
|
||||||
|
renderDataHash: string;
|
||||||
|
nodes: Readonly<Record<string, ResolvedPdfNodeStyle>>;
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
The server builds this projection from the applied program and authoritative semantic tree. It contains final
|
||||||
|
declarations and structural props keyed by stable node key, with variables already resolved and comments, variable
|
||||||
|
names, selectors, source spans, and diagnostics removed. The public browser accepts it only when all versions,
|
||||||
|
fingerprints, and render-data hash match.
|
||||||
|
|
||||||
|
`renderDataHash` is SHA-256 over a domain-separated, RFC 8785 JSON Canonicalization Scheme serialization of the complete
|
||||||
|
public render input and resolved node projection. The domain includes the projection format version. It excludes
|
||||||
|
owner-only metadata and both stylesheet source values. The browser recomputes the hash before accepting the projection.
|
||||||
|
On mismatch it requests a fresh projection or falls back to the server-rendered PDF. That fallback uses the existing
|
||||||
|
public-resume visibility/password policy and public rendering rate limits; it is not an authorization bypass. Server PDF
|
||||||
|
export compiles the database's applied value directly.
|
||||||
|
|
||||||
|
## Legacy Migration
|
||||||
|
|
||||||
|
`metadata.styleRules` remains readable during compatibility rollout.
|
||||||
|
|
||||||
|
If a resume has legacy rules but no active Semantic CSS value:
|
||||||
|
|
||||||
|
1. Existing PDF rendering continues to use legacy rules.
|
||||||
|
2. Opening Custom Styles deterministically converts the rules into Semantic CSS.
|
||||||
|
3. The generated source preserves target specificity and array order.
|
||||||
|
4. Camel-case intent properties become kebab-case CSS declarations.
|
||||||
|
5. Numeric dimensions become explicit point values.
|
||||||
|
6. Rule labels become comments.
|
||||||
|
7. Disabled rules become clearly labeled commented blocks.
|
||||||
|
8. Draft autosave keeps legacy rendering active.
|
||||||
|
9. The user compares the converted preview and explicitly selects **Activate Semantic CSS**; active stylesheet
|
||||||
|
rendering then takes precedence.
|
||||||
|
|
||||||
|
Legacy target and slot mappings compile to equivalent semantic selectors and roles. For example:
|
||||||
|
|
||||||
|
```css
|
||||||
|
/* Experience heading */
|
||||||
|
section[type="experience"] > section-heading {
|
||||||
|
font-size: 20pt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Conversion is behavioral rather than a blind property rename. It evaluates each rule through the legacy resolver,
|
||||||
|
including specificity, numeric clamps, link-decoration ordering, bold/template precedence, icon-size translation, and
|
||||||
|
known template exceptions. The serializer emits the effective stylesheet deltas needed to preserve the current
|
||||||
|
resume's rendered appearance. It retains portable original scopes where behavior is equivalent and emits
|
||||||
|
resume-specific role or ID exceptions where legacy composition requires them.
|
||||||
|
|
||||||
|
Labels, IDs, attribute values, comments, strings, and comment terminators are escaped through one CSS serializer. Legacy
|
||||||
|
declarations that had no rendered effect remain non-applying and are explained in generated comments rather than
|
||||||
|
silently gaining new behavior.
|
||||||
|
|
||||||
|
Visual parity is guaranteed at activation for the current resume data, template, and builder base settings. Subsequent
|
||||||
|
template or base-setting changes follow Semantic CSS behavior; they are not guaranteed to reproduce how the retired
|
||||||
|
legacy resolver would have reacted.
|
||||||
|
|
||||||
|
Legacy rules remain as read-only rollback data during the flagged compatibility phase. Old Reactive Resume JSON imports
|
||||||
|
continue to parse them. New exports include the complete versioned stylesheet value. Copying from the editor copies only
|
||||||
|
the editable `source`.
|
||||||
|
|
||||||
|
No bulk database migration is required.
|
||||||
|
|
||||||
|
The server-owned stylesheet revision requires a normal DDL migration that adds a revision column with a zero default.
|
||||||
|
The statement above means no bulk backfill or rewrite of existing resume JSONB rows is required.
|
||||||
|
|
||||||
|
## Security and Resource Limits
|
||||||
|
|
||||||
|
Semantic CSS is declarative and cannot execute code or fetch resources.
|
||||||
|
|
||||||
|
The compiler enforces bounded:
|
||||||
|
|
||||||
|
- Source length.
|
||||||
|
- Rule and declaration count.
|
||||||
|
- Selector length and combinator count.
|
||||||
|
- Functional pseudo-class nesting.
|
||||||
|
- Variable expansion depth.
|
||||||
|
- Media-query nesting.
|
||||||
|
|
||||||
|
Compiler caches are bounded by count and total memory. Browser compilation runs in a worker. Server compilation uses the
|
||||||
|
same limits before rendering or persistence. Unsupported language versions are rejected explicitly rather than silently
|
||||||
|
interpreted by a newer grammar.
|
||||||
|
|
||||||
|
The renderer-versioned property registry defines every property's value grammar, shorthand expansion, inheritance,
|
||||||
|
allowed primitive kinds, relative-unit behavior, and hard technical bounds. Validation runs again after variable and
|
||||||
|
shorthand expansion, so banned asset functions cannot be hidden inside either construct.
|
||||||
|
|
||||||
|
PDF generation additionally enforces maximum authored page dimensions, maximum output pages, render timeout, and memory
|
||||||
|
budgets. Candidate promotion performs this bounded render preflight before replacing `applied`. A preflight failure
|
||||||
|
saves the editable source, preserves the previous applied value, and returns a controlled diagnostic. Later renderer
|
||||||
|
failures caused by subsequent content changes return a controlled preview/export error but do not silently mutate
|
||||||
|
stylesheet history.
|
||||||
|
|
||||||
|
## Documentation Registry
|
||||||
|
|
||||||
|
Semantic element names, attributes, template-part names, properties, values, inheritance behavior, and supported node
|
||||||
|
types come from typed registries. The editor completion data, user documentation, compiler validation, and template
|
||||||
|
coverage tests are generated from these registries.
|
||||||
|
|
||||||
|
This makes undocumented template internals unreachable and prevents documentation from drifting away from runtime
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Compiler
|
||||||
|
|
||||||
|
- Golden lexer and parser fixtures for valid and invalid source.
|
||||||
|
- Selector matching, specificity, source order, `!important`, inheritance, variables, resets, shorthands, units, and
|
||||||
|
media queries.
|
||||||
|
- Structural directive resolution.
|
||||||
|
- Exact source-range diagnostics.
|
||||||
|
- Property-registry exhaustiveness against supported PDF adapter types.
|
||||||
|
- Fuzz and resource-limit tests proving malformed text cannot crash or hang compilation.
|
||||||
|
|
||||||
|
### Schema and persistence
|
||||||
|
|
||||||
|
- Revision compare-and-swap rejects stale concurrent saves.
|
||||||
|
- Preflight occurs outside database locks, followed by a short CAS on both stylesheet revision and resume render-data
|
||||||
|
version.
|
||||||
|
- Serialized mutations consume stale acknowledgements for revision advancement without replacing newer editor state.
|
||||||
|
- Out-of-order worker results cannot replace newer editor state.
|
||||||
|
- Valid source edits replace both stylesheet values.
|
||||||
|
- Invalid source edits are stored while the current applied value is preserved.
|
||||||
|
- Compile-valid but render-failing source is stored without replacing the current applied value.
|
||||||
|
- Editor undo/redo restores historical invalid source with its historical valid applied value.
|
||||||
|
- Generic full-resume updates preserve the server-owned stylesheet.
|
||||||
|
- Clients cannot forge `applied` through normal edit transitions.
|
||||||
|
- Imports with invalid source retain text and independently validate the imported applied value.
|
||||||
|
- Duplicate and version restore preserve valid source/applied pairs.
|
||||||
|
- Public DTOs redact source, comments, diagnostics, and source locations.
|
||||||
|
- Public projections reject registry, tree, adapter, or render-data-hash mismatches and use the defined fallback.
|
||||||
|
- Public render hashes use the canonical, domain-separated contract, and fallback rendering preserves public/password
|
||||||
|
authorization and rate limiting.
|
||||||
|
- Backend-first rolling deployment preserves stylesheet fields when old clients submit full resume data.
|
||||||
|
- Undo, redo, JSON import, JSON export, duplication, and version restore preserve stylesheet state.
|
||||||
|
- Legacy conversion preserves effective output across precedence quirks, clamps, template exceptions, and supported
|
||||||
|
intent properties.
|
||||||
|
|
||||||
|
### PDF rendering
|
||||||
|
|
||||||
|
- Shared semantic primitives receive correct ancestry and attributes.
|
||||||
|
- Header, picture, contacts, pages, regions, sections, items, fields, rich text, and template parts resolve styles.
|
||||||
|
- Structural hiding and ordering occur before rendering.
|
||||||
|
- Positional selectors and inheritance remain based on the immutable source tree after hiding and ordering.
|
||||||
|
- Authored-page selectors, wrapped physical subpages, fixed nodes, page size, and media queries follow the defined phase
|
||||||
|
model.
|
||||||
|
- Browser and server adapters resolve identical programs.
|
||||||
|
- Every template smoke-renders with a comprehensive stylesheet.
|
||||||
|
- Every registered node and template part has resolved-style coverage.
|
||||||
|
- All 15 templates have visual regression coverage; focused fixtures cover every unique template feature.
|
||||||
|
- Preview and exported PDF use the same applied stylesheet value.
|
||||||
|
|
||||||
|
### Web editor
|
||||||
|
|
||||||
|
- Diagnostics, completions, formatting, search, copy and paste, color previews, autosave, and expanded mode.
|
||||||
|
- Invalid edits preserve source and last-valid preview.
|
||||||
|
- Correcting invalid text applies it without losing formatting.
|
||||||
|
- Out-of-order compilation and save responses are discarded.
|
||||||
|
- Stale save acknowledgements still advance the mutation revision before the queued edit is sent.
|
||||||
|
- Revision conflicts rebase the editor without dropping unsaved source.
|
||||||
|
- Known-but-absent selectors produce warnings.
|
||||||
|
- Legacy conversion is deterministic and user-visible.
|
||||||
|
|
||||||
|
### End-to-end acceptance
|
||||||
|
|
||||||
|
One portable stylesheet is pasted into resumes using different templates. The test verifies group selectors, one
|
||||||
|
section-specific rule, one item-specific rule, a header rule, a rich-text rule, a template-part rule, a media query, and
|
||||||
|
a pagination directive. It then introduces an error, confirms that preview and export remain on the last valid version,
|
||||||
|
corrects the error, and confirms that preview and export update together.
|
||||||
|
|
||||||
|
## Rollout
|
||||||
|
|
||||||
|
1. Deploy the dormant compiler and registries, tolerant schema handling, public projection/redaction,
|
||||||
|
generic-update field preservation, and the dedicated revisioned stylesheet mutation to the entire backend fleet.
|
||||||
|
No client can activate Semantic CSS during this stage.
|
||||||
|
2. Introduce the legacy converter behind a disabled authoring feature flag.
|
||||||
|
3. Instrument shared PDF primitives and structural child preparation.
|
||||||
|
4. Instrument header and template-specific parts across all 15 templates.
|
||||||
|
5. Add the editor and revision/conflict behavior.
|
||||||
|
6. Run legacy and Semantic CSS rendering paths side by side in tests, without double-applying them.
|
||||||
|
7. Enable Semantic CSS for opted-in resumes while retaining legacy rollback data and monitoring compile failures,
|
||||||
|
revision conflicts, render latency, memory, output pages, and fallback usage.
|
||||||
|
8. Enable it by default after mixed-client compatibility, public-redaction, template coverage, visual regression,
|
||||||
|
resource-limit, and end-to-end gates pass.
|
||||||
|
|
||||||
|
The authoring flag controls editor availability and whether a rollout cohort creates new resumes in semantic mode.
|
||||||
|
Before default enablement, resumes outside that cohort start in legacy mode; after default enablement they start in
|
||||||
|
semantic mode with empty version-1 source values. Rendering always honors a persisted semantic mode even if authoring is
|
||||||
|
later disabled. A stylesheet is never applied on top of legacy rules; an active stylesheet takes sole precedence for
|
||||||
|
custom PDF styling.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- Users can copy one text block between resumes and reproduce portable PDF styling.
|
||||||
|
- Every documented semantic node and template part can be targeted consistently.
|
||||||
|
- One section or item can be targeted by stable ID without making portable selectors resume-specific.
|
||||||
|
- Invalid text is never lost and never breaks preview or export.
|
||||||
|
- Preview, public rendering, browser export, and server export agree.
|
||||||
|
- Existing custom styles retain visual parity after deterministic conversion.
|
||||||
|
- The system accepts no executable code, font choice, asset reference, or network-fetching construct.
|
||||||
|
- All 15 templates pass semantic coverage and PDF smoke tests.
|
||||||
+298
@@ -0,0 +1,298 @@
|
|||||||
|
# Semantic CSS Author Reference and Unified Documentation Generation
|
||||||
|
|
||||||
|
**Date:** 2026-07-29
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Reactive Resume will provide one canonical, author-facing Semantic CSS reference at:
|
||||||
|
|
||||||
|
`https://docs.rxresu.me/guides/semantic-css-reference`
|
||||||
|
|
||||||
|
The existing `docs/guides/semantic-css-reference.mdx` page will be expanded rather than duplicated. It will combine
|
||||||
|
hand-written explanations and copy-paste examples with generated tables sourced from the runtime registries and PDF
|
||||||
|
template manifests.
|
||||||
|
|
||||||
|
The Custom Styles editor will include a compact, accessible help hint linking directly to that page.
|
||||||
|
|
||||||
|
A new root command, `pnpm docs:gen`, will replace `pnpm docs:semantic-css` and regenerate:
|
||||||
|
|
||||||
|
1. Semantic CSS reference tables.
|
||||||
|
2. The resume-builder skill schema reference.
|
||||||
|
3. The complete JSON Schema embedded in the public schema guide.
|
||||||
|
4. The checked-in OpenAPI specification.
|
||||||
|
|
||||||
|
## Audience and goals
|
||||||
|
|
||||||
|
The reference is for resume authors who write Semantic CSS in the builder. It must let an author:
|
||||||
|
|
||||||
|
- Discover what selectors, properties, values, and directives exist.
|
||||||
|
- Understand which semantic nodes and template parts can be targeted.
|
||||||
|
- Copy working examples for common customizations.
|
||||||
|
- Diagnose invalid or ineffective styles.
|
||||||
|
- Understand portability, last-valid behavior, resource limits, and unsupported syntax.
|
||||||
|
|
||||||
|
The page is a language reference, not contributor documentation. Compiler architecture, AST implementation details,
|
||||||
|
internal adapter names, and package ownership stay out of the public page.
|
||||||
|
|
||||||
|
## Canonical page structure
|
||||||
|
|
||||||
|
The reference is organized for lookup rather than linear reading.
|
||||||
|
|
||||||
|
### 1. Semantic CSS in one minute
|
||||||
|
|
||||||
|
- The `@version 1;` directive.
|
||||||
|
- One complete, portable stylesheet.
|
||||||
|
- The relationship between editable source, applied source, preview, and export.
|
||||||
|
|
||||||
|
### 2. Selector grammar
|
||||||
|
|
||||||
|
- Universal, semantic type, ID, and attribute selectors.
|
||||||
|
- Supported attribute operators.
|
||||||
|
- Descendant, child, adjacent-sibling, and general-sibling combinators.
|
||||||
|
- Selector lists.
|
||||||
|
- Supported functional and structural pseudo-classes.
|
||||||
|
- Case-sensitivity behavior.
|
||||||
|
- Explicitly unsupported selector syntax.
|
||||||
|
- Paired valid and invalid examples.
|
||||||
|
|
||||||
|
### 3. Semantic element catalog
|
||||||
|
|
||||||
|
- Generated parent and child relationships.
|
||||||
|
- Generated attributes and roles.
|
||||||
|
- Known attribute value domains.
|
||||||
|
- Portable section-type selectors versus resume-specific IDs.
|
||||||
|
- Rich-text structure, including distinct list-item row and list-item content semantics.
|
||||||
|
|
||||||
|
### 4. Cascade and values
|
||||||
|
|
||||||
|
- Specificity, source order, selector-list specificity, inheritance, and `!important`.
|
||||||
|
- Semantic CSS behavior for `initial`, `inherit`, `unset`, and `revert`.
|
||||||
|
- Author custom properties, nested `var()` fallbacks, unresolved variables, and cycles.
|
||||||
|
- Reserved read-only `--resume-*` system variables.
|
||||||
|
- Numbers, lengths, units, colors, functions, and shorthands.
|
||||||
|
|
||||||
|
### 5. Property reference
|
||||||
|
|
||||||
|
- Generated property table grouped by category.
|
||||||
|
- Applicability by semantic node.
|
||||||
|
- Inheritance.
|
||||||
|
- Accepted units and constrained keywords where authoritative metadata exists.
|
||||||
|
- Examples for text, spacing, borders, flex layout, images, transforms, and structural properties.
|
||||||
|
|
||||||
|
The generated table must not present a loose registry hint as an exhaustive value grammar. Value syntax that is
|
||||||
|
implemented by parser or cascade logic remains hand-written unless it has authoritative shared metadata.
|
||||||
|
|
||||||
|
### 6. PDF behavior
|
||||||
|
|
||||||
|
- Page sizing.
|
||||||
|
- Hiding and stable sibling ordering.
|
||||||
|
- Pagination, fixed content, minimum presence ahead, orphans, and widows.
|
||||||
|
- Media-query grammar, evaluation order, and page-dimension behavior.
|
||||||
|
- React PDF-specific layout limitations that affect authors.
|
||||||
|
|
||||||
|
### 7. Template-specific selectors
|
||||||
|
|
||||||
|
- A generated matrix for all 15 templates.
|
||||||
|
- Exact template-part names.
|
||||||
|
- Selector forms.
|
||||||
|
- Owner or placement conditions.
|
||||||
|
- Allowed semantic children.
|
||||||
|
- Portability warnings and guarded selector examples.
|
||||||
|
|
||||||
|
The matrix is generated from actual template manifests, not an independently maintained list.
|
||||||
|
|
||||||
|
### 8. Diagnostics and limits
|
||||||
|
|
||||||
|
- Stable compiler and preflight diagnostic codes.
|
||||||
|
- Severity.
|
||||||
|
- Meaning and likely corrective action.
|
||||||
|
- Source, selector, declaration, node, page, size, timeout, and memory limits.
|
||||||
|
- Last-valid preview and export behavior after an invalid edit.
|
||||||
|
|
||||||
|
### 9. Copy-paste recipes
|
||||||
|
|
||||||
|
- Restyle section headings.
|
||||||
|
- Target a section type.
|
||||||
|
- Target one section, item, or field.
|
||||||
|
- Style sidebar content by placement.
|
||||||
|
- Customize rich-text lists.
|
||||||
|
- Change authored page dimensions.
|
||||||
|
- Prevent awkward page breaks.
|
||||||
|
- Customize optional template decoration.
|
||||||
|
- Apply dimension-dependent PDF styles with `@media`.
|
||||||
|
|
||||||
|
### 10. Unsupported capabilities and portability checklist
|
||||||
|
|
||||||
|
- Unsupported selector, at-rule, layout, asset, font, script, interaction, and network capabilities.
|
||||||
|
- Guidance for keeping a stylesheet portable across templates.
|
||||||
|
|
||||||
|
## Generated documentation architecture
|
||||||
|
|
||||||
|
### Command
|
||||||
|
|
||||||
|
The root package exposes:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm docs:gen
|
||||||
|
```
|
||||||
|
|
||||||
|
The existing `docs:semantic-css` command is replaced by `docs:gen`, leaving one canonical documentation-generation
|
||||||
|
entrypoint.
|
||||||
|
|
||||||
|
### Semantic CSS reference data
|
||||||
|
|
||||||
|
Generated Semantic CSS sections consume existing authoritative sources:
|
||||||
|
|
||||||
|
- Supported versions and compile limits.
|
||||||
|
- Semantic element registry.
|
||||||
|
- Property registry.
|
||||||
|
- Read-only system-variable registry.
|
||||||
|
- PDF template manifests.
|
||||||
|
- Shared compiler and preflight diagnostic catalogs.
|
||||||
|
|
||||||
|
The generator emits deterministic, marker-delimited sections into
|
||||||
|
`docs/guides/semantic-css-reference.mdx`.
|
||||||
|
|
||||||
|
Generated factual sections include:
|
||||||
|
|
||||||
|
- Semantic elements, parents, attributes, roles, and known value domains.
|
||||||
|
- Property category, applicability, inheritance, units, and constrained keywords.
|
||||||
|
- System variables.
|
||||||
|
- Per-template template parts.
|
||||||
|
- Diagnostics.
|
||||||
|
- Compile and preflight limits.
|
||||||
|
|
||||||
|
Manual prose remains outside generated markers.
|
||||||
|
|
||||||
|
### Resume JSON Schema
|
||||||
|
|
||||||
|
The generator computes the canonical Resume JSON Schema once from `resumeDataSchema` using Zod's JSON Schema
|
||||||
|
conversion.
|
||||||
|
|
||||||
|
That canonical schema drives two outputs:
|
||||||
|
|
||||||
|
1. `skills/resume-builder/references/schema.md`
|
||||||
|
- A compact, AI-friendly Markdown reference.
|
||||||
|
- Field hierarchy, types, required fields, constraints, and representative shapes.
|
||||||
|
- Derived from the canonical JSON Schema rather than maintained separately.
|
||||||
|
|
||||||
|
2. `docs/guides/json-resume-schema.mdx`
|
||||||
|
- The complete canonical JSON Schema inside a generated, marker-delimited JSON block.
|
||||||
|
- Human-written explanation remains outside the generated block.
|
||||||
|
|
||||||
|
### OpenAPI specification
|
||||||
|
|
||||||
|
OpenAPI generation is exposed through one reusable, pure generator owned by `apps/server/src/openapi`.
|
||||||
|
|
||||||
|
- The runtime `/api/openapi/spec.json` handler calls it with `env.APP_URL`.
|
||||||
|
- A sibling server documentation-generation script calls it with `https://rxresu.me` and writes `docs/spec.json`.
|
||||||
|
- The root `docs:gen` command orchestrates the tooling generator and this server-owned OpenAPI generator.
|
||||||
|
- The checked-in output is `docs/spec.json`.
|
||||||
|
- The API version comes from the current application version.
|
||||||
|
|
||||||
|
This removes drift between runtime OpenAPI output and the checked-in documentation artifact, including stale versions
|
||||||
|
and localhost server URLs.
|
||||||
|
|
||||||
|
### Determinism and failure behavior
|
||||||
|
|
||||||
|
Generation must:
|
||||||
|
|
||||||
|
- Produce stable ordering and formatting.
|
||||||
|
- Require every expected marker.
|
||||||
|
- Fail on duplicate or missing markers.
|
||||||
|
- Fail on inconsistent template-manifest coverage.
|
||||||
|
- Avoid silently leaving a partially updated reference that appears authoritative.
|
||||||
|
|
||||||
|
The generator computes all output text before writing any target. It does not add a general transaction framework.
|
||||||
|
|
||||||
|
## Custom Styles help hint
|
||||||
|
|
||||||
|
The Semantic CSS editor's shared chrome displays this hint directly above the code editor:
|
||||||
|
|
||||||
|
> **Not sure what to write?** Browse the Semantic CSS language reference.
|
||||||
|
|
||||||
|
The link:
|
||||||
|
|
||||||
|
- Targets `https://docs.rxresu.me/guides/semantic-css-reference`.
|
||||||
|
- Opens in a new tab.
|
||||||
|
- Uses `rel="noopener noreferrer"`.
|
||||||
|
- Uses the existing `BookOpenIcon`, marked as decorative.
|
||||||
|
- Has translated visible text.
|
||||||
|
- Includes translated screen-reader text indicating that it opens in a new tab.
|
||||||
|
- Appears in both the standard desktop editor and the mobile focus sheet because both use the same editor chrome.
|
||||||
|
|
||||||
|
The implementation stays local to the stylesheet editor. It does not introduce a shared component or central URL
|
||||||
|
registry for one link.
|
||||||
|
|
||||||
|
## Documentation navigation
|
||||||
|
|
||||||
|
`docs/docs.json` lists `guides/semantic-css-reference` immediately after `guides/using-custom-styles`.
|
||||||
|
|
||||||
|
The public route is:
|
||||||
|
|
||||||
|
`https://docs.rxresu.me/guides/semantic-css-reference`
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
### Generator verification
|
||||||
|
|
||||||
|
- `pnpm docs:gen` regenerates all four artifact groups.
|
||||||
|
- A non-mutating test generates into temporary files and compares them byte-for-byte with committed outputs.
|
||||||
|
- Generated output is deterministic across repeated runs.
|
||||||
|
- Every runtime template part appears in the generated template matrix.
|
||||||
|
- Cross-registry checks reject inconsistent template-part parent or child coverage.
|
||||||
|
- The generated OpenAPI document matches the shared runtime generator for the documentation URL and current version.
|
||||||
|
- Both schema Markdown targets are derived from the same canonical Resume JSON Schema.
|
||||||
|
|
||||||
|
### Example verification
|
||||||
|
|
||||||
|
- Complete copy-paste examples marked as valid compile successfully.
|
||||||
|
- Selected intentionally invalid examples produce their documented diagnostic.
|
||||||
|
- Small illustrative fragments that are not complete stylesheets are not forced through a full compiler test.
|
||||||
|
|
||||||
|
### UI verification
|
||||||
|
|
||||||
|
The stylesheet editor test verifies:
|
||||||
|
|
||||||
|
- Accessible link name.
|
||||||
|
- Exact public URL.
|
||||||
|
- New-tab target.
|
||||||
|
- `noopener noreferrer`.
|
||||||
|
- Presence in the standard editor.
|
||||||
|
- Presence in the mobile focus sheet.
|
||||||
|
|
||||||
|
### Focused gates
|
||||||
|
|
||||||
|
- Tooling tests and typecheck.
|
||||||
|
- Resume/schema tests and typechecks affected by exported metadata.
|
||||||
|
- PDF manifest/reference consistency tests and typecheck.
|
||||||
|
- API/server OpenAPI tests and typechecks.
|
||||||
|
- Web editor tests and typecheck.
|
||||||
|
- Workspace boundary check.
|
||||||
|
- Focused formatting and Markdown validation.
|
||||||
|
|
||||||
|
Chrome verification is not required.
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Contributor/compiler architecture documentation.
|
||||||
|
- A second Semantic CSS reference route.
|
||||||
|
- Splitting the reference across multiple pages.
|
||||||
|
- Interactive documentation playgrounds.
|
||||||
|
- New editor completion or hover features.
|
||||||
|
- New Semantic CSS syntax or rendering behavior, except for correcting factual registry inconsistencies required to generate an
|
||||||
|
accurate reference.
|
||||||
|
- General documentation URL centralization.
|
||||||
|
|
||||||
|
## Acceptance criteria
|
||||||
|
|
||||||
|
- The canonical reference documents every author-facing Semantic CSS selector, semantic element, property, variable, directive,
|
||||||
|
value family, template part, diagnostic family, limit, and unsupported syntax category.
|
||||||
|
- The reference contains copy-paste examples for common author goals.
|
||||||
|
- Generated facts come from authoritative runtime metadata and have staleness coverage.
|
||||||
|
- `pnpm docs:gen` refreshes the Semantic CSS tables, both Resume JSON Schema references, and `docs/spec.json`.
|
||||||
|
- Runtime and checked-in OpenAPI output share one generator.
|
||||||
|
- The reference is visible in documentation navigation.
|
||||||
|
- The Custom Styles editor links to the exact public reference route on desktop and mobile.
|
||||||
|
- No unrelated product behavior or documentation architecture is introduced.
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
# Semantic CSS Complete Rename Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Use **Semantic CSS** as the feature's only name. Remove the former acronym and its prefixes before the feature is merged
|
||||||
|
so authors, contributors, diagnostics, and documentation all use one vocabulary.
|
||||||
|
|
||||||
|
## Naming Contract
|
||||||
|
|
||||||
|
The rename applies to every tracked source, test, fixture, generated marker, guide, plan, and specification in this
|
||||||
|
branch. Git history is not rewritten.
|
||||||
|
|
||||||
|
| Context | Canonical form |
|
||||||
|
| --- | --- |
|
||||||
|
| Product and language name | Semantic CSS |
|
||||||
|
| TypeScript symbol form | `SemanticCss*` |
|
||||||
|
| Constant prefix | `SEMANTIC_CSS_*` |
|
||||||
|
| Slug and cache form | `semantic-css-*` |
|
||||||
|
| Version directive | `@version 1;` |
|
||||||
|
| System variables | `--resume-*` |
|
||||||
|
| Renderer properties | `-resume-*` |
|
||||||
|
| Empty source constant | `EMPTY_SEMANTIC_CSS_SOURCE` |
|
||||||
|
| Documentation markers | `SEMANTIC-CSS-*` |
|
||||||
|
|
||||||
|
Existing neutral names remain unchanged, including `stylesheet`, `mode: "semantic"`, `languageVersion`, semantic node
|
||||||
|
names, API routes, database columns, and the `/applying-custom-styles` documentation URL.
|
||||||
|
|
||||||
|
## Language Syntax
|
||||||
|
|
||||||
|
New stylesheets and formatted output start with:
|
||||||
|
|
||||||
|
```css
|
||||||
|
@version 1;
|
||||||
|
```
|
||||||
|
|
||||||
|
Resolved builder values use the `--resume-*` namespace:
|
||||||
|
|
||||||
|
```css
|
||||||
|
:root {
|
||||||
|
--accent: var(--resume-primary-color);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Renderer-specific properties use the `-resume-*` namespace:
|
||||||
|
|
||||||
|
```css
|
||||||
|
section {
|
||||||
|
-resume-min-presence-ahead: 24pt;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The compiler accepts only the new syntax. There are no deprecated aliases, conversion paths, or compatibility warnings
|
||||||
|
because the feature has not shipped.
|
||||||
|
|
||||||
|
## Product and Documentation
|
||||||
|
|
||||||
|
All visible editor labels, help text, errors, diagnostics, logs intended for operators, tests that assert visible copy,
|
||||||
|
and the Applying Custom Styles guide say **Semantic CSS**. The guide and examples teach only `@version`,
|
||||||
|
`--resume-*`, and `-resume-*`.
|
||||||
|
|
||||||
|
The guide remains manually authored. `pnpm docs:gen` continues to regenerate only the Resume schema references and
|
||||||
|
OpenAPI specification; example compilation tests continue to validate the guide's marked Semantic CSS examples.
|
||||||
|
|
||||||
|
## Internal Code
|
||||||
|
|
||||||
|
Public package exports and internal identifiers use `SemanticCss` or `SEMANTIC_CSS` when the language name is required.
|
||||||
|
Identifiers already scoped by a stylesheet module may retain a neutral `Stylesheet*` name instead of repeating
|
||||||
|
`SemanticCss`.
|
||||||
|
|
||||||
|
The compiler build/cache identifier changes so cached output produced under the old grammar cannot be reused. No data
|
||||||
|
migration is added.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
The implementation is complete when:
|
||||||
|
|
||||||
|
1. A case-insensitive tracked-file search finds no occurrence of the former four-letter acronym.
|
||||||
|
2. A tracked-file search finds none of the former directive, variable, or renderer-property prefixes.
|
||||||
|
3. Compiler tests prove `@version 1;` is required and the old directive is rejected as unsupported.
|
||||||
|
4. Registry and rendering tests cover the renamed system variables and renderer properties.
|
||||||
|
5. The public guide's marked examples compile.
|
||||||
|
6. Focused package tests, typechecks, documentation generation, Knip, Biome, and the existing E2E workflow pass.
|
||||||
|
7. No local Chrome run is required; browser verification remains CI-owned.
|
||||||
@@ -7,6 +7,7 @@
|
|||||||
},
|
},
|
||||||
"apps/web": {
|
"apps/web": {
|
||||||
"entry": ["lingui.config.ts", "vite.config.ts", "vitest.config.ts"],
|
"entry": ["lingui.config.ts", "vite.config.ts", "vitest.config.ts"],
|
||||||
|
"ignoreDependencies": ["buffer"],
|
||||||
"vite": {
|
"vite": {
|
||||||
"config": []
|
"config": []
|
||||||
},
|
},
|
||||||
@@ -34,13 +35,16 @@
|
|||||||
"@better-auth/drizzle-adapter",
|
"@better-auth/drizzle-adapter",
|
||||||
"@better-auth/infra",
|
"@better-auth/infra",
|
||||||
"@better-auth/passkey",
|
"@better-auth/passkey",
|
||||||
|
"@bramus/specificity",
|
||||||
"@orpc/experimental-ratelimit",
|
"@orpc/experimental-ratelimit",
|
||||||
"@sindresorhus/slugify",
|
"@sindresorhus/slugify",
|
||||||
"@t3-oss/env-core",
|
"@t3-oss/env-core",
|
||||||
"@uiw/color-convert",
|
"@uiw/color-convert",
|
||||||
"ai",
|
"ai",
|
||||||
"bcrypt",
|
"bcrypt",
|
||||||
|
"canonicalize",
|
||||||
"cjk-regex",
|
"cjk-regex",
|
||||||
|
"css-tree",
|
||||||
"deepmerge-ts",
|
"deepmerge-ts",
|
||||||
"drizzle-zod",
|
"drizzle-zod",
|
||||||
"fast-json-patch",
|
"fast-json-patch",
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
ALTER TABLE "resume" ADD COLUMN "stylesheet_revision" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
|
||||||
|
ALTER TABLE "resume" ADD COLUMN "render_data_version" integer DEFAULT 0 NOT NULL;
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -23,6 +23,7 @@
|
|||||||
"build": "turbo run build",
|
"build": "turbo run build",
|
||||||
"doctor": "turbo run doctor",
|
"doctor": "turbo run doctor",
|
||||||
"check": "biome check --write --unsafe . && markdownlint-cli2 --fix && github-actionlint -shellcheck= -pyflakes=",
|
"check": "biome check --write --unsafe . && markdownlint-cli2 --fix && github-actionlint -shellcheck= -pyflakes=",
|
||||||
|
"docs:gen": "pnpm --filter server docs:gen && pnpm --filter @reactive-resume/tooling docs:gen",
|
||||||
"db:generate": "turbo run db:generate --filter=@reactive-resume/db",
|
"db:generate": "turbo run db:generate --filter=@reactive-resume/db",
|
||||||
"db:migrate": "turbo run db:migrate --filter=@reactive-resume/db",
|
"db:migrate": "turbo run db:migrate --filter=@reactive-resume/db",
|
||||||
"db:studio": "turbo run db:studio --filter=@reactive-resume/db",
|
"db:studio": "turbo run db:studio --filter=@reactive-resume/db",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"./context": "./src/context.ts",
|
"./context": "./src/context.ts",
|
||||||
"./features/flags": "./src/features/flags/index.ts",
|
"./features/flags": "./src/features/flags/index.ts",
|
||||||
"./features/resume/export": "./src/features/resume/export.ts",
|
"./features/resume/export": "./src/features/resume/export.ts",
|
||||||
|
"./features/resume/public-pdf": "./src/features/resume/public-pdf.ts",
|
||||||
"./features/storage": "./src/features/storage/index.ts",
|
"./features/storage": "./src/features/storage/index.ts",
|
||||||
"./routers": "./src/routers/index.ts"
|
"./routers": "./src/routers/index.ts"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { StylesheetPreflightRunner } from "@reactive-resume/pdf/server";
|
||||||
import type { Locale } from "@reactive-resume/utils/locale";
|
import type { Locale } from "@reactive-resume/utils/locale";
|
||||||
import type { User } from "better-auth";
|
import type { User } from "better-auth";
|
||||||
import { ORPCError, os } from "@orpc/server";
|
import { ORPCError, os } from "@orpc/server";
|
||||||
@@ -10,6 +11,8 @@ interface ORPCContext {
|
|||||||
locale: Locale;
|
locale: Locale;
|
||||||
reqHeaders: Headers;
|
reqHeaders: Headers;
|
||||||
resHeaders?: Headers;
|
resHeaders?: Headers;
|
||||||
|
trustedClient?: string;
|
||||||
|
stylesheetPreflightRunner?: StylesheetPreflightRunner;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
|
||||||
|
|||||||
@@ -4,6 +4,110 @@ import { redactResumeForViewer } from "../features/resume/access-policy";
|
|||||||
import { resumeDto } from "./resume";
|
import { resumeDto } from "./resume";
|
||||||
|
|
||||||
describe("resume DTO output validation", () => {
|
describe("resume DTO output validation", () => {
|
||||||
|
it("normalizes ordinary PUT data without losing compatible custom-section overlap", () => {
|
||||||
|
const parsed = resumeDto.update.input.parse({
|
||||||
|
id: "resume-id",
|
||||||
|
data: {
|
||||||
|
...structuredClone(defaultResumeData),
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "experience-item",
|
||||||
|
hidden: false,
|
||||||
|
company: "Analytical Engines",
|
||||||
|
position: "Programmer",
|
||||||
|
location: "London",
|
||||||
|
period: "1842–1843",
|
||||||
|
description: "<p>Wrote the first algorithm.</p>",
|
||||||
|
content: "<p>Compatible overlap</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.data?.customSections[0]?.items[0]).toMatchObject({
|
||||||
|
content: "<p>Compatible overlap</p>",
|
||||||
|
roles: [],
|
||||||
|
website: { url: "", label: "", inlineLink: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects renderer-unsafe custom sections before update or import persistence", () => {
|
||||||
|
const data = {
|
||||||
|
...defaultResumeData,
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(resumeDto.update.input.safeParse({ id: "resume-id", data }).success).toBe(false);
|
||||||
|
expect(resumeDto.import.input.safeParse({ data }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("defers imported stylesheet validation to the stable unavailable-feature error", () => {
|
||||||
|
expect(
|
||||||
|
resumeDto.import.input.safeParse({
|
||||||
|
data: {
|
||||||
|
...defaultResumeData,
|
||||||
|
metadata: {
|
||||||
|
...defaultResumeData.metadata,
|
||||||
|
stylesheet: { invalid: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}).success,
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not let otherwise-invalid imports bypass validation without a stylesheet field", () => {
|
||||||
|
expect(resumeDto.import.input.safeParse({ data: { metadata: {} } }).success).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[resumeDto.getById.output, {}],
|
||||||
|
[resumeDto.getBySlug.output, { stylesheetMode: "legacy" }],
|
||||||
|
[resumeDto.update.output, {}],
|
||||||
|
[resumeDto.patch.output, {}],
|
||||||
|
] as const)("keeps server concurrency columns out of ordinary resume outputs", (output, extra) => {
|
||||||
|
const parsed = output.parse({
|
||||||
|
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||||
|
name: "Resume",
|
||||||
|
slug: "resume",
|
||||||
|
tags: [],
|
||||||
|
data: defaultResumeData,
|
||||||
|
isPublic: false,
|
||||||
|
isLocked: false,
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
hasPassword: false,
|
||||||
|
stylesheetRevision: 7,
|
||||||
|
renderDataVersion: 9,
|
||||||
|
...extra,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed).not.toHaveProperty("stylesheetRevision");
|
||||||
|
expect(parsed).not.toHaveProperty("renderDataVersion");
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts public resume responses after owner-only fields are redacted", () => {
|
it("accepts public resume responses after owner-only fields are redacted", () => {
|
||||||
const dbResume = {
|
const dbResume = {
|
||||||
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||||
@@ -26,10 +130,71 @@ describe("resume DTO output validation", () => {
|
|||||||
const publicResume = {
|
const publicResume = {
|
||||||
...redactResumeForViewer(dbResume, false),
|
...redactResumeForViewer(dbResume, false),
|
||||||
hasPassword: dbResume.hasPassword,
|
hasPassword: dbResume.hasPassword,
|
||||||
|
stylesheetMode: "legacy",
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(publicResume.name).toBe("Resume");
|
expect(publicResume.name).toBe("Resume");
|
||||||
expect(publicResume.data.metadata.notes).toBe("");
|
expect(publicResume.data.metadata.notes).toBe("");
|
||||||
expect(resumeDto.getBySlug.output.safeParse(publicResume).success).toBe(true);
|
expect(resumeDto.getBySlug.output.safeParse(publicResume).success).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("exposes only the safe public stylesheet mode discriminator", () => {
|
||||||
|
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||||
|
const parsed = resumeDto.getBySlug.output.parse({
|
||||||
|
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||||
|
name: "Resume",
|
||||||
|
slug: "resume",
|
||||||
|
tags: [],
|
||||||
|
data: redactResumeForViewer(
|
||||||
|
{
|
||||||
|
name: "Owner title",
|
||||||
|
data: {
|
||||||
|
...defaultResumeData,
|
||||||
|
metadata: {
|
||||||
|
...defaultResumeData.metadata,
|
||||||
|
stylesheet: { mode: "semantic", source, applied: source },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
false,
|
||||||
|
).data,
|
||||||
|
isPublic: true,
|
||||||
|
isLocked: false,
|
||||||
|
hasPassword: false,
|
||||||
|
stylesheetMode: "semantic",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(parsed.stylesheetMode).toBe("semantic");
|
||||||
|
expect(JSON.stringify(parsed)).not.toContain("@version");
|
||||||
|
expect(JSON.stringify(parsed)).not.toContain("stylesheetRevision");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns current canonical stylesheet state only on version restore", () => {
|
||||||
|
const resume = {
|
||||||
|
id: "019e128d-0598-75d2-ae6a-771e2eb84614",
|
||||||
|
name: "Resume",
|
||||||
|
slug: "resume",
|
||||||
|
tags: [],
|
||||||
|
data: defaultResumeData,
|
||||||
|
isPublic: false,
|
||||||
|
isLocked: false,
|
||||||
|
updatedAt: new Date("2026-01-01T00:00:00Z"),
|
||||||
|
hasPassword: false,
|
||||||
|
};
|
||||||
|
const stylesheet = {
|
||||||
|
mode: "semantic" as const,
|
||||||
|
source: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
applied: { languageVersion: 1, text: "@version 1;\n" },
|
||||||
|
};
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resumeDto.restoreVersion.output.parse({
|
||||||
|
resume,
|
||||||
|
stylesheetState: { stylesheet, revision: 8, renderDataVersion: 13 },
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
resume,
|
||||||
|
stylesheetState: { stylesheet, revision: 8, renderDataVersion: 13 },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,8 +1,18 @@
|
|||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
import { createSelectSchema } from "drizzle-zod";
|
import { createSelectSchema } from "drizzle-zod";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import * as schema from "@reactive-resume/db/schema";
|
import * as schema from "@reactive-resume/db/schema";
|
||||||
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
|
import { jsonPatchOperationSchema } from "@reactive-resume/resume/patch";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { semanticStylesheetSchema, stylesheetSourceSchema } from "@reactive-resume/schema/resume/stylesheet";
|
||||||
|
|
||||||
|
const importedResumeDataSchema = z.custom<ResumeData>((value) => {
|
||||||
|
if (typeof value !== "object" || value === null) return false;
|
||||||
|
const data = value as Record<string, unknown>;
|
||||||
|
if (typeof data.metadata !== "object" || data.metadata === null) return false;
|
||||||
|
const { stylesheet: _stylesheet, ...metadata } = data.metadata as Record<string, unknown>;
|
||||||
|
return resumeDataSchema.safeParse({ ...data, metadata }).success;
|
||||||
|
});
|
||||||
|
|
||||||
const resumeSchema = createSelectSchema(schema.resume, {
|
const resumeSchema = createSelectSchema(schema.resume, {
|
||||||
id: z.string().describe("The ID of the resume."),
|
id: z.string().describe("The ID of the resume."),
|
||||||
@@ -16,6 +26,60 @@ const resumeSchema = createSelectSchema(schema.resume, {
|
|||||||
userId: z.string().describe("The ID of the user who owns the resume."),
|
userId: z.string().describe("The ID of the user who owns the resume."),
|
||||||
createdAt: z.date().describe("The date and time the resume was created."),
|
createdAt: z.date().describe("The date and time the resume was created."),
|
||||||
updatedAt: z.date().describe("The date and time the resume was last updated."),
|
updatedAt: z.date().describe("The date and time the resume was last updated."),
|
||||||
|
}).omit({ stylesheetRevision: true, renderDataVersion: true });
|
||||||
|
|
||||||
|
const stylesheetMutationCommon = {
|
||||||
|
id: z.string().describe("The ID of the resume."),
|
||||||
|
expectedRevision: z.number().int().nonnegative(),
|
||||||
|
expectedRenderDataVersion: z.number().int().nonnegative(),
|
||||||
|
editGeneration: z.number().int().nonnegative(),
|
||||||
|
};
|
||||||
|
const stylesheetDiagnosticSchema = z.strictObject({
|
||||||
|
code: z.string(),
|
||||||
|
severity: z.enum(["error", "warning"]),
|
||||||
|
message: z.string(),
|
||||||
|
range: z.strictObject({
|
||||||
|
start: z.strictObject({
|
||||||
|
line: z.number().int().positive(),
|
||||||
|
column: z.number().int().positive(),
|
||||||
|
offset: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
end: z.strictObject({
|
||||||
|
line: z.number().int().positive(),
|
||||||
|
column: z.number().int().positive(),
|
||||||
|
offset: z.number().int().nonnegative(),
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const stylesheetStateSchema = z.strictObject({
|
||||||
|
stylesheet: semanticStylesheetSchema,
|
||||||
|
revision: z.number().int().nonnegative(),
|
||||||
|
renderDataVersion: z.number().int().nonnegative(),
|
||||||
|
});
|
||||||
|
const publicPdfPageSizeSchema = z.union([
|
||||||
|
z.enum(["A4", "LETTER"]),
|
||||||
|
z.strictObject({ width: z.number().finite(), height: z.number().finite().optional() }),
|
||||||
|
]);
|
||||||
|
const publicPdfNodePresentationSchema = z.strictObject({
|
||||||
|
style: z.record(z.string(), z.union([z.string(), z.number().finite(), z.null()])).optional(),
|
||||||
|
size: publicPdfPageSizeSchema.optional(),
|
||||||
|
break: z.boolean().optional(),
|
||||||
|
wrap: z.boolean().optional(),
|
||||||
|
fixed: z.boolean().optional(),
|
||||||
|
minPresenceAhead: z.number().finite().optional(),
|
||||||
|
orphans: z.number().finite().optional(),
|
||||||
|
widows: z.number().finite().optional(),
|
||||||
|
hidden: z.boolean().optional(),
|
||||||
|
order: z.number().int().nonnegative().optional(),
|
||||||
|
});
|
||||||
|
const publicStyleProjectionSchema = z.strictObject({
|
||||||
|
formatVersion: z.literal(1),
|
||||||
|
languageVersion: z.number().int().positive(),
|
||||||
|
semanticTreeVersion: z.literal(1),
|
||||||
|
registryFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
adapterFingerprint: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
renderDataHash: z.string().regex(/^[a-f0-9]{64}$/),
|
||||||
|
nodes: z.record(z.string(), publicPdfNodePresentationSchema),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const resumeDto = {
|
export const resumeDto = {
|
||||||
@@ -43,7 +107,12 @@ export const resumeDto = {
|
|||||||
// the redacted public response passes output validation.
|
// the redacted public response passes output validation.
|
||||||
output: resumeSchema
|
output: resumeSchema
|
||||||
.omit({ name: true, password: true, userId: true, createdAt: true, updatedAt: true })
|
.omit({ name: true, password: true, userId: true, createdAt: true, updatedAt: true })
|
||||||
.extend({ name: z.string() }),
|
.extend({ name: z.string(), stylesheetMode: z.enum(["legacy", "semantic"]) }),
|
||||||
|
},
|
||||||
|
|
||||||
|
getStyleProjection: {
|
||||||
|
input: z.strictObject({ username: z.string(), slug: z.string() }),
|
||||||
|
output: publicStyleProjectionSchema,
|
||||||
},
|
},
|
||||||
|
|
||||||
create: {
|
create: {
|
||||||
@@ -54,7 +123,7 @@ export const resumeDto = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
import: {
|
import: {
|
||||||
input: resumeSchema.pick({ data: true }),
|
input: z.object({ data: importedResumeDataSchema }),
|
||||||
output: z.string().describe("The ID of the imported resume."),
|
output: z.string().describe("The ID of the imported resume."),
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -122,6 +191,58 @@ export const resumeDto = {
|
|||||||
resumeId: z.string().describe("The ID of the resume to restore."),
|
resumeId: z.string().describe("The ID of the resume to restore."),
|
||||||
versionId: z.string().describe("The ID of the version snapshot to restore."),
|
versionId: z.string().describe("The ID of the version snapshot to restore."),
|
||||||
}),
|
}),
|
||||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
output: z.strictObject({
|
||||||
|
resume: resumeSchema.omit({ password: true, userId: true, createdAt: true }).extend({ hasPassword: z.boolean() }),
|
||||||
|
stylesheetState: stylesheetStateSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
|
||||||
|
stylesheet: {
|
||||||
|
errors: {
|
||||||
|
validation: z.strictObject({
|
||||||
|
diagnostics: z.array(stylesheetDiagnosticSchema),
|
||||||
|
}),
|
||||||
|
parity: z.strictObject({
|
||||||
|
mismatches: z.array(z.string()),
|
||||||
|
}),
|
||||||
|
revisionConflict: z.strictObject({
|
||||||
|
state: stylesheetStateSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
getState: {
|
||||||
|
input: z.strictObject({ id: z.string().describe("The ID of the resume.") }),
|
||||||
|
output: stylesheetStateSchema,
|
||||||
|
},
|
||||||
|
mutate: {
|
||||||
|
input: z.discriminatedUnion("transition", [
|
||||||
|
z.strictObject({
|
||||||
|
...stylesheetMutationCommon,
|
||||||
|
transition: z.literal("edit_source"),
|
||||||
|
source: stylesheetSourceSchema,
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...stylesheetMutationCommon,
|
||||||
|
transition: z.literal("activate"),
|
||||||
|
source: stylesheetSourceSchema,
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...stylesheetMutationCommon,
|
||||||
|
transition: z.literal("deactivate"),
|
||||||
|
}),
|
||||||
|
z.strictObject({
|
||||||
|
...stylesheetMutationCommon,
|
||||||
|
transition: z.literal("restore_history"),
|
||||||
|
restore: z.strictObject({
|
||||||
|
mode: z.enum(["legacy", "semantic"]),
|
||||||
|
source: stylesheetSourceSchema,
|
||||||
|
applied: stylesheetSourceSchema,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
output: stylesheetStateSchema.extend({
|
||||||
|
editGeneration: z.number().int().nonnegative(),
|
||||||
|
diagnostics: z.array(stylesheetDiagnosticSchema),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ export type FeatureFlags = {
|
|||||||
disableEmailAuth: boolean;
|
disableEmailAuth: boolean;
|
||||||
showSponsors: boolean;
|
showSponsors: boolean;
|
||||||
smtpEnabled: boolean;
|
smtpEnabled: boolean;
|
||||||
|
semanticCssAuthoring: boolean;
|
||||||
|
semanticCssDefault: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency).
|
// Mirrors isSmtpEnabled() in packages/email/src/transport.ts (kept local to avoid an api -> email dependency).
|
||||||
@@ -30,6 +32,8 @@ export const flagsRouter = {
|
|||||||
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
|
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
|
||||||
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
|
showSponsors: z.boolean().describe("Whether sponsor placements are shown on this instance."),
|
||||||
smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."),
|
smtpEnabled: z.boolean().describe("Whether outbound email (SMTP) is configured on this instance."),
|
||||||
|
semanticCssAuthoring: z.boolean().describe("Whether Semantic CSS authoring is enabled on this instance."),
|
||||||
|
semanticCssDefault: z.boolean().describe("Whether new resumes start in Semantic CSS mode."),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.handler(
|
.handler(
|
||||||
@@ -38,6 +42,8 @@ export const flagsRouter = {
|
|||||||
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
|
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
|
||||||
showSponsors: env.FLAG_SHOW_SPONSORS,
|
showSponsors: env.FLAG_SHOW_SPONSORS,
|
||||||
smtpEnabled: isSmtpEnabled(),
|
smtpEnabled: isSmtpEnabled(),
|
||||||
|
semanticCssAuthoring: env.FLAG_SEMANTIC_CSS_AUTHORING,
|
||||||
|
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -71,6 +71,25 @@ describe("redactResumeForViewer", () => {
|
|||||||
expect(result.data.metadata.notes).toBe("");
|
expect(result.data.metadata.notes).toBe("");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("strips editable and applied stylesheet source for non-owner", () => {
|
||||||
|
const source = { languageVersion: 1, text: "@version 1;\nresume { color: red; }\n" };
|
||||||
|
const resume = {
|
||||||
|
name: "Title",
|
||||||
|
data: {
|
||||||
|
...defaultResumeData,
|
||||||
|
metadata: {
|
||||||
|
...defaultResumeData.metadata,
|
||||||
|
stylesheet: { mode: "semantic" as const, source, applied: source },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = redactResumeForViewer(resume, false);
|
||||||
|
|
||||||
|
expect(result.data.metadata.stylesheet).toBeUndefined();
|
||||||
|
expect(JSON.stringify(result)).not.toContain("@version");
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
|
it("preserves resume.data.basics.name (the person's name) for non-owner", () => {
|
||||||
const resume = {
|
const resume = {
|
||||||
name: "Dashboard title",
|
name: "Dashboard title",
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export function redactResumeForViewer<T extends { name: string; data: ResumeData
|
|||||||
viewerIsOwner: boolean,
|
viewerIsOwner: boolean,
|
||||||
): T {
|
): T {
|
||||||
if (viewerIsOwner) return resume;
|
if (viewerIsOwner) return resume;
|
||||||
|
const { stylesheet: _stylesheet, ...metadata } = resume.data.metadata;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...resume,
|
...resume,
|
||||||
@@ -60,7 +61,7 @@ export function redactResumeForViewer<T extends { name: string; data: ResumeData
|
|||||||
data: {
|
data: {
|
||||||
...resume.data,
|
...resume.data,
|
||||||
metadata: {
|
metadata: {
|
||||||
...resume.data.metadata,
|
...metadata,
|
||||||
notes: "",
|
notes: "",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRouterClient } from "@orpc/server";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
getById: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({ env: { FLAG_SEMANTIC_CSS_DEFAULT: false } }));
|
||||||
|
|
||||||
|
vi.mock("../../context", async () => {
|
||||||
|
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||||
|
return {
|
||||||
|
protectedProcedure: os.$context<{
|
||||||
|
locale: "en-US";
|
||||||
|
reqHeaders: Headers;
|
||||||
|
user: { id: string };
|
||||||
|
}>(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./service", () => ({
|
||||||
|
resumeService: {
|
||||||
|
create: mocks.create,
|
||||||
|
getById: mocks.getById,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { crudRouter } = await import("./crud");
|
||||||
|
|
||||||
|
const rendererUnsafeData = (): ResumeData =>
|
||||||
|
({
|
||||||
|
...structuredClone(defaultResumeData),
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as unknown as ResumeData;
|
||||||
|
|
||||||
|
describe("resume duplicate route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.create.mockResolvedValue("copy-id");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects invalid stored source data before calling the shared create service", async () => {
|
||||||
|
mocks.getById.mockResolvedValue({
|
||||||
|
id: "resume-id",
|
||||||
|
name: "Resume",
|
||||||
|
slug: "resume",
|
||||||
|
tags: [],
|
||||||
|
data: rendererUnsafeData(),
|
||||||
|
});
|
||||||
|
const client = createRouterClient(crudRouter, {
|
||||||
|
context: { locale: "en-US", reqHeaders: new Headers(), user: { id: "user-id" } } as never,
|
||||||
|
});
|
||||||
|
|
||||||
|
const error = await client
|
||||||
|
.duplicate({ id: "resume-id", name: "Copy", slug: "copy", tags: [] })
|
||||||
|
.catch((caught: unknown) => caught);
|
||||||
|
|
||||||
|
expect(error).toMatchObject({ code: "INTERNAL_SERVER_ERROR", status: 500 });
|
||||||
|
expect(error).toHaveProperty("cause.issues.0.path", ["customSections", 0, "items", 0, "company"]);
|
||||||
|
expect(mocks.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,13 @@
|
|||||||
import { createSampleResumeData } from "@reactive-resume/schema/resume/sample";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { generateId, generateRandomName, slugify } from "@reactive-resume/utils/string";
|
||||||
import { protectedProcedure } from "../../context";
|
import { protectedProcedure } from "../../context";
|
||||||
import { resumeDto } from "../../dto/resume";
|
import { resumeDto } from "../../dto/resume";
|
||||||
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
import { resumeMutationRateLimit } from "../../middleware/rate-limit";
|
||||||
|
import { parseStoredResumeData } from "./resume-data-validation";
|
||||||
import { resumeService } from "./service";
|
import { resumeService } from "./service";
|
||||||
|
import { prepareImportedResumeData } from "./stylesheet-preflight";
|
||||||
|
import { createResumeData } from "./stylesheet-preservation";
|
||||||
|
|
||||||
export const crudRouter = {
|
export const crudRouter = {
|
||||||
list: protectedProcedure
|
list: protectedProcedure
|
||||||
@@ -69,7 +73,12 @@ export const crudRouter = {
|
|||||||
tags: input.tags,
|
tags: input.tags,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
...(input.withSampleData ? { data: createSampleResumeData(input.name) } : {}),
|
data: createResumeData({
|
||||||
|
semanticCssDefault: env.FLAG_SEMANTIC_CSS_DEFAULT,
|
||||||
|
withSampleData: input.withSampleData,
|
||||||
|
name: input.name,
|
||||||
|
locale: context.locale,
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -92,16 +101,32 @@ export const crudRouter = {
|
|||||||
message: "A resume with this slug already exists.",
|
message: "A resume with this slug already exists.",
|
||||||
status: 400,
|
status: 400,
|
||||||
},
|
},
|
||||||
|
SEMANTIC_STYLESHEET_UNAVAILABLE: {
|
||||||
|
message: "Semantic stylesheet PDF preflight is unavailable.",
|
||||||
|
status: 503,
|
||||||
|
},
|
||||||
|
STYLESHEET_VALIDATION_FAILED: {
|
||||||
|
message: "The imported stylesheet failed validation.",
|
||||||
|
status: 400,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
|
const id = generateId();
|
||||||
|
const data = await prepareImportedResumeData({
|
||||||
|
data: resumeDataSchema.parse(input.data),
|
||||||
|
resumeId: id,
|
||||||
|
revision: 0,
|
||||||
|
...(context.stylesheetPreflightRunner ? { runner: context.stylesheetPreflightRunner } : {}),
|
||||||
|
});
|
||||||
const name = generateRandomName();
|
const name = generateRandomName();
|
||||||
const slug = slugify(name);
|
const slug = slugify(name);
|
||||||
|
|
||||||
const id = await resumeService.create({
|
await resumeService.create({
|
||||||
|
id,
|
||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
tags: [],
|
tags: [],
|
||||||
data: input.data,
|
data,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
});
|
});
|
||||||
@@ -110,7 +135,7 @@ export const crudRouter = {
|
|||||||
await resumeService.versions.snapshot({
|
await resumeService.versions.snapshot({
|
||||||
resumeId: id,
|
resumeId: id,
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
data: input.data,
|
data,
|
||||||
label: "Imported",
|
label: "Imported",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -220,6 +245,7 @@ export const crudRouter = {
|
|||||||
.output(resumeDto.duplicate.output)
|
.output(resumeDto.duplicate.output)
|
||||||
.handler(async ({ context, input }) => {
|
.handler(async ({ context, input }) => {
|
||||||
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
|
||||||
|
const data = parseStoredResumeData(original.data);
|
||||||
|
|
||||||
return resumeService.create({
|
return resumeService.create({
|
||||||
userId: context.user.id,
|
userId: context.user.id,
|
||||||
@@ -227,7 +253,7 @@ export const crudRouter = {
|
|||||||
slug: input.slug ?? original.slug,
|
slug: input.slug ?? original.slug,
|
||||||
tags: input.tags ?? original.tags,
|
tags: input.tags ?? original.tags,
|
||||||
locale: context.locale,
|
locale: context.locale,
|
||||||
data: original.data,
|
data,
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,31 @@ describe("subscribeResumeUpdated", () => {
|
|||||||
await iterator.next();
|
await iterator.next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("accepts stylesheet invalidations and ignores unknown mutation names", async () => {
|
||||||
|
const client = makeFakeClient();
|
||||||
|
pool.connect.mockResolvedValueOnce(client);
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
const iterator = subscribeResumeUpdated({
|
||||||
|
resumeId: "r1",
|
||||||
|
userId: "u1",
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
const resultP = iterator.next();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
|
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "forged" }));
|
||||||
|
client.__notify("resume_updated", JSON.stringify({ ...exampleEvent, mutation: "stylesheet" }));
|
||||||
|
|
||||||
|
const result = await resultP;
|
||||||
|
expect(result.value?.mutation).toBe("stylesheet");
|
||||||
|
|
||||||
|
controller.abort();
|
||||||
|
await iterator.next();
|
||||||
|
});
|
||||||
|
|
||||||
it("terminates immediately if signal is already aborted", async () => {
|
it("terminates immediately if signal is already aborted", async () => {
|
||||||
const client = makeFakeClient();
|
const client = makeFakeClient();
|
||||||
pool.connect.mockResolvedValueOnce(client);
|
pool.connect.mockResolvedValueOnce(client);
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { getPool } from "@reactive-resume/db/client";
|
import { getPool } from "@reactive-resume/db/client";
|
||||||
|
|
||||||
const RESUME_UPDATED_CHANNEL = "resume_updated";
|
const RESUME_UPDATED_CHANNEL = "resume_updated";
|
||||||
|
const resumeMutationNames = new Set([
|
||||||
|
"sync",
|
||||||
|
"create",
|
||||||
|
"update",
|
||||||
|
"patch",
|
||||||
|
"lock",
|
||||||
|
"password",
|
||||||
|
"delete",
|
||||||
|
"stylesheet",
|
||||||
|
] as const);
|
||||||
|
|
||||||
type PgNotification = {
|
type PgNotification = {
|
||||||
channel?: string | undefined;
|
channel?: string | undefined;
|
||||||
@@ -12,7 +22,7 @@ export type ResumeUpdatedEvent = {
|
|||||||
resumeId: string;
|
resumeId: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete";
|
mutation: "sync" | "create" | "update" | "patch" | "lock" | "password" | "delete" | "stylesheet";
|
||||||
};
|
};
|
||||||
|
|
||||||
type SubscribeResumeUpdatedInput = {
|
type SubscribeResumeUpdatedInput = {
|
||||||
@@ -30,7 +40,8 @@ function isResumeUpdatedEvent(value: unknown): value is ResumeUpdatedEvent {
|
|||||||
typeof event.resumeId === "string" &&
|
typeof event.resumeId === "string" &&
|
||||||
typeof event.userId === "string" &&
|
typeof event.userId === "string" &&
|
||||||
typeof event.updatedAt === "string" &&
|
typeof event.updatedAt === "string" &&
|
||||||
typeof event.mutation === "string"
|
typeof event.mutation === "string" &&
|
||||||
|
resumeMutationNames.has(event.mutation as ResumeUpdatedEvent["mutation"])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getById: vi.fn(),
|
||||||
|
renderPdf: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./service", () => ({
|
||||||
|
resumeService: {
|
||||||
|
getById: mocks.getById,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/pdf/server", () => ({
|
||||||
|
createResumePdfFile: mocks.renderPdf,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { createResumePdfDownload } = await import("./export");
|
||||||
|
|
||||||
|
const createRendererUnsafeResumeData = (): ResumeData => {
|
||||||
|
const data = structuredClone(defaultResumeData);
|
||||||
|
data.customSections = [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
return data;
|
||||||
|
};
|
||||||
|
|
||||||
|
const createLegacyRendererSafeResumeData = (): ResumeData =>
|
||||||
|
({
|
||||||
|
...structuredClone(defaultResumeData),
|
||||||
|
customSections: [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: "experience-item",
|
||||||
|
hidden: false,
|
||||||
|
company: "Analytical Engines",
|
||||||
|
position: "Programmer",
|
||||||
|
location: "London",
|
||||||
|
period: "1842–1843",
|
||||||
|
description: "<p>Wrote the first algorithm.</p>",
|
||||||
|
content: "<p>Compatible overlap</p>",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}) as unknown as ResumeData;
|
||||||
|
|
||||||
|
describe("createResumePdfDownload", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mocks.getById.mockReset();
|
||||||
|
mocks.renderPdf.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects renderer-unsafe stored data before export projection or PDF dispatch", async () => {
|
||||||
|
mocks.getById.mockResolvedValue({
|
||||||
|
id: "resume-1",
|
||||||
|
name: "Resume",
|
||||||
|
data: createRendererUnsafeResumeData(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(createResumePdfDownload({ id: "resume-1", userId: "user-1" })).rejects.toMatchObject({
|
||||||
|
code: "INTERNAL_SERVER_ERROR",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(mocks.renderPdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still renders valid stored data", async () => {
|
||||||
|
const body = new File(["%PDF"], "resume.pdf", { type: "application/pdf" });
|
||||||
|
mocks.getById.mockResolvedValue({
|
||||||
|
id: "resume-1",
|
||||||
|
name: "Resume",
|
||||||
|
data: createLegacyRendererSafeResumeData(),
|
||||||
|
});
|
||||||
|
mocks.renderPdf.mockResolvedValue(body);
|
||||||
|
|
||||||
|
await expect(createResumePdfDownload({ id: "resume-1", userId: "user-1" })).resolves.toMatchObject({ body });
|
||||||
|
expect(mocks.renderPdf.mock.calls[0]?.[0].data.customSections[0]?.items[0]).toMatchObject({
|
||||||
|
content: "<p>Compatible overlap</p>",
|
||||||
|
roles: [],
|
||||||
|
website: { url: "", label: "", inlineLink: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -5,6 +5,7 @@ import { getResumeExportData, resumeHasCoverLetter } from "@reactive-resume/resu
|
|||||||
import { generateFilename } from "@reactive-resume/utils/file";
|
import { generateFilename } from "@reactive-resume/utils/file";
|
||||||
import { protectedProcedure } from "../../context";
|
import { protectedProcedure } from "../../context";
|
||||||
import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
import { pdfExportRateLimit } from "../../middleware/rate-limit";
|
||||||
|
import { parseStoredResumeData } from "./resume-data-validation";
|
||||||
import { resumeService } from "./service";
|
import { resumeService } from "./service";
|
||||||
|
|
||||||
export {
|
export {
|
||||||
@@ -21,8 +22,9 @@ type CreateResumePdfDownloadInput = {
|
|||||||
|
|
||||||
export async function createResumePdfDownload(input: CreateResumePdfDownloadInput) {
|
export async function createResumePdfDownload(input: CreateResumePdfDownloadInput) {
|
||||||
const resume = await resumeService.getById({ id: input.id, userId: input.userId });
|
const resume = await resumeService.getById({ id: input.id, userId: input.userId });
|
||||||
|
const data = parseStoredResumeData(resume.data);
|
||||||
const target = input.target ?? "resume";
|
const target = input.target ?? "resume";
|
||||||
if (target === "cover-letter" && !resumeHasCoverLetter(resume.data)) {
|
if (target === "cover-letter" && !resumeHasCoverLetter(data)) {
|
||||||
throw new ORPCError("NOT_FOUND", { message: "No cover letter found for this resume" });
|
throw new ORPCError("NOT_FOUND", { message: "No cover letter found for this resume" });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -34,7 +36,7 @@ export async function createResumePdfDownload(input: CreateResumePdfDownloadInpu
|
|||||||
// exported, instead of at server boot. Slashes cold-start file I/O on
|
// exported, instead of at server boot. Slashes cold-start file I/O on
|
||||||
// constrained/slow-disk hosts. See fork perf/lazy-load-pdf.
|
// constrained/slow-disk hosts. See fork perf/lazy-load-pdf.
|
||||||
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
|
const { createResumePdfFile } = await import("@reactive-resume/pdf/server");
|
||||||
const body = await createResumePdfFile({ data: getResumeExportData(resume.data, target), filename });
|
const body = await createResumePdfFile({ data: getResumeExportData(data, target), filename });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
headers: {
|
headers: {
|
||||||
|
|||||||
@@ -0,0 +1,259 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||||
|
import { createPublicResumePdf } from "./public-pdf";
|
||||||
|
import { createPublicRenderRateLimiter } from "./public-render-rate-limit";
|
||||||
|
import { getStyleProjection } from "./public-style-projection";
|
||||||
|
|
||||||
|
const requestHeaders = new Headers({ "x-forwarded-for": "203.0.113.7" });
|
||||||
|
const input = {
|
||||||
|
username: "jane",
|
||||||
|
slug: "resume",
|
||||||
|
requestHeaders,
|
||||||
|
trustedClient: "203.0.113.9",
|
||||||
|
mismatchReason: "render-data-hash" as const,
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildResume = (overrides: Partial<{ isPublic: boolean; passwordHash: string | null }> = {}) => ({
|
||||||
|
id: "resume-1",
|
||||||
|
userId: "owner-1",
|
||||||
|
name: "Private dashboard title",
|
||||||
|
slug: "resume",
|
||||||
|
data: structuredClone(defaultResumeData),
|
||||||
|
isPublic: overrides.isPublic ?? true,
|
||||||
|
passwordHash: overrides.passwordHash ?? null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const buildRendererUnsafeResume = () => {
|
||||||
|
const resume = buildResume();
|
||||||
|
resume.data.customSections = [
|
||||||
|
{
|
||||||
|
id: "custom-experience",
|
||||||
|
type: "experience",
|
||||||
|
title: "Experience",
|
||||||
|
icon: "",
|
||||||
|
columns: 1,
|
||||||
|
hidden: false,
|
||||||
|
keepTogether: false,
|
||||||
|
startOnNewPage: false,
|
||||||
|
items: [{ id: "summary-shaped-item", hidden: false, content: "<p>Missing company</p>" }],
|
||||||
|
} as never,
|
||||||
|
];
|
||||||
|
return resume;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("createPublicResumePdf", () => {
|
||||||
|
it("rejects unbounded mismatch metadata before access or rendering", async () => {
|
||||||
|
const findResume = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(
|
||||||
|
{
|
||||||
|
...input,
|
||||||
|
mismatchReason: "private source" as typeof input.mismatchReason,
|
||||||
|
clientRegistryFingerprint: "not-a-fingerprint",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
findResume,
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn(),
|
||||||
|
rateLimiter: { consume: vi.fn() },
|
||||||
|
renderPdf: vi.fn(),
|
||||||
|
getFingerprints: vi.fn(),
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).rejects.toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||||
|
expect(findResume).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("authorizes private and password-protected resumes before budget or render", async () => {
|
||||||
|
const consume = vi.fn();
|
||||||
|
const renderPdf = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(input, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(buildResume({ isPublic: false })),
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume },
|
||||||
|
renderPdf,
|
||||||
|
getFingerprints: vi.fn(),
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
expect(consume).not.toHaveBeenCalled();
|
||||||
|
expect(renderPdf).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(input, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(buildResume({ passwordHash: "hash" })),
|
||||||
|
hasPasswordAccess: vi.fn().mockReturnValue(false),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume },
|
||||||
|
renderPdf,
|
||||||
|
getFingerprints: vi.fn(),
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "NEED_PASSWORD" });
|
||||||
|
expect(consume).not.toHaveBeenCalled();
|
||||||
|
expect(renderPdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects renderer-unsafe stored data before budget, projection metadata, or rendering", async () => {
|
||||||
|
const consume = vi.fn();
|
||||||
|
const renderPdf = vi.fn();
|
||||||
|
const getFingerprints = vi.fn();
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(input, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(buildRendererUnsafeResume()),
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume },
|
||||||
|
renderPdf,
|
||||||
|
getFingerprints,
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "INTERNAL_SERVER_ERROR" });
|
||||||
|
|
||||||
|
expect(consume).not.toHaveBeenCalled();
|
||||||
|
expect(getFingerprints).not.toHaveBeenCalled();
|
||||||
|
expect(renderPdf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares the exact limiter with style projection", async () => {
|
||||||
|
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||||
|
const findResume = vi.fn().mockResolvedValue(buildResume());
|
||||||
|
const hasPasswordAccess = vi.fn();
|
||||||
|
const projectionInput = {
|
||||||
|
...input,
|
||||||
|
requestHeaders: new Headers({ "x-forwarded-for": "198.51.100.1" }),
|
||||||
|
};
|
||||||
|
const pdfInput = {
|
||||||
|
...input,
|
||||||
|
requestHeaders: new Headers({ "x-forwarded-for": "198.51.100.2" }),
|
||||||
|
};
|
||||||
|
|
||||||
|
await getStyleProjection(projectionInput, {
|
||||||
|
findResume,
|
||||||
|
hasPasswordAccess,
|
||||||
|
rateLimiter: limiter,
|
||||||
|
createProjection: vi.fn().mockResolvedValue({
|
||||||
|
formatVersion: 1,
|
||||||
|
languageVersion: 1,
|
||||||
|
semanticTreeVersion: 1,
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
renderDataHash: "2".repeat(64),
|
||||||
|
nodes: {},
|
||||||
|
}),
|
||||||
|
cache: new Map(),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(pdfInput, {
|
||||||
|
findResume,
|
||||||
|
hasPasswordAccess,
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: limiter,
|
||||||
|
renderPdf: vi.fn(),
|
||||||
|
getFingerprints: vi.fn(),
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "RATE_LIMIT_EXCEEDED" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not accept a caller-supplied owner identity for a private resume", async () => {
|
||||||
|
const forgedInput = { ...input, currentUserId: "owner-1" };
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(forgedInput, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(buildResume({ isPublic: false })),
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume: vi.fn() },
|
||||||
|
renderPdf: vi.fn().mockResolvedValue(new File(["%PDF"], "resume.pdf")),
|
||||||
|
getFingerprints: vi.fn().mockResolvedValue({
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
}),
|
||||||
|
now: () => 0,
|
||||||
|
observe: vi.fn(),
|
||||||
|
}),
|
||||||
|
).rejects.toMatchObject({ code: "NOT_FOUND" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("propagates semantic diagnostics instead of returning an unstyled fallback PDF", async () => {
|
||||||
|
const observe = vi.fn();
|
||||||
|
const semanticError = new Error("The semantic stylesheet could not be rendered.", {
|
||||||
|
cause: [{ code: "RESOURCE_LIMIT", severity: "error" }],
|
||||||
|
});
|
||||||
|
const renderPdf = vi.fn().mockRejectedValue(semanticError);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
createPublicResumePdf(input, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(buildResume()),
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume: vi.fn() },
|
||||||
|
renderPdf,
|
||||||
|
getFingerprints: vi.fn().mockResolvedValue({
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
}),
|
||||||
|
now: () => 10,
|
||||||
|
observe,
|
||||||
|
}),
|
||||||
|
).rejects.toBe(semanticError);
|
||||||
|
|
||||||
|
expect(renderPdf).toHaveBeenCalledTimes(1);
|
||||||
|
expect(observe).toHaveBeenCalledWith(expect.objectContaining({ success: false }));
|
||||||
|
expect(observe).not.toHaveBeenCalledWith(expect.objectContaining({ success: true }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("emits source-free, hashed fallback metadata", async () => {
|
||||||
|
const sensitive = "Ada <ada@example.test> /* source */";
|
||||||
|
const observe = vi.fn();
|
||||||
|
let now = 10;
|
||||||
|
const resume = buildResume();
|
||||||
|
resume.id = sensitive;
|
||||||
|
resume.data.basics.name = sensitive;
|
||||||
|
resume.data.metadata.stylesheet = {
|
||||||
|
mode: "semantic",
|
||||||
|
source: { languageVersion: 1, text: sensitive },
|
||||||
|
applied: { languageVersion: 1, text: sensitive },
|
||||||
|
};
|
||||||
|
|
||||||
|
await createPublicResumePdf(input, {
|
||||||
|
findResume: vi.fn().mockResolvedValue(resume),
|
||||||
|
hasPasswordAccess: vi.fn(),
|
||||||
|
resolveCurrentUserId: vi.fn().mockResolvedValue(undefined),
|
||||||
|
rateLimiter: { consume: vi.fn() },
|
||||||
|
renderPdf: vi.fn().mockResolvedValue(new File(["%PDF"], "resume.pdf", { type: "application/pdf" })),
|
||||||
|
getFingerprints: vi.fn().mockResolvedValue({
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
}),
|
||||||
|
now: () => (now += 5),
|
||||||
|
observe,
|
||||||
|
});
|
||||||
|
|
||||||
|
const serialized = JSON.stringify(observe.mock.calls);
|
||||||
|
expect(observe).toHaveBeenCalledWith({
|
||||||
|
name: "semantic_css.render_fallback",
|
||||||
|
resumeIdHash: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||||
|
mismatchReason: "render-data-hash",
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
durationMs: 5,
|
||||||
|
success: true,
|
||||||
|
});
|
||||||
|
expect(serialized).not.toContain(sensitive);
|
||||||
|
expect(serialized).not.toMatch(/source|comment|diagnostic|email/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||||
|
import type { PublicRenderAccessDependencies } from "./public-style-projection";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { generateFilename } from "@reactive-resume/utils/file";
|
||||||
|
import { publicRenderRateLimiter } from "./public-render-rate-limit";
|
||||||
|
import { defaultPublicRenderAccessDependencies, loadAuthorizedPublicRenderResume } from "./public-style-projection";
|
||||||
|
import { hashSemanticCssResumeId } from "./stylesheet-observability";
|
||||||
|
|
||||||
|
export type PublicResumePdfMismatchReason =
|
||||||
|
| "missing-projection"
|
||||||
|
| "format-version"
|
||||||
|
| "language-version"
|
||||||
|
| "semantic-tree-version"
|
||||||
|
| "registry-fingerprint"
|
||||||
|
| "adapter-fingerprint"
|
||||||
|
| "render-data-hash"
|
||||||
|
| "invalid-projection";
|
||||||
|
|
||||||
|
export const PUBLIC_RESUME_PDF_MISMATCH_REASONS = [
|
||||||
|
"missing-projection",
|
||||||
|
"format-version",
|
||||||
|
"language-version",
|
||||||
|
"semantic-tree-version",
|
||||||
|
"registry-fingerprint",
|
||||||
|
"adapter-fingerprint",
|
||||||
|
"render-data-hash",
|
||||||
|
"invalid-projection",
|
||||||
|
] as const satisfies readonly PublicResumePdfMismatchReason[];
|
||||||
|
|
||||||
|
export type CreatePublicResumePdfInput = {
|
||||||
|
username: string;
|
||||||
|
slug: string;
|
||||||
|
requestHeaders: Headers;
|
||||||
|
trustedClient: string;
|
||||||
|
mismatchReason: PublicResumePdfMismatchReason;
|
||||||
|
clientRegistryFingerprint?: string;
|
||||||
|
clientAdapterFingerprint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicResumePdfDependencies = PublicRenderAccessDependencies & {
|
||||||
|
resolveCurrentUserId(requestHeaders: Headers): Promise<string | undefined>;
|
||||||
|
rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void };
|
||||||
|
renderPdf(input: { data: ResumeData; filename: string }): Promise<File>;
|
||||||
|
getFingerprints(): Promise<{ registryFingerprint: string; adapterFingerprint: string }>;
|
||||||
|
now(): number;
|
||||||
|
observe(event: Readonly<Record<string, unknown>>): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const defaultDependencies: PublicResumePdfDependencies = {
|
||||||
|
...defaultPublicRenderAccessDependencies,
|
||||||
|
resolveCurrentUserId: async (requestHeaders) =>
|
||||||
|
(await import("../../context")).resolveUserFromRequestHeaders(requestHeaders).then((user) => user?.id),
|
||||||
|
rateLimiter: publicRenderRateLimiter,
|
||||||
|
renderPdf: async (input) => (await import("@reactive-resume/pdf/server")).createResumePdfFile(input),
|
||||||
|
getFingerprints: async () =>
|
||||||
|
(await import("@reactive-resume/pdf/public-projection")).getPublicStyleProjectionFingerprints(),
|
||||||
|
now: Date.now,
|
||||||
|
observe: console.info,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function createPublicResumePdf(
|
||||||
|
input: CreatePublicResumePdfInput,
|
||||||
|
dependencies: PublicResumePdfDependencies = defaultDependencies,
|
||||||
|
): Promise<{ body: File; filename: string }> {
|
||||||
|
const fingerprintPattern = /^[a-f0-9]{64}$/;
|
||||||
|
if (
|
||||||
|
!PUBLIC_RESUME_PDF_MISMATCH_REASONS.includes(input.mismatchReason) ||
|
||||||
|
(input.clientRegistryFingerprint !== undefined && !fingerprintPattern.test(input.clientRegistryFingerprint)) ||
|
||||||
|
(input.clientAdapterFingerprint !== undefined && !fingerprintPattern.test(input.clientAdapterFingerprint))
|
||||||
|
) {
|
||||||
|
throw new ORPCError("BAD_REQUEST", { status: 400, message: "Invalid public PDF fallback metadata." });
|
||||||
|
}
|
||||||
|
const currentUserId = await dependencies.resolveCurrentUserId(input.requestHeaders);
|
||||||
|
const resume = await loadAuthorizedPublicRenderResume(
|
||||||
|
{
|
||||||
|
username: input.username,
|
||||||
|
slug: input.slug,
|
||||||
|
requestHeaders: input.requestHeaders,
|
||||||
|
trustedClient: input.trustedClient,
|
||||||
|
...(currentUserId ? { currentUserId } : {}),
|
||||||
|
},
|
||||||
|
dependencies,
|
||||||
|
);
|
||||||
|
dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id });
|
||||||
|
const startedAt = dependencies.now();
|
||||||
|
const fingerprints = await dependencies.getFingerprints();
|
||||||
|
const event = (success: boolean) => {
|
||||||
|
dependencies.observe({
|
||||||
|
name: "semantic_css.render_fallback",
|
||||||
|
resumeIdHash: hashSemanticCssResumeId(resume.id),
|
||||||
|
mismatchReason: input.mismatchReason,
|
||||||
|
...(input.clientRegistryFingerprint ? { clientRegistryFingerprint: input.clientRegistryFingerprint } : {}),
|
||||||
|
...(input.clientAdapterFingerprint ? { clientAdapterFingerprint: input.clientAdapterFingerprint } : {}),
|
||||||
|
...fingerprints,
|
||||||
|
durationMs: Math.max(0, dependencies.now() - startedAt),
|
||||||
|
success,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const filename = generateFilename(resume.data.basics.name || "Resume", "pdf");
|
||||||
|
const body = await dependencies.renderPdf({ data: resume.data, filename });
|
||||||
|
event(true);
|
||||||
|
return {
|
||||||
|
body,
|
||||||
|
filename,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
event(false);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { createPublicRenderRateLimiter } from "./public-render-rate-limit";
|
||||||
|
|
||||||
|
describe("public render rate limit", () => {
|
||||||
|
it("cannot reset a transport client's budget by rotating forwarding headers", () => {
|
||||||
|
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||||
|
const first = {
|
||||||
|
trustedClient: "203.0.113.9",
|
||||||
|
requestHeaders: new Headers({
|
||||||
|
"cf-connecting-ip": "198.51.100.1",
|
||||||
|
"x-forwarded-for": "198.51.100.2",
|
||||||
|
}),
|
||||||
|
resumeId: "resume-1",
|
||||||
|
};
|
||||||
|
const rotated = {
|
||||||
|
...first,
|
||||||
|
requestHeaders: new Headers({
|
||||||
|
"cf-connecting-ip": "198.51.100.3",
|
||||||
|
"x-forwarded-for": "198.51.100.4",
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
limiter.consume(first);
|
||||||
|
|
||||||
|
expect(() => limiter.consume(rotated)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shares one IP-and-resume token bucket across projection and PDF consumers", () => {
|
||||||
|
const limiter = createPublicRenderRateLimiter({ capacity: 2, refillWindowMs: 60_000, now: () => 0 });
|
||||||
|
const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" };
|
||||||
|
|
||||||
|
limiter.consume(input);
|
||||||
|
limiter.consume(input);
|
||||||
|
|
||||||
|
expect(() => limiter.consume(input)).toThrowError(
|
||||||
|
expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps budgets separate by client IP and resume", () => {
|
||||||
|
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000, now: () => 0 });
|
||||||
|
limiter.consume({ trustedClient: "203.0.113.7", resumeId: "resume-1" });
|
||||||
|
|
||||||
|
expect(() => limiter.consume({ trustedClient: "203.0.113.8", resumeId: "resume-1" })).not.toThrow();
|
||||||
|
expect(() => limiter.consume({ trustedClient: "203.0.113.7", resumeId: "resume-2" })).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refills the bounded bucket over time", () => {
|
||||||
|
let now = 0;
|
||||||
|
const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 1_000, now: () => now });
|
||||||
|
const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" };
|
||||||
|
limiter.consume(input);
|
||||||
|
expect(() => limiter.consume(input)).toThrow();
|
||||||
|
|
||||||
|
now = 1_000;
|
||||||
|
|
||||||
|
expect(() => limiter.consume(input)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
|
type PublicRenderRateLimitInput = {
|
||||||
|
/** Sanitized transport identity supplied by the server adapter, never by request headers. */
|
||||||
|
trustedClient: string;
|
||||||
|
resumeId: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PublicRenderRateLimiter = {
|
||||||
|
consume(input: PublicRenderRateLimitInput): void;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Bucket = {
|
||||||
|
tokens: number;
|
||||||
|
updatedAt: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const MAX_BUCKETS = 50_000;
|
||||||
|
|
||||||
|
export function createPublicRenderRateLimiter(
|
||||||
|
options: { capacity?: number; refillWindowMs?: number; now?: () => number } = {},
|
||||||
|
): PublicRenderRateLimiter {
|
||||||
|
const capacity = options.capacity ?? 6;
|
||||||
|
const refillWindowMs = options.refillWindowMs ?? 60_000;
|
||||||
|
const now = options.now ?? Date.now;
|
||||||
|
if (!Number.isInteger(capacity) || capacity <= 0 || !Number.isFinite(refillWindowMs) || refillWindowMs <= 0) {
|
||||||
|
throw new Error("Public render token bucket requires positive finite limits");
|
||||||
|
}
|
||||||
|
const buckets = new Map<string, Bucket>();
|
||||||
|
|
||||||
|
return {
|
||||||
|
consume(input) {
|
||||||
|
const currentTime = now();
|
||||||
|
const trustedClient = input.trustedClient.trim() || "unknown";
|
||||||
|
const key = `${trustedClient}:${input.resumeId}`;
|
||||||
|
const previous = buckets.get(key) ?? { tokens: capacity, updatedAt: currentTime };
|
||||||
|
const elapsed = Math.max(0, currentTime - previous.updatedAt);
|
||||||
|
const tokens = Math.min(capacity, previous.tokens + (elapsed * capacity) / refillWindowMs);
|
||||||
|
|
||||||
|
if (tokens < 1) {
|
||||||
|
throw new ORPCError("RATE_LIMIT_EXCEEDED", {
|
||||||
|
status: 429,
|
||||||
|
message: "Public resume rendering rate limit exceeded.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
buckets.delete(key);
|
||||||
|
buckets.set(key, { tokens: tokens - 1, updatedAt: currentTime });
|
||||||
|
if (buckets.size <= MAX_BUCKETS) return;
|
||||||
|
|
||||||
|
for (const [candidate, bucket] of buckets) {
|
||||||
|
if (currentTime - bucket.updatedAt >= refillWindowMs) buckets.delete(candidate);
|
||||||
|
}
|
||||||
|
if (buckets.size > MAX_BUCKETS) buckets.delete(buckets.keys().next().value as string);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicRenderRateLimiter = createPublicRenderRateLimiter();
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { createRouterClient } from "@orpc/server";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({
|
||||||
|
getStyleProjection: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../context", async () => {
|
||||||
|
const { os } = await vi.importActual<typeof import("@orpc/server")>("@orpc/server");
|
||||||
|
const base = os.$context<{
|
||||||
|
locale: "en-US";
|
||||||
|
reqHeaders: Headers;
|
||||||
|
resHeaders?: Headers;
|
||||||
|
trustedClient?: string;
|
||||||
|
}>();
|
||||||
|
return { publicProcedure: base, protectedProcedure: base };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("./public-style-projection", () => ({
|
||||||
|
getStyleProjection: mocks.getStyleProjection,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("./service", () => ({ resumeService: {} }));
|
||||||
|
|
||||||
|
const { sharingRouter } = await import("./sharing");
|
||||||
|
|
||||||
|
describe("public style projection route", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.getStyleProjection.mockResolvedValue({
|
||||||
|
formatVersion: 1,
|
||||||
|
languageVersion: 1,
|
||||||
|
semanticTreeVersion: 1,
|
||||||
|
registryFingerprint: "0".repeat(64),
|
||||||
|
adapterFingerprint: "1".repeat(64),
|
||||||
|
renderDataHash: "2".repeat(64),
|
||||||
|
nodes: {},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mounts a concrete public GET route beside the ordinary JSON read", () => {
|
||||||
|
expect(sharingRouter.getStyleProjection["~orpc"].route).toMatchObject({
|
||||||
|
method: "GET",
|
||||||
|
path: "/resumes/{username}/{slug}/style-projection",
|
||||||
|
operationId: "getResumeStyleProjection",
|
||||||
|
});
|
||||||
|
expect(sharingRouter.getBySlug["~orpc"].route.path).toBe("/resumes/{username}/{slug}");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks the concrete projection response private and uses the server-derived client identity", async () => {
|
||||||
|
const reqHeaders = new Headers({ "x-forwarded-for": "198.51.100.1" });
|
||||||
|
const resHeaders = new Headers();
|
||||||
|
const client = createRouterClient(sharingRouter, {
|
||||||
|
context: {
|
||||||
|
locale: "en-US",
|
||||||
|
reqHeaders,
|
||||||
|
resHeaders,
|
||||||
|
trustedClient: "203.0.113.9",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getStyleProjection({ username: "jane", slug: "resume" });
|
||||||
|
|
||||||
|
expect(resHeaders.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
expect(mocks.getStyleProjection).toHaveBeenCalledWith({
|
||||||
|
username: "jane",
|
||||||
|
slug: "resume",
|
||||||
|
requestHeaders: reqHeaders,
|
||||||
|
trustedClient: "203.0.113.9",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user