diff --git a/.ncurc.cjs b/.ncurc.cjs deleted file mode 100644 index 4bec78a9d..000000000 --- a/.ncurc.cjs +++ /dev/null @@ -1,17 +0,0 @@ -// @ts-check - -const betaPackages = ["drizzle-zod"]; -const rcPackages = ["drizzle-orm", "drizzle-kit"]; - -/** @type {import('npm-check-updates').RunOptions} */ -module.exports = { - upgrade: true, - workspaces: true, - install: "always", - packageManager: "pnpm", - target: (packageName) => { - if (betaPackages.includes(packageName)) return "@beta"; - if (rcPackages.includes(packageName)) return "@rc"; - return "latest"; - }, -}; diff --git a/IDEA.md b/IDEA.md deleted file mode 120000 index 47dc3e3d8..000000000 --- a/IDEA.md +++ /dev/null @@ -1 +0,0 @@ -AGENTS.md \ No newline at end of file diff --git a/apps/server/src/mcp/server.ts b/apps/server/src/mcp/server.ts index 5051fc046..e64c45855 100644 --- a/apps/server/src/mcp/server.ts +++ b/apps/server/src/mcp/server.ts @@ -3,7 +3,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { onError } from "@orpc/client"; import { createRouterClient } from "@orpc/server"; import router from "@reactive-resume/api/routers"; -import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp"; +import { + buildMcpServerInfo, + MCP_TOOL_NAME, + registerPrompts, + registerResources, + registerTools, +} from "@reactive-resume/mcp"; import { appVersion } from "../app-version"; import { getRequestLocale } from "../rpc/locale"; @@ -23,41 +29,19 @@ function createRequestClient(request: Request): RouterClient { } export function createMcpServer(request: Request) { - const server = new McpServer( - { - name: "reactive-resume", - version: appVersion, - title: "Reactive Resume", - websiteUrl: "https://rxresu.me", - description: - "Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.", - icons: [ - { - src: "https://rxresu.me/icon/light.svg", - mimeType: "image/svg+xml", - theme: "light", - }, - { - src: "https://rxresu.me/icon/dark.svg", - mimeType: "image/svg+xml", - theme: "dark", - }, - ], - }, - { - instructions: [ - "You are connected to Reactive Resume over MCP.", - "Authenticate with OAuth (recommended) or an API key (`x-api-key`).", - `Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`, - `List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`, - `Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`, - `Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`, - `Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`, - `Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`, - `Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`, - ].join(" "), - }, - ); + const server = new McpServer(buildMcpServerInfo(appVersion), { + instructions: [ + "You are connected to Reactive Resume over MCP.", + "Authenticate with OAuth (recommended) or an API key (`x-api-key`).", + `Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`, + `List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`, + `Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`, + `Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`, + `Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`, + `Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`, + `Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`, + ].join(" "), + }); const client = createRequestClient(request); registerResources(server, client); diff --git a/apps/server/src/openapi/generate-spec.ts b/apps/server/src/openapi/generate-spec.ts index b5dca6c63..d12a5ef6a 100644 --- a/apps/server/src/openapi/generate-spec.ts +++ b/apps/server/src/openapi/generate-spec.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from "node:fs/promises"; import { fileURLToPath } from "node:url"; -export async function generateOpenApiDocumentation( +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 { diff --git a/apps/web/package.json b/apps/web/package.json index e9c22d9b0..824e02631 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -72,16 +72,13 @@ "@uiw/react-color-colorful": "^2.10.3", "ai": "^7.0.102", "better-auth": "1.7.5", - "buffer": "^6.0.3", "cmdk": "^1.1.1", - "drizzle-orm": "1.0.0-rc.4", "es-toolkit": "^1.52.0", "fuse.js": "^7.5.0", "immer": "^11.1.18", "js-cookie": "^3.0.8", "motion": "^13.4.0", "pdfjs-dist": "6.3.289", - "pg": "^8.23.0", "prettier": "^3.9.7", "qrcode.react": "^4.2.0", "react": "^19.3.0", @@ -112,7 +109,6 @@ "@tanstack/react-router-devtools": "^1.167.2", "@tanstack/router-plugin": "^1.168.38", "@types/babel__core": "^7.20.5", - "@types/pg": "^8.23.1", "@types/react": "^19.3.0", "@types/react-dom": "^19.3.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", diff --git a/apps/web/src/components/animation/text-mask.test.tsx b/apps/web/src/components/animation/text-mask.test.tsx deleted file mode 100644 index 80aa97280..000000000 --- a/apps/web/src/components/animation/text-mask.test.tsx +++ /dev/null @@ -1,47 +0,0 @@ -// @vitest-environment happy-dom - -import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { TextMaskEffect } from "./text-mask"; - -const renderMask = (props: React.ComponentProps) => render(); - -describe("TextMaskEffect", () => { - it("renders the supplied text in all visible text layers", () => { - const { container } = renderMask({ text: "Hello World" }); - const texts = container.querySelectorAll("text"); - expect(texts.length).toBeGreaterThanOrEqual(2); - for (const el of texts) { - expect(el.textContent).toBe("Hello World"); - } - }); - - it("forwards aria-hidden onto the root svg", () => { - const { container } = renderMask({ text: "X", "aria-hidden": "true" }); - const svg = container.querySelector("svg") as SVGSVGElement; - expect(svg.getAttribute("aria-hidden")).toBe("true"); - }); - - it("renders an aria-label on the root svg", () => { - const { container } = renderMask({ text: "X" }); - const svg = container.querySelector("svg") as SVGSVGElement; - expect(svg.getAttribute("aria-label")).toBe("Text mask effect"); - }); - - it("does not throw on mouse-enter / mouse-move / mouse-leave interactions", () => { - const { container } = renderMask({ text: "X" }); - const svg = container.querySelector("svg") as SVGSVGElement; - - expect(() => { - fireEvent.mouseEnter(svg); - fireEvent.mouseMove(svg, { clientX: 50, clientY: 25 }); - fireEvent.mouseLeave(svg); - }).not.toThrow(); - }); - - it("merges custom className into the svg", () => { - const { container } = renderMask({ text: "X", className: "custom-class" }); - const svg = container.querySelector("svg") as SVGSVGElement; - expect(svg.getAttribute("class") ?? "").toContain("custom-class"); - }); -}); diff --git a/apps/web/src/components/animation/text-mask.tsx b/apps/web/src/components/animation/text-mask.tsx deleted file mode 100644 index dfcdc6449..000000000 --- a/apps/web/src/components/animation/text-mask.tsx +++ /dev/null @@ -1,110 +0,0 @@ -import { m } from "motion/react"; -import { useRef, useState } from "react"; -import { cn } from "@reactive-resume/utils/style"; - -const textClassName = cn("fill-transparent font-bold text-3xl leading-none tracking-tight"); - -type TextMaskEffectProps = { - text: string; - duration?: number; - className?: string; - "aria-hidden"?: boolean | "true" | "false"; -}; - -export const TextMaskEffect = ({ text, duration = 6, className, "aria-hidden": ariaHidden }: TextMaskEffectProps) => { - const svgRef = useRef(null); - const [hovered, setHovered] = useState(false); - const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" }); - - return ( - setHovered(true)} - onMouseLeave={() => setHovered(false)} - onMouseMove={(e) => { - if (!svgRef.current) return; - const svgRect = svgRef.current.getBoundingClientRect(); - const cxPercentage = ((e.clientX - svgRect.left) / svgRect.width) * 100; - const cyPercentage = ((e.clientY - svgRect.top) / svgRect.height) * 100; - - setMaskPosition({ cx: `${cxPercentage}%`, cy: `${cyPercentage}%` }); - }} - > - - - {hovered && ( - <> - - - - - - - )} - - - - - - - - - - - - - - {text} - - - - {text} - - - - {text} - - - ); -}; diff --git a/apps/web/src/dialogs/api-key/registry.tsx b/apps/web/src/dialogs/api-key/registry.tsx index 122d7d5ac..ca462c325 100644 --- a/apps/web/src/dialogs/api-key/registry.tsx +++ b/apps/web/src/dialogs/api-key/registry.tsx @@ -1,4 +1,4 @@ -import type { AnyDialogRendererEntry } from "../renderer-registry"; +import type { AnyDialogRendererEntry } from "../schemas"; import { CreateApiKeyDialog } from "./create"; export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [ diff --git a/apps/web/src/dialogs/auth/enable-two-factor.tsx b/apps/web/src/dialogs/auth/enable-two-factor.tsx index 1c899c84b..f2c63317e 100644 --- a/apps/web/src/dialogs/auth/enable-two-factor.tsx +++ b/apps/web/src/dialogs/auth/enable-two-factor.tsx @@ -21,6 +21,7 @@ import { FormControl, FormItem, FormLabel, FormMessage } from "@reactive-resume/ import { Input } from "@reactive-resume/ui/components/input"; import { OTPField } from "@reactive-resume/ui/components/otp-field"; import { toast } from "@reactive-resume/ui/components/toast"; +import { downloadWithAnchor } from "@reactive-resume/utils/file"; import { useFormBlocker } from "@/hooks/use-form-blocker"; import { authClient } from "@/libs/auth/client"; import { getReadableErrorMessage } from "@/libs/error-message"; @@ -164,16 +165,7 @@ export function EnableTwoFactorDialog(_: DialogProps<"auth.two-factor.enable">) const handleDownloadBackupCodes = () => { if (!backupCodes) return; - const content = backupCodes.join("\n"); - const blob = new Blob([content], { type: "text/plain" }); - const url = URL.createObjectURL(blob); - const a = document.createElement("a"); - a.href = url; - a.download = "reactive-resume_backup-codes.txt"; - document.body.appendChild(a); - a.click(); - document.body.removeChild(a); - URL.revokeObjectURL(url); + downloadWithAnchor(new Blob([backupCodes.join("\n")], { type: "text/plain" }), "reactive-resume_backup-codes.txt"); }; return ( diff --git a/apps/web/src/dialogs/auth/registry.tsx b/apps/web/src/dialogs/auth/registry.tsx index f41c558c4..deacb5a9a 100644 --- a/apps/web/src/dialogs/auth/registry.tsx +++ b/apps/web/src/dialogs/auth/registry.tsx @@ -1,4 +1,4 @@ -import type { AnyDialogRendererEntry } from "../renderer-registry"; +import type { AnyDialogRendererEntry } from "../schemas"; import { ChangePasswordDialog } from "./change-password"; import { DisableTwoFactorDialog } from "./disable-two-factor"; import { EnableTwoFactorDialog } from "./enable-two-factor"; diff --git a/apps/web/src/dialogs/renderer-registry.ts b/apps/web/src/dialogs/renderer-registry.ts deleted file mode 100644 index 833f26138..000000000 --- a/apps/web/src/dialogs/renderer-registry.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { ReactNode } from "react"; -import type { DialogSchema, DialogType } from "./schemas"; - -// ponytail: defineDialogRenderer/defineDialogRendererRegistry were identity fns; inlined -type DialogRendererEntry = { - type: T; - render: (dialog: Extract) => ReactNode; -}; - -export type AnyDialogRendererEntry = { - [T in DialogType]: DialogRendererEntry; -}[DialogType]; diff --git a/apps/web/src/dialogs/resume/registry.tsx b/apps/web/src/dialogs/resume/registry.tsx index bbc7a289e..9e22f9630 100644 --- a/apps/web/src/dialogs/resume/registry.tsx +++ b/apps/web/src/dialogs/resume/registry.tsx @@ -1,4 +1,4 @@ -import type { AnyDialogRendererEntry } from "../renderer-registry"; +import type { AnyDialogRendererEntry } from "../schemas"; import { CreateResumeDialog, DuplicateResumeDialog, UpdateResumeDialog } from "."; import { ImportResumeDialog } from "./import"; import { CreateAwardDialog, UpdateAwardDialog } from "./sections/award"; diff --git a/apps/web/src/dialogs/schemas.ts b/apps/web/src/dialogs/schemas.ts index a86778d6b..e57f3af78 100644 --- a/apps/web/src/dialogs/schemas.ts +++ b/apps/web/src/dialogs/schemas.ts @@ -1,4 +1,5 @@ import type { CoverLetterItem, CustomSection, SectionItem, SummaryItem } from "@reactive-resume/schema/resume/data"; +import type { ReactNode } from "react"; type EmptyDialog = { [K in T]: { type: K; data?: undefined } }[T]; @@ -55,6 +56,15 @@ export type DialogSchema = export type DialogType = DialogSchema["type"]; +export type DialogRendererEntry = { + type: T; + render: (dialog: Extract) => ReactNode; +}; + +export type AnyDialogRendererEntry = { + [T in DialogType]: DialogRendererEntry; +}[DialogType]; + export type DialogData = Extract["data"]; type DialogPropsData = diff --git a/apps/web/src/features/ats-checker/messages.test.ts b/apps/web/src/features/ats-checker/messages.test.ts index 1093270ba..175614541 100644 --- a/apps/web/src/features/ats-checker/messages.test.ts +++ b/apps/web/src/features/ats-checker/messages.test.ts @@ -3,24 +3,10 @@ import { beforeAll, describe, expect, it } from "vitest"; import { i18n } from "@lingui/core"; import { PDF_ATS_RULE_CODES } from "@reactive-resume/resume/ats-pdf"; -import { - getPdfCategoryDescription, - getPdfCategoryLabel, - getPdfFindingMessage, - getPdfSeverityLabel, - getPdfSkipReasonLabel, -} from "./messages"; +import { getPdfCategoryDescription, getPdfCategoryLabel, getPdfFindingMessage, getPdfSeverityLabel } from "./messages"; const CATEGORIES = ["parseability", "layout", "sections", "contact", "dates", "content"] as const; const SEVERITIES = ["blocker", "warning", "tip"] as const; -const SKIP_REASONS = [ - "no-text", - "no-operators", - "not-english", - "not-applicable", - "encrypted", - "insufficient-data", -] as const; beforeAll(() => { i18n.load("en", {}); @@ -60,8 +46,4 @@ describe("labels", () => { for (const severity of SEVERITIES) expect(getPdfSeverityLabel(severity).length).toBeGreaterThan(0); }); - - it("explains every reason a check can be skipped", () => { - for (const reason of SKIP_REASONS) expect(getPdfSkipReasonLabel(reason).length).toBeGreaterThan(0); - }); }); diff --git a/apps/web/src/features/ats-checker/messages.ts b/apps/web/src/features/ats-checker/messages.ts index 29c395d31..093c2267a 100644 --- a/apps/web/src/features/ats-checker/messages.ts +++ b/apps/web/src/features/ats-checker/messages.ts @@ -1,4 +1,4 @@ -import type { PdfCategory, PdfRuleCode, PdfSeverity, PdfSkipReason } from "@reactive-resume/resume/ats-pdf"; +import type { PdfCategory, PdfRuleCode, PdfSeverity } from "@reactive-resume/resume/ats-pdf"; import { t } from "@lingui/core/macro"; import { match } from "ts-pattern"; @@ -370,14 +370,3 @@ export function getPdfSeverityLabel(severity: PdfSeverity): string { .with("tip", () => t`Tip`) .exhaustive(); } - -export function getPdfSkipReasonLabel(reason: PdfSkipReason): string { - return match(reason) - .with("no-text", () => t`No text could be read from this file.`) - .with("no-operators", () => t`This file's page contents could not be inspected in time.`) - .with("not-english", () => t`This check only runs on resumes written in English.`) - .with("not-applicable", () => t`This check does not apply to this file.`) - .with("encrypted", () => t`This file is encrypted.`) - .with("insufficient-data", () => t`There was not enough information to run this check.`) - .exhaustive(); -} diff --git a/apps/web/src/features/command-palette/store.test.ts b/apps/web/src/features/command-palette/store.test.ts index c9c895da6..eeaf16b57 100644 --- a/apps/web/src/features/command-palette/store.test.ts +++ b/apps/web/src/features/command-palette/store.test.ts @@ -67,16 +67,6 @@ describe("useCommandPaletteStore", () => { expect(useCommandPaletteStore.getState().peekPage()).toBe("b"); }); - it("popPage removes the last page and clears search", () => { - useCommandPaletteStore.setState({ pages: ["a", "b"], search: "x" }); - - useCommandPaletteStore.getState().popPage(); - - const state = useCommandPaletteStore.getState(); - expect(state.pages).toEqual(["a"]); - expect(state.search).toBe(""); - }); - it("reset clears every state field", () => { useCommandPaletteStore.setState({ open: true, search: "x", pages: ["a"] }); diff --git a/apps/web/src/features/command-palette/store.ts b/apps/web/src/features/command-palette/store.ts index cd68aeb15..e2182c647 100644 --- a/apps/web/src/features/command-palette/store.ts +++ b/apps/web/src/features/command-palette/store.ts @@ -11,7 +11,6 @@ type CommandPaletteActions = { setSearch: (search: string) => void; pushPage: (page: string) => void; peekPage: () => string | undefined; - popPage: () => void; reset: () => void; goBack: () => void; }; @@ -38,8 +37,6 @@ export const useCommandPaletteStore = create((set, get) => pushPage: (page) => set((state) => ({ pages: [...state.pages, page], search: "" })), - popPage: () => set((state) => ({ pages: state.pages.slice(0, -1), search: "" })), - reset: () => set(initialState), goBack: () => { diff --git a/apps/web/src/features/resume/builder/section-recovery.tsx b/apps/web/src/features/resume/builder/section-recovery.tsx index a25b9bb91..b9b3ed5b6 100644 --- a/apps/web/src/features/resume/builder/section-recovery.tsx +++ b/apps/web/src/features/resume/builder/section-recovery.tsx @@ -10,7 +10,8 @@ import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@r import { Button } from "@reactive-resume/ui/components/button"; import { Separator } from "@reactive-resume/ui/components/separator"; import { useCurrentBuilderResumeSelector, useUpdateResumeData } from "@/features/resume/builder/draft"; -import { getSectionTitle, leftSidebarSections } from "@/libs/resume/section"; +import { leftSidebarSections } from "@/libs/resume/section"; +import { resolveLayoutSectionTitle } from "@/routes/builder/$resumeId/-sidebar/right/sections/layout/title"; export function getVisibleLeftSidebarSections(data: ResumeData): LeftSidebarSection[] { const hiddenSectionIds = new Set( @@ -80,18 +81,6 @@ export function SectionEditorList({ renderSection }: SectionEditorListProps) { ); } -function getRecoverySectionTitle(data: ResumeData, sectionId: string): string { - if (sectionId === "summary") return data.summary.title || getSectionTitle("summary"); - - if (Object.hasOwn(data.sections, sectionId)) { - const type = sectionId as SectionType; - return data.sections[type].title || getSectionTitle(type); - } - - const customSection = data.customSections.find((section) => section.id === sectionId); - return customSection?.title || (customSection ? getSectionTitle(customSection.type) : sectionId); -} - export function SectionRecovery() { const data = useCurrentBuilderResumeSelector((resume) => resume.data); const updateResumeData = useUpdateResumeData(); @@ -132,7 +121,7 @@ export function SectionRecovery() {
    {hiddenSections.map(({ sectionId }) => { - const title = getRecoverySectionTitle(data, sectionId); + const title = resolveLayoutSectionTitle(data, sectionId); return (
  • 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)}]`, + const attributes = [ + ...new Set(["id", "role", ...Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ attributes }) => attributes)]), + ]; + const roles = [...new Set(Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ roles }) => roles))]; + return [ + ...new Set([ + ...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()", - ]); + ...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)); + return [...new Set([...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" { @@ -110,12 +106,14 @@ function completionLabels(source: string, position: number, metadata: SemanticCs 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), - ]); + return [ + ...new Set([ + ...(definition?.values ?? []), + ...(definition?.units ?? []), + ...userVariables(source), + ...Object.keys(SYSTEM_VARIABLE_REGISTRY_V1), + ]), + ]; } } } diff --git a/apps/web/src/libs/root-context.ts b/apps/web/src/libs/root-context.ts new file mode 100644 index 000000000..ab6a7c6db --- /dev/null +++ b/apps/web/src/libs/root-context.ts @@ -0,0 +1,17 @@ +import { getSession } from "./auth/session"; +import { getLocale, loadLocale } from "./locale"; +import { client } from "./orpc/client"; +import { getTheme } from "./theme"; + +export async function loadRootContext() { + const [theme, locale, session, flags] = await Promise.all([ + getTheme(), + getLocale(), + getSession(), + client.flags.get(), + ]); + + await loadLocale(locale); + + return { theme, locale, session, flags }; +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index e56199166..78d4c35d6 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -2,24 +2,15 @@ import { createRouter } from "@tanstack/react-router"; import { ErrorScreen } from "./components/layout/error-screen"; import { LoadingScreen } from "./components/layout/loading-screen"; import { NotFoundScreen } from "./components/layout/not-found-screen"; -import { getSession } from "./libs/auth/session"; -import { getLocale, loadLocale } from "./libs/locale"; -import { client, orpc } from "./libs/orpc/client"; +import { orpc } from "./libs/orpc/client"; import { getQueryClient } from "./libs/query/client"; -import { getTheme } from "./libs/theme"; +import { loadRootContext } from "./libs/root-context"; import { routeTree } from "./routeTree.gen"; export const getRouter = async () => { const queryClient = getQueryClient(); - const [theme, locale, session, flags] = await Promise.all([ - getTheme(), - getLocale(), - getSession(), - client.flags.get(), - ]); - - await loadLocale(locale); + const { theme, locale, session, flags } = await loadRootContext(); const router = createRouter({ routeTree, diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 3f65012ac..f5ee26d01 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -26,10 +26,8 @@ import { CommandPalette } from "@/features/command-palette"; import { ThemeProvider } from "@/features/theme/provider"; import { ConfirmDialogProvider } from "@/hooks/use-confirm"; import { PromptDialogProvider } from "@/hooks/use-prompt"; -import { getSession } from "@/libs/auth/session"; -import { getLocale, isRTL, loadLocale } from "@/libs/locale"; -import { client } from "@/libs/orpc/client"; -import { getTheme } from "@/libs/theme"; +import { isRTL } from "@/libs/locale"; +import { loadRootContext } from "@/libs/root-context"; type RouterContext = { theme: Theme; @@ -90,18 +88,7 @@ export const Route = createRootRouteWithContext()({ ], }; }, - beforeLoad: async () => { - const [theme, locale, session, flags] = await Promise.all([ - getTheme(), - getLocale(), - getSession(), - client.flags.get(), - ]); - - await loadLocale(locale); - - return { theme, locale, session, flags }; - }, + beforeLoad: async () => loadRootContext(), }); function RootComponent() { diff --git a/apps/web/src/routes/agent/$threadId.tsx b/apps/web/src/routes/agent/$threadId.tsx index 1c2dbf851..28937bf0c 100644 --- a/apps/web/src/routes/agent/$threadId.tsx +++ b/apps/web/src/routes/agent/$threadId.tsx @@ -3,7 +3,8 @@ import { Trans } from "@lingui/react/macro"; import { ChatCircleDotsIcon, SidebarSimpleIcon, SquaresFourIcon } from "@phosphor-icons/react"; import { useQuery } from "@tanstack/react-query"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; -import { useCallback, useRef, useState, useSyncExternalStore } from "react"; +import { useCallback, useRef, useState } from "react"; +import { useMediaQuery } from "usehooks-ts"; import { Button } from "@reactive-resume/ui/components/button"; import { ResizableGroup, ResizablePanel, ResizableSeparator } from "@reactive-resume/ui/components/resizable"; import { Tabs, TabsList, TabsTrigger } from "@reactive-resume/ui/components/tabs"; @@ -20,24 +21,10 @@ export const Route = createFileRoute("/agent/$threadId")({ }); // Matches Tailwind's `lg` breakpoint, which this route's two layouts switch on. -const DESKTOP_QUERY = "(min-width: 1024px)"; - // Exactly one layout may mount: each AgentChat owns its own useChat state and stream // reconnection, so CSS-hiding a second instance would double-connect streams and expose // stale pending approval controls after a resize. -function useIsDesktopLayout() { - const [mediaQueryList] = useState(() => (typeof window === "undefined" ? null : window.matchMedia(DESKTOP_QUERY))); - - return useSyncExternalStore( - (onStoreChange) => { - if (!mediaQueryList) return () => {}; - mediaQueryList.addEventListener("change", onStoreChange); - return () => mediaQueryList.removeEventListener("change", onStoreChange); - }, - () => mediaQueryList?.matches ?? false, - () => false, - ); -} +const DESKTOP_QUERY = "(min-width: 1024px)"; function RouteComponent() { const { threadId } = Route.useParams(); @@ -47,7 +34,7 @@ function RouteComponent() { const resumePanelRef = useRef(null); const [isThreadsCollapsed, setIsThreadsCollapsed] = useState(false); const [isResumeCollapsed, setIsResumeCollapsed] = useState(false); - const isDesktopLayout = useIsDesktopLayout(); + const isDesktopLayout = useMediaQuery(DESKTOP_QUERY, { initializeWithValue: false }); const { data, isLoading, error } = useQuery(orpc.agent.threads.get.queryOptions({ input: { id: threadId } })); useAgentResumeUpdateSubscription({ resumeId: data?.resume?.id, threadId }); diff --git a/chatgpt-app-submission.json b/chatgpt-app-submission.json deleted file mode 100644 index 8f61838e4..000000000 --- a/chatgpt-app-submission.json +++ /dev/null @@ -1,464 +0,0 @@ -{ - "$schema": "https://developers.openai.com/apps-sdk/schemas/chatgpt-app-submission.v1.json", - "schema_version": 1, - "app_info": { - "display_name": "Reactive Resume", - "subtitle": "Build resumes, track jobs", - "description": "Reactive Resume helps users create, import, edit, duplicate, organize, share, and export resumes, and track job applications with contacts, notes, timelines, follow-ups, and PDF attachments. Users can request AI-assisted job extraction, match assessments, tailored resume summaries, and message drafts through their configured AI provider. AI features send job context and, where applicable, full resume data to that provider. Cover-letter drafts are saved; recruiter follow-up drafts are not sent. Uploaded application PDFs are accessible to anyone with their file URL.", - "category": "PRODUCTIVITY" - }, - "tools": { - "list_resumes": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves the authenticated user’s resume IDs and metadata, with optional tag filters and sorting, without modifying resumes.", - "open_world_justification": "Reads account records without publishing content or changing external systems.", - "destructive_justification": "Does not remove or replace resume data." - } - }, - "list_resume_tags": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Computes a sorted list of distinct tags from the user’s resumes without modifying them.", - "open_world_justification": "Reads only the user’s stored resume tags.", - "destructive_justification": "Does not delete or replace tags or resumes." - } - }, - "read_resume": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves the full data of a resume owned by the authenticated user without modifying it.", - "open_world_justification": "Returns stored resume data without publishing it or sending it to another service.", - "destructive_justification": "Does not delete or overwrite resume content." - } - }, - "download_resume_pdf": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Computes a signed, ten-minute PDF download URL for an owned resume or visible cover letter without saving a file or changing the resume.", - "open_world_justification": "Returns a temporary download URL without changing public resume visibility or invoking a third-party service.", - "destructive_justification": "Does not delete or overwrite the resume or cover letter." - } - }, - "create_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Creates a new private resume in the user’s account, optionally populated with sample content.", - "open_world_justification": "Creates an account-owned private resume without publishing it.", - "destructive_justification": "Creates a new record and rejects a conflicting slug rather than overwriting another resume." - } - }, - "import_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Validates supplied resume JSON and saves a new private resume with an automatically assigned name and slug.", - "open_world_justification": "Imports data into the user’s account without publishing it or sending it to another service.", - "destructive_justification": "Creates a separate resume rather than replacing an existing one." - } - }, - "duplicate_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Creates a new private resume containing a copy of an owned resume’s data.", - "open_world_justification": "Saves the copy privately even when the original resume is public.", - "destructive_justification": "Leaves the source resume intact and rejects conflicting slugs." - } - }, - "apply_resume_patch": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Applies JSON Patch operations and saves the resulting resume data and a version snapshot.", - "open_world_justification": "Editing a public resume changes the content visible through its existing public link.", - "destructive_justification": "Can remove or overwrite existing resume content, with ownership, lock, patch validation, and bounded version-history safeguards." - } - }, - "update_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Updates an owned resume’s name, slug, tags, or public visibility and returns its share URL.", - "open_world_justification": "Can publish or unpublish a resume and change its public URL.", - "destructive_justification": "Can overwrite metadata, replace tags, or revoke anonymous access, while refusing changes to locked resumes." - } - }, - "delete_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Deletes the selected resume and attempts to remove its associated stored files.", - "open_world_justification": "Deleting a published resume removes its public page and can remove publicly accessible associated files.", - "destructive_justification": "Permanently deletes the resume after checking ownership and refusing deletion of locked resumes." - } - }, - "lock_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Sets an owned resume’s lock flag to prevent edits and deletion.", - "open_world_justification": "Changes an account editing control without publishing content or calling external services.", - "destructive_justification": "Preserves resume content and can be reversed with the unlock tool." - } - }, - "unlock_resume": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Clears an owned resume’s lock flag to allow subsequent edits and deletion.", - "open_world_justification": "Changes an account editing control without publishing content or calling external services.", - "destructive_justification": "Does not itself delete or overwrite resume content and can be reversed by locking again." - } - }, - "get_resume_statistics": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves view and download counts and timestamps for an owned resume without incrementing them.", - "open_world_justification": "Reads stored statistics without changing public content or external systems.", - "destructive_justification": "Does not remove or replace statistics or resume data." - } - }, - "list_applications": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves owned application records, including contacts, notes, document URLs, and timeline, with optional filters.", - "open_world_justification": "Reads account records without contacting employers or publishing applications.", - "destructive_justification": "Does not delete or overwrite application records." - } - }, - "read_application": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves one owned application with contacts, document URLs, follow-up details, and timeline.", - "open_world_justification": "Reads the user’s application record without contacting external systems.", - "destructive_justification": "Does not delete or replace application data." - } - }, - "list_application_tags": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Retrieves distinct tags used by the user’s job applications without changing them.", - "open_world_justification": "Reads stored account tags without external interactions.", - "destructive_justification": "Does not remove or overwrite tags." - } - }, - "get_application_stats": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Computes counts by stage and source for the user’s non-archived applications without modifying records.", - "open_world_justification": "Reads private tracker data without publishing it or contacting employers.", - "destructive_justification": "Does not delete or replace application data." - } - }, - "create_application": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Creates a tracked job application and an initial stage timeline entry in the user’s account.", - "open_world_justification": "Creates a private tracking record without submitting an application to an employer.", - "destructive_justification": "Creates a new application rather than replacing an existing record." - } - }, - "update_application": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Updates application fields and appends a stage timeline event when the stage changes.", - "open_world_justification": "Updates the private tracker without submitting forms or contacting employers.", - "destructive_justification": "Can overwrite existing fields, contact lists, tags, and linked document or resume references after ownership checks." - } - }, - "add_application_note": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Appends a dated free-text note to an owned application’s timeline.", - "open_world_justification": "Stores the note in the private tracker without sending it to anyone.", - "destructive_justification": "Appends a new note without overwriting existing timeline entries." - } - }, - "update_application_timeline_entry": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Updates an owned application’s timeline date or note text and recalculates its applied date.", - "open_world_justification": "Changes private timeline data without contacting employers or publishing it.", - "destructive_justification": "Overwrites an existing entry’s date or note text while validating stage ordering and disallowing edits to derived stage text." - } - }, - "delete_application_timeline_entry": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Removes a timeline entry from an owned application and recalculates its applied date.", - "open_world_justification": "Changes only the private application timeline.", - "destructive_justification": "Deletes a note or older stage entry while preventing deletion of the current stage entry." - } - }, - "delete_application": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Deletes an owned job application and attempts to delete its owned uploaded documents when no remaining application references them.", - "open_world_justification": "Can remove uploaded documents accessible through public file URLs.", - "destructive_justification": "Permanently deletes the application and unreferenced owned documents after checking ownership." - } - }, - "bulk_update_applications": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Updates stages, archive state, or tags across owned applications and records stage changes.", - "open_world_justification": "Changes private tracker records without contacting employers or publishing them.", - "destructive_justification": "Can overwrite existing stage and archive values for multiple applications, with updates restricted to owned records." - } - }, - "bulk_delete_applications": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Deletes multiple owned applications and attempts to clean up their unreferenced owned uploaded documents.", - "open_world_justification": "Can remove attached documents that were accessible through public file URLs.", - "destructive_justification": "Permanently deletes matching owned applications and their unreferenced owned documents." - } - }, - "import_applications": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": false, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Creates up to 500 private application records and their initial stage timeline entries from supplied rows.", - "open_world_justification": "Imports tracking records without submitting applications or contacting employers.", - "destructive_justification": "Creates new records rather than overwriting existing applications." - } - }, - "attach_application_document": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Uploads a PDF and replaces the selected document attachment on an owned application.", - "open_world_justification": "Creates a file URL that anyone with the link can download without signing in.", - "destructive_justification": "Replaces the existing attachment and deletes its previous owned file when no other application references it." - } - }, - "remove_application_document": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Clears the selected attachment fields and attempts to delete the unreferenced owned uploaded file.", - "open_world_justification": "Can remove a PDF accessible through a public file URL.", - "destructive_justification": "Removes the attachment and deletes its owned file when no remaining application references it." - } - }, - "autofill_application_from_job": { - "annotations": { - "readOnlyHint": true, - "openWorldHint": true, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Computes suggested company, role, location, and salary fields from pasted job text without saving an application.", - "open_world_justification": "Sends the pasted job text to the user’s configured AI provider for generation.", - "destructive_justification": "Returns suggestions without deleting or overwriting stored records." - } - }, - "score_application_match": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Generates a resume-to-job match assessment and saves its score and AI metadata on the application.", - "open_world_justification": "Sends the full linked resume and job description to the user’s configured AI provider.", - "destructive_justification": "Overwrites the application’s existing match score and AI metadata after checking ownership and required context." - } - }, - "tailor_resume_for_application": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": true - }, - "justifications": { - "read_only_justification": "Creates a private resume copy with a rewritten summary, replaces the application’s resume link, and appends a timeline note.", - "open_world_justification": "Sends the full linked resume and job context to the user’s configured AI provider.", - "destructive_justification": "Overwrites the application’s linked resume reference while preserving the original resume." - } - }, - "draft_application_message": { - "annotations": { - "readOnlyHint": false, - "openWorldHint": true, - "destructiveHint": false - }, - "justifications": { - "read_only_justification": "Generates message text and, in cover-letter mode, saves a new cover letter linked to the application.", - "open_world_justification": "Sends application context and the full linked resume when available to the user’s configured AI provider without sending the resulting message to a recruiter.", - "destructive_justification": "Creates a new cover letter or returns a follow-up draft without deleting or overwriting existing documents." - } - } - }, - "test_cases": [ - { - "description": "Create and edit a resume, duplicate it, and verify that locking prevents a further edit; use an authenticated review account with unused slugs.", - "user_prompt": "Create a private resume named Submission Review with slug submission-review and tag review, using sample data. Read it and change its headline to Software Engineer. Duplicate it as Submission Review Copy with slug submission-review-copy. Lock the original, then try changing its headline to Product Engineer and report whether the edit was rejected.", - "file_attachment_urls": null, - "tools_triggered": "create_resume, read_resume, apply_resume_patch, duplicate_resume, lock_resume", - "expected_output": "Creates two private resumes, changes the original headline before duplication, and rejects the attempted edit after locking without changing the locked resume.", - "expected_output_url": null - }, - { - "description": "Import synthetic resume data, publish the imported resume, and export PDFs; the source resume has no visible cover letter.", - "user_prompt": "Find my Submission Review Copy resume, read its data, and import that data as a new resume. Rename the imported resume Submission Review Public, set its slug to submission-review-public, and make it public. Give me its share link and a resume PDF download link, then try exporting its cover letter.", - "file_attachment_urls": null, - "tools_triggered": "list_resumes, read_resume, import_resume, update_resume, download_resume_pdf", - "expected_output": "Creates a separate resume, explicitly reports that it is public, returns its share link and a PDF link expiring in ten minutes, and reports that no visible cover letter is available without inventing a document.", - "expected_output_url": null - }, - { - "description": "Track a synthetic application, update its stage, append a note, and inspect aggregate results; start with no other applications.", - "user_prompt": "Create a saved application for Example Company, Software Engineer, tagged review. Find and read it, move it to interview, and add a timeline note saying Technical interview scheduled. Show my application tags and counts by stage.", - "file_attachment_urls": null, - "tools_triggered": "create_application, list_applications, read_application, update_application, add_application_note, list_application_tags, get_application_stats", - "expected_output": "Creates a private tracker record, records the stage change and note, returns the review tag and interview count, and does not submit an application or contact an employer.", - "expected_output_url": null - }, - { - "description": "Replace and remove a synthetic PDF attachment; reviewer supplies two valid PDFs under 10MB and one over 10MB, and no other application references the originals.", - "user_prompt": "For my Example Company application, attach the first synthetic resume PDF I provide. I understand anyone with its file link can download it. Replace it with the second PDF, try replacing that with my PDF larger than 10MB, then remove the accepted attachment.", - "file_attachment_urls": null, - "tools_triggered": "list_applications, attach_application_document, remove_application_document", - "expected_output": "Discloses public-link access, attaches and replaces valid PDFs, removes the unreferenced old file, rejects the oversized replacement without changing the accepted attachment, then clears and deletes the final unreferenced file.", - "expected_output_url": null - }, - { - "description": "Exercise AI assistance with an enabled, tested default provider and a synthetic resume; use an application with job text and linked resume.", - "user_prompt": "Extract fields from this posting: Example Company seeks a Software Engineer in Berlin to build TypeScript web applications. Create a saved application from those fields and keep that posting as its job description. Link my Submission Review Copy resume, score the match, and make a tailored resume copy for this job. Draft a cover letter and a recruiter follow-up. I authorize sending the job and full resume data to my configured AI provider, replacing this application’s resume link with the tailored copy, and saving the cover letter; do not send either message.", - "file_attachment_urls": null, - "tools_triggered": "autofill_application_from_job, create_application, list_resumes, update_application, score_application_match, tailor_resume_for_application, draft_application_message", - "expected_output": "Extracts stated fields without inventing salary, saves the application and match assessment, creates a private copy with a rewritten summary and replaces the resume link, saves a new cover letter, and returns an unsent follow-up draft.", - "expected_output_url": null - } - ], - "negative_test_cases": [ - { - "description": "Do not invoke the app to search job boards.", - "user_prompt": "Find software engineering vacancies posted today across job boards.", - "file_attachment_urls": null, - "tools_triggered": null, - "expected_output": "The app should not be invoked because it does not search job boards or fetch vacancies.", - "expected_output_url": null - }, - { - "description": "Do not invoke the app to send a message or submit an application.", - "user_prompt": "Email this finished cover letter to the recruiter and submit my application on the employer’s website; no drafting or tracking changes are needed.", - "file_attachment_urls": null, - "tools_triggered": null, - "expected_output": "The app should not be invoked because it cannot send recruiter messages or submit external application forms.", - "expected_output_url": null - }, - { - "description": "Do not invoke the app for general interview practice without account data.", - "user_prompt": "Give me five common behavioral interview questions; do not use my saved resumes or applications.", - "file_attachment_urls": null, - "tools_triggered": null, - "expected_output": "The app should not be invoked because general interview practice does not require its account tools.", - "expected_output_url": null - } - ] -} diff --git a/knip.json b/knip.json index b707a0efa..b6c9722fa 100644 --- a/knip.json +++ b/knip.json @@ -3,7 +3,7 @@ "tags": ["-lintignore"], "workspaces": { ".": { - "ignoreFiles": [".ncurc.cjs", ".design-sync/**"] + "ignoreFiles": [".design-sync/**"] }, "apps/web": { "entry": ["lingui.config.ts", "vite.config.ts", "vitest.config.ts"], diff --git a/package.json b/package.json index 913ac2304..2f4fab1c1 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,6 @@ ], "scripts": { "build": "pnpm pdf:translations && turbo run build", - "doctor": "turbo run doctor", "check": "pnpm pdf:translations && 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", @@ -41,7 +40,6 @@ "test:ci": "turbo run test:ci --concurrency=4", "test:e2e": "playwright test", "test:e2e:ui": "playwright test --ui", - "test:e2e:ci": "playwright test", "test:agent": "turbo run test:agent --concurrency=4", "pdf:translations": "pnpm --filter @reactive-resume/tooling pdf:translations" }, @@ -51,7 +49,6 @@ "@commitlint/config-conventional": "^21.2.2", "@playwright/test": "^1.63.0", "@reactive-resume/config": "workspace:*", - "@testing-library/dom": "^10.4.2", "@testing-library/jest-dom": "^7.0.1", "@testing-library/react": "^16.3.3", "@testing-library/user-event": "^14.6.7", diff --git a/packages/ai/package.json b/packages/ai/package.json index 9ec371a49..d4278e996 100644 --- a/packages/ai/package.json +++ b/packages/ai/package.json @@ -23,7 +23,6 @@ "@reactive-resume/resume": "workspace:*", "@reactive-resume/schema": "workspace:*", "@reactive-resume/utils": "workspace:*", - "deepmerge-ts": "^8.0.2", "jsonrepair": "^3.15.0", "zod": "^4.6.5" }, diff --git a/packages/ai/src/resume/sanitize.ts b/packages/ai/src/resume/sanitize.ts index 8ba712edc..182884e55 100644 --- a/packages/ai/src/resume/sanitize.ts +++ b/packages/ai/src/resume/sanitize.ts @@ -1,5 +1,4 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import { deepmergeCustom } from "deepmerge-ts"; import { jsonrepair } from "jsonrepair"; import { flattenError, ZodError } from "zod"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; @@ -38,10 +37,17 @@ type ResumeSanitizationResult = { diagnostics: ResumeSanitizationDiagnostics; }; -const mergeDefaultsDeep = deepmergeCustom({ - filterValues: (values) => values.filter((value) => value !== undefined && value !== null), - mergeArrays: false, -}); +function mergeDefaultsDeep(target: Record, source: Record): Record { + const output: Record = { ...target }; + + for (const [key, value] of Object.entries(source)) { + if (value === undefined || value === null) continue; + const current = target[key]; + output[key] = isObject(current) && isObject(value) ? mergeDefaultsDeep(current, value) : value; + } + + return output; +} const sectionRequiredFieldMap = { profiles: "network", diff --git a/packages/api/package.json b/packages/api/package.json index 0b8c4e145..ccb7f2618 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -55,7 +55,6 @@ "ioredis": "^6.0.0", "jsonrepair": "^3.15.0", "ollama-ai-provider-v2": "^4.0.1", - "react": "^19.3.0", "resumable-stream": "^2.2.12", "sanitize-html": "^2.17.7", "sharp": "^0.35.4", diff --git a/packages/api/src/features/agent/context.test.ts b/packages/api/src/features/agent/context.test.ts index e99038cc7..d3a127b67 100644 --- a/packages/api/src/features/agent/context.test.ts +++ b/packages/api/src/features/agent/context.test.ts @@ -1,6 +1,6 @@ import type { ModelMessage } from "ai"; import { describe, expect, it } from "vitest"; -import { estimateTokenCount, pruneAgentModelContext } from "./context"; +import { estimateTokens, pruneAgentModelContext } from "./context"; const BIG_RESUME = { basics: { name: "Alice" }, sections: { summary: { content: "x".repeat(2_000) } } }; @@ -265,11 +265,11 @@ describe("pruneAgentModelContext — tier 3 (turn dropping)", () => { }); }); -describe("estimateTokenCount", () => { +describe("estimateTokens", () => { it("estimates natural text at roughly one token per word", () => { - expect(estimateTokenCount("old question ".repeat(50))).toBeGreaterThanOrEqual(90); - expect(estimateTokenCount("old question ".repeat(50))).toBeLessThan(120); - expect(estimateTokenCount({ a: 1 })).toBeGreaterThan(0); + expect(estimateTokens("old question ".repeat(50))).toBeGreaterThanOrEqual(90); + expect(estimateTokens("old question ".repeat(50))).toBeLessThan(120); + expect(estimateTokens({ a: 1 })).toBeGreaterThan(0); }); it("treats binary attachment data as opaque bytes instead of serializing it", () => { @@ -277,7 +277,7 @@ describe("estimateTokenCount", () => { const message = { role: "user", content: [{ type: "image", image: bytes, mediaType: "image/png" }] }; const started = performance.now(); - const estimate = estimateTokenCount(message); + const estimate = estimateTokens(message); const elapsedMs = performance.now() - started; // ~bytes/4 tokens, computed without expanding each byte into JSON. diff --git a/packages/api/src/features/agent/context.ts b/packages/api/src/features/agent/context.ts index 619c0a336..5eea8e108 100644 --- a/packages/api/src/features/agent/context.ts +++ b/packages/api/src/features/agent/context.ts @@ -25,7 +25,7 @@ function isBinary(value: unknown): value is ArrayBufferView | ArrayBuffer { // so serialization-based estimation would expand binary into per-byte entries (hundreds of MB of // JSON for one allowed 25MB attachment). String leaves go through tokenx; binary counts as // opaque bytes; structural syntax gets a small flat cost. -function estimateTokens(value: unknown): number { +export function estimateTokens(value: unknown): number { if (value === null || value === undefined) return 1; if (typeof value === "string") return estimateTextTokenCount(value); if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return 2; @@ -43,10 +43,6 @@ function estimateTokens(value: unknown): number { return 2; } -export function estimateTokenCount(value: unknown): number { - return estimateTokens(value); -} - function contentParts(message: ModelMessage): LoosePart[] { return Array.isArray(message.content) ? (message.content as unknown as LoosePart[]) : []; } @@ -116,18 +112,10 @@ function hasApprovalContent(message: ModelMessage): boolean { ); } -function lastIndexWhere(items: T[], predicate: (item: T) => boolean): number { - for (let index = items.length - 1; index >= 0; index--) { - // biome-ignore lint/style/noNonNullAssertion: index is in range - if (predicate(items[index]!)) return index; - } - return -1; -} - // Tier 2: collapse the oldest tool call/result pairs into stubs — always both sides of a pair // (several BYOK gateways reject unpaired tool messages), never the protected regions. function collapseOldestToolPairs(messages: ModelMessage[], budget: number): ModelMessage[] { - const lastAssistantIndex = lastIndexWhere(messages, (message) => message.role === "assistant"); + const lastAssistantIndex = messages.findLastIndex((message) => message.role === "assistant"); const survivingSnapshotCallIds = new Set(); // The surviving snapshot (last one, by Tier 0) must keep its full pair. @@ -152,7 +140,7 @@ function collapseOldestToolPairs(messages: ModelMessage[], budget: number): Mode let changed = false; for (const [index, message] of next.entries()) { - if (estimateTokenCount(next) <= budget) break; + if (estimateTokens(next) <= budget) break; if (index === lastAssistantIndex || hasApprovalContent(message)) continue; if (message.role !== "assistant" && message.role !== "tool") continue; @@ -194,7 +182,7 @@ function dropOldestTurns(messages: ModelMessage[], budget: number): ModelMessage const turns = turnStarts.map((start, turnIndex) => messages.slice(start, turnStarts[turnIndex + 1])); let dropCount = 0; - while (dropCount < turns.length - 2 && estimateTokenCount(turns.slice(dropCount).flat()) > budget) { + while (dropCount < turns.length - 2 && estimateTokens(turns.slice(dropCount).flat()) > budget) { dropCount += 1; } @@ -206,15 +194,15 @@ export function pruneAgentModelContext( budget: number = AGENT_CONTEXT_TOKEN_BUDGET, ): ModelMessage[] { let current = supersedeStaleSnapshots(messages); - if (estimateTokenCount(current) <= budget) return current; + if (estimateTokens(current) <= budget) return current; // Tier 1: strip reasoning from all but the last assistant message. const withoutReasoning = pruneMessages({ messages: current, reasoning: "before-last-message" }); - if (estimateTokenCount(withoutReasoning) < estimateTokenCount(current)) current = withoutReasoning; - if (estimateTokenCount(current) <= budget) return current; + if (estimateTokens(withoutReasoning) < estimateTokens(current)) current = withoutReasoning; + if (estimateTokens(current) <= budget) return current; current = collapseOldestToolPairs(current, budget); - if (estimateTokenCount(current) <= budget) return current; + if (estimateTokens(current) <= budget) return current; return dropOldestTurns(current, budget); } diff --git a/packages/api/src/features/agent/messages-persistence.ts b/packages/api/src/features/agent/messages-persistence.ts index 05666eb1e..d0db364fa 100644 --- a/packages/api/src/features/agent/messages-persistence.ts +++ b/packages/api/src/features/agent/messages-persistence.ts @@ -129,7 +129,7 @@ export function withAccumulatedUsageMetadata(previous: UIMessage, next: UIMessag return { ...next, metadata: { ...nextMetadata, usage } } as UIMessage; } -async function nextMessageSequence(threadId: string, database: AgentMessagesDb) { +export async function nextMessageSequence(threadId: string, database: AgentMessagesDb) { const [row] = await database .select({ maxSequence: max(schema.agentMessage.sequence) }) .from(schema.agentMessage) @@ -138,7 +138,7 @@ async function nextMessageSequence(threadId: string, database: AgentMessagesDb) return (row?.maxSequence ?? -1) + 1; } -async function touchThread(input: { threadId: string; userId: string }, database: AgentMessagesDb) { +export async function touchThread(input: { threadId: string; userId: string }, database: AgentMessagesDb) { await database .update(schema.agentThread) .set({ lastMessageAt: new Date() }) diff --git a/packages/api/src/features/agent/service.test.ts b/packages/api/src/features/agent/service.test.ts index 96b1e2348..0d86377f6 100644 --- a/packages/api/src/features/agent/service.test.ts +++ b/packages/api/src/features/agent/service.test.ts @@ -16,6 +16,8 @@ const messagesPersistenceMock = { insertDraftAssistantMessage: vi.fn(), upsertAssistantUiMessage: vi.fn(), deleteDraftIfEmpty: vi.fn(), + nextMessageSequence: vi.fn(async () => 1), + touchThread: vi.fn(), withAccumulatedUsageMetadata: vi.fn((_previous: unknown, next: unknown) => next), }; const storageServiceMock = { @@ -167,6 +169,7 @@ beforeEach(() => { messagesPersistenceMock.upsertAssistantUiMessage.mockResolvedValue({ rowId: "draft-row-1" }); messagesPersistenceMock.deleteDraftIfEmpty.mockResolvedValue(undefined); messagesPersistenceMock.withAccumulatedUsageMetadata.mockImplementation((_previous: unknown, next: unknown) => next); + messagesPersistenceMock.nextMessageSequence.mockResolvedValue(1); for (const mock of Object.values(storageServiceMock)) mock.mockReset(); for (const mock of Object.values(resumeServiceMock)) mock.mockReset(); for (const mock of Object.values(aiProvidersServiceMock)) mock.mockReset(); @@ -367,7 +370,6 @@ describe("agentService.messages.send", () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); @@ -449,7 +451,6 @@ describe("agentService.messages.send", () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); @@ -573,7 +574,6 @@ describe("agentService.messages.send", () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); dbMock.insert.mockReturnValue({ @@ -662,7 +662,6 @@ describe("agentService.messages.send", () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) .mockImplementationOnce(() => selectWhereResult([attachment])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])); @@ -1112,7 +1111,6 @@ describe("agentService.messages.send", () => { dbMock.select .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: 2 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 4 }])) .mockImplementationOnce(() => selectOrderByResult([firstUserMessage, unresolvedAssistantMessage, legacyAnswerMessage, retryMessage]), @@ -1320,7 +1318,6 @@ describe("agentService.messages.stop", () => { dbMock.select // send(): getThread, next sequence, message count, thread messages .mockImplementationOnce(() => selectLimitResult([activeThread])) - .mockImplementationOnce(() => selectWhereResult([{ maxSequence: -1 }])) .mockImplementationOnce(() => selectWhereResult([{ total: 1 }])) .mockImplementationOnce(() => selectOrderByResult([persistedMessage])) // stop(): getThread now reports the active run registered by send() (generateId() -> "test-id") diff --git a/packages/api/src/features/agent/service.ts b/packages/api/src/features/agent/service.ts index 755e6bb8c..fd1566e12 100644 --- a/packages/api/src/features/agent/service.ts +++ b/packages/api/src/features/agent/service.ts @@ -14,7 +14,7 @@ import { ToolLoopAgent, wrapLanguageModel, } from "ai"; -import { and, asc, count, desc, eq, gte, inArray, isNull, max, sql } from "drizzle-orm"; +import { and, asc, count, desc, eq, gte, inArray, isNull, sql } from "drizzle-orm"; import { db } from "@reactive-resume/db/client"; import * as schema from "@reactive-resume/db/schema"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; @@ -30,6 +30,8 @@ import { applyStepToUiMessage, deleteDraftIfEmpty, insertDraftAssistantMessage, + nextMessageSequence, + touchThread, upsertAssistantUiMessage, withAccumulatedUsageMetadata, } from "./messages-persistence"; @@ -102,10 +104,6 @@ type AttachmentModelInput = { data: Uint8Array; }; -function cloneResumeData(data: T): T { - return structuredClone(data); -} - function toThreadSummary(row: AgentThreadRecord & { resumeName?: string | null; providerLabel?: string | null }) { return { id: row.id, @@ -421,7 +419,7 @@ async function createWorkingResume(input: CreateThreadInput) { slug, tags: [...source.tags], locale: input.locale, - data: cloneResumeData(source.data), + data: structuredClone(source.data), }); return { id, source, title: name }; @@ -435,7 +433,7 @@ async function createWorkingResume(input: CreateThreadInput) { slug: buildUniqueAgentDraftSlug(name, existingSlugs), tags: [], locale: input.locale, - data: cloneResumeData(defaultResumeData), + data: structuredClone(defaultResumeData), }); return { id, source: null, title: name }; @@ -459,15 +457,6 @@ async function getThread(input: { id: string; userId: string }) { return thread; } -async function getNextMessageSequence(threadId: string) { - const [row] = await db - .select({ maxSequence: max(schema.agentMessage.sequence) }) - .from(schema.agentMessage) - .where(eq(schema.agentMessage.threadId, threadId)); - - return (row?.maxSequence ?? -1) + 1; -} - async function persistMessage(input: { userId: string; threadId: string; @@ -475,7 +464,7 @@ async function persistMessage(input: { status?: string; sequence?: number; }) { - const sequence = input.sequence ?? (await getNextMessageSequence(input.threadId)); + const sequence = input.sequence ?? (await nextMessageSequence(input.threadId, db)); const [message] = await db .insert(schema.agentMessage) .values({ @@ -488,10 +477,7 @@ async function persistMessage(input: { }) .returning(); - await db - .update(schema.agentThread) - .set({ lastMessageAt: new Date() }) - .where(and(eq(schema.agentThread.id, input.threadId), eq(schema.agentThread.userId, input.userId))); + await touchThread({ threadId: input.threadId, userId: input.userId }, db); return message; } @@ -709,7 +695,7 @@ async function applyResumePatch(input: { } } - const snapshotData = cloneResumeData(before.data); + const snapshotData = structuredClone(before.data); const operations = normalizeAgentResumePatchOperations(before.data, input.operations); const { action, patched } = await db @@ -1230,7 +1216,7 @@ export const agentService = { draftUiMessage = continuation.message; } else { attachmentsForModel = attachments; - const sequence = await getNextMessageSequence(input.threadId); + const sequence = await nextMessageSequence(input.threadId, db); const userMessage = withAttachmentUiParts(input.message, attachments); const persistedUserMessage = await persistMessage({ userId: input.userId, @@ -1535,7 +1521,7 @@ export const agentService = { const restored = await resumeService.patchInTransaction(tx, { id: resumeId, userId: input.userId, - operations: [{ op: "replace", path: "", value: cloneResumeData(snapshotData) }], + operations: [{ op: "replace", path: "", value: structuredClone(snapshotData) }], expectedUpdatedAt: latestAction.appliedUpdatedAt, }); diff --git a/packages/api/src/features/ai/capabilities.ts b/packages/api/src/features/ai/capabilities.ts index 9724e9239..00cb7f4f0 100644 --- a/packages/api/src/features/ai/capabilities.ts +++ b/packages/api/src/features/ai/capabilities.ts @@ -62,16 +62,7 @@ function isDateSnapshotForModel(model: string, modelId: string) { const snapshotPrefix = `${modelId}-`; if (!model.startsWith(snapshotPrefix)) return false; - const suffix = model.slice(snapshotPrefix.length); - const [year, month, day] = suffix.split("-"); - - return ( - suffix.length === "YYYY-MM-DD".length && - year?.length === 4 && - month?.length === 2 && - day?.length === 2 && - [year, month, day].every((part) => /^\d+$/.test(part)) - ); + return /^\d{4}-\d{2}-\d{2}$/.test(model.slice(snapshotPrefix.length)); } export function supportsOpenAIWebSearch(model: string) { diff --git a/packages/api/src/features/ai/router.ts b/packages/api/src/features/ai/router.ts index 61d085d6f..887656c44 100644 --- a/packages/api/src/features/ai/router.ts +++ b/packages/api/src/features/ai/router.ts @@ -1,13 +1,10 @@ import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { UIMessage } from "ai"; import { ORPCError } from "@orpc/client"; -import { type } from "@orpc/server"; import { AISDKError } from "ai"; import { flattenError, ZodError, z } from "zod"; import { protectedProcedure } from "../../context"; import { aiRequestRateLimit } from "../../middleware/rate-limit"; import { aiProvidersService } from "../ai-providers/service"; -import { resumeService } from "../resume/service"; import { atsReviewInputSchema, atsReviewOutputSchema, reviewResumeText } from "./ats-review"; import { aiService, fileInputSchema } from "./service"; @@ -138,48 +135,6 @@ export const aiRouter = { } }), - chat: protectedProcedure - .route({ - method: "POST", - path: "/ai/chat", - tags: ["AI"], - operationId: "aiChat", - summary: "Chat with AI to modify resume", - description: - "Streams a chat response from the configured AI provider. The LLM can call the propose_resume_patches tool to generate JSON Patch proposals for explicit user approval. Requires authentication and AI provider credentials.", - }) - .input( - type<{ - aiProviderId?: string; - messages: UIMessage[]; - resumeId: string; - }>(), - ) - .use(aiRequestRateLimit) - .handler(async ({ context, input }) => { - try { - const [provider, resume] = await Promise.all([ - getRunnableProvider(context.user.id, input.aiProviderId), - resumeService.getById({ id: input.resumeId, userId: context.user.id }), - ]); - - return await aiService.chat({ - provider: provider.provider, - model: provider.model, - apiKey: provider.apiKey, - baseURL: provider.baseURL ?? "", - messages: input.messages, - resumeData: resume.data, - resumeUpdatedAt: resume.updatedAt, - }); - } catch (error) { - if (isCredentialEncryptionUnavailable(error)) throwCredentialEncryptionUnavailable(); - if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError(); - if (isAiProviderGatewayError(error)) throwAiProviderGatewayError(error); - throw error; - } - }), - atsReview: protectedProcedure .route({ method: "POST", diff --git a/packages/api/src/features/ai/service.ts b/packages/api/src/features/ai/service.ts index 9f8b9da2f..acd7f976c 100644 --- a/packages/api/src/features/ai/service.ts +++ b/packages/api/src/features/ai/service.ts @@ -1,6 +1,6 @@ import type { AIProvider } from "@reactive-resume/ai/types"; import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { ModelMessage, UIMessage } from "ai"; +import type { ModelMessage } from "ai"; import { inflateRawSync } from "node:zlib"; import { createAnthropic } from "@ai-sdk/anthropic"; import { createCerebras } from "@ai-sdk/cerebras"; @@ -15,23 +15,11 @@ import { createOpenAICompatible } from "@ai-sdk/openai-compatible"; import { createPerplexity } from "@ai-sdk/perplexity"; import { createTogetherAI } from "@ai-sdk/togetherai"; import { createXai } from "@ai-sdk/xai"; -import { streamToEventIterator } from "@orpc/server"; -import { - APICallError, - convertToModelMessages, - createGateway, - generateText, - LoadAPIKeyError, - NoSuchModelError, - stepCountIs, - streamText, - tool, -} from "ai"; +import { APICallError, createGateway, generateText, LoadAPIKeyError, NoSuchModelError } from "ai"; import { createOllama } from "ollama-ai-provider-v2"; import { match } from "ts-pattern"; import { z } from "zod"; import { - chatSystemPromptTemplate, docxParserSystemPrompt, docxParserUserPrompt, pdfParserSystemPrompt, @@ -39,13 +27,7 @@ import { } from "@reactive-resume/ai/prompts"; import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template"; import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize"; -import { - normalizeResumePatchProposals, - resumePatchProposalToolInputSchema, - resumePatchProposalToolOutputSchema, -} from "@reactive-resume/ai/tools/patch-proposal"; import { AI_PROVIDER_DEFAULT_BASE_URLS, AI_PROVIDER_DISPLAY_NAMES, aiProviderSchema } from "@reactive-resume/ai/types"; -import { applyResumePatches } from "@reactive-resume/resume/patch"; import { supportsProviderNativeWebSearch } from "./capabilities"; import { resolveAiBaseUrl } from "./url-policy"; @@ -94,29 +76,15 @@ const TEST_CONNECTION_MAX_OUTPUT_TOKENS = 128; // AbortSignal.timeout stores the delay as a 32-bit signed integer. const MAX_ABORT_SIGNAL_TIMEOUT_MS = 2_147_483_647; -/** - * Parse `AI_TEST_TIMEOUT_MS` into a safe, finite, non-negative integer. - * - * Rejects empty, non-numeric, negative, fractional, and out-of-range values - * so that `AbortSignal.timeout` never receives an invalid delay. - * - * @param raw - The raw environment variable value, if set. - * @param fallback - Milliseconds to use when `raw` is missing or invalid. - * @returns The validated timeout in milliseconds. - */ -function parseTestConnectionTimeoutMs(raw: string | undefined, fallback: number): number { - if (raw === undefined) return fallback; - const trimmed = raw.trim(); - if (trimmed === "") return fallback; - if (!/^\d+$/.test(trimmed)) return fallback; - const value = Number(trimmed); - if (value < 0 || value > MAX_ABORT_SIGNAL_TIMEOUT_MS) return fallback; - return value; -} - // Long enough for a cold local model to load, short enough that the UI does not look frozen. // Self-hosted deployments with cold-start models (e.g. Ollama) can override via AI_TEST_TIMEOUT_MS. -const TEST_CONNECTION_TIMEOUT_MS = parseTestConnectionTimeoutMs(process.env.AI_TEST_TIMEOUT_MS, 30_000); +const TEST_CONNECTION_TIMEOUT_MS = z.coerce + .number() + .int() + .nonnegative() + .max(MAX_ABORT_SIGNAL_TIMEOUT_MS) + .catch(30_000) + .parse(process.env.AI_TEST_TIMEOUT_MS?.trim() || undefined); const DOCX_DOCUMENT_XML_PATH = "word/document.xml"; const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50; const ZIP_CENTRAL_DIRECTORY_SIGNATURE = 0x02014b50; @@ -486,49 +454,7 @@ async function parseDocx(input: ParseDocxInput): Promise { return parseAndValidateResumeJson(result.text); } -function buildChatSystemPrompt(resumeData: ResumeData): string { - return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2)); -} - -type ChatInput = z.infer & { - messages: UIMessage[]; - resumeData: ResumeData; - resumeUpdatedAt: Date; -}; - -async function chat(input: ChatInput) { - const model = getModel(input); - const systemPrompt = buildChatSystemPrompt(input.resumeData); - - const result = streamText({ - model, - system: systemPrompt, - messages: await convertToModelMessages(input.messages), - tools: { - propose_resume_patches: tool({ - description: - "Return one or more cohesive resume change proposals. Each proposal must include a title, optional summary, and valid JSON Patch operations against the current resume data. The tool validates but does not apply changes.", - inputSchema: resumePatchProposalToolInputSchema, - outputSchema: resumePatchProposalToolOutputSchema, - execute: (toolInput) => { - const proposals = normalizeResumePatchProposals(toolInput, input.resumeUpdatedAt); - - for (const proposal of proposals) { - applyResumePatches(input.resumeData, proposal.operations); - } - - return { proposals }; - }, - }), - }, - stopWhen: stepCountIs(3), - }); - - return streamToEventIterator(result.toUIMessageStream()); -} - export const aiService = { - chat, parseDocx, parsePdf, testConnection, diff --git a/packages/api/src/features/resume/events.ts b/packages/api/src/features/resume/events.ts index 75cca1965..ee577a52d 100644 --- a/packages/api/src/features/resume/events.ts +++ b/packages/api/src/features/resume/events.ts @@ -77,28 +77,17 @@ export async function* subscribeResumeUpdated({ resumeId, userId, signal }: Subs try { await client.query(`LISTEN ${RESUME_UPDATED_CHANNEL}`); - const waitForNextEvent = async (): Promise => { - if (done) return null; - + while (!done) { const event = queue.shift(); - if (event) return event; + if (event) { + yield event; + continue; + } await new Promise((resolve) => { wake = resolve; }); - - return waitForNextEvent(); - }; - - async function* streamEvents(): AsyncGenerator { - const event = await waitForNextEvent(); - if (!event) return; - - yield event; - yield* streamEvents(); } - - yield* streamEvents(); } finally { signal?.removeEventListener("abort", onAbort); client.off("notification", onNotification); diff --git a/packages/api/src/features/resume/public-pdf.ts b/packages/api/src/features/resume/public-pdf.ts index f78300e39..9cd9fe5ad 100644 --- a/packages/api/src/features/resume/public-pdf.ts +++ b/packages/api/src/features/resume/public-pdf.ts @@ -24,7 +24,7 @@ export type PublicResumePdfDependencies = { findResume(input: Pick): Promise; hasPasswordAccess(requestHeaders: Headers, resumeId: string, passwordHash: string | null): boolean | Promise; resolveCurrentUserId(requestHeaders: Headers): Promise; - rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void }; + rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void | Promise }; renderPdf(input: { data: ResumeData; filename: string }): Promise; }; @@ -78,7 +78,7 @@ export async function createPublicResumePdf( } const data = parseStoredResumeData(resume.data); - dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id }); + await dependencies.rateLimiter.consume({ trustedClient: input.trustedClient, resumeId: resume.id }); const filename = generateFilename(data.basics.name || "Resume", "pdf"); return { body: await dependencies.renderPdf({ data, filename }), filename }; } diff --git a/packages/api/src/features/resume/public-render-rate-limit.test.ts b/packages/api/src/features/resume/public-render-rate-limit.test.ts index 63882f65c..313fa65a0 100644 --- a/packages/api/src/features/resume/public-render-rate-limit.test.ts +++ b/packages/api/src/features/resume/public-render-rate-limit.test.ts @@ -1,9 +1,18 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { createPublicRenderRateLimiter } from "./public-render-rate-limit"; +beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(0); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + 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 }); + it("cannot reset a transport client's budget by rotating forwarding headers", async () => { + const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000 }); const first = { trustedClient: "203.0.113.9", requestHeaders: new Headers({ @@ -20,42 +29,37 @@ describe("public render rate limit", () => { }), }; - limiter.consume(first); + await limiter.consume(first); - expect(() => limiter.consume(rotated)).toThrowError( - expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }), - ); + await expect(limiter.consume(rotated)).rejects.toMatchObject({ 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 }); + it("shares one IP-and-resume token bucket across projection and PDF consumers", async () => { + const limiter = createPublicRenderRateLimiter({ capacity: 2, refillWindowMs: 60_000 }); const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" }; - limiter.consume(input); - limiter.consume(input); + await limiter.consume(input); + await limiter.consume(input); - expect(() => limiter.consume(input)).toThrowError( - expect.objectContaining({ code: "RATE_LIMIT_EXCEEDED", status: 429 }), - ); + await expect(limiter.consume(input)).rejects.toMatchObject({ 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" }); + it("keeps budgets separate by client IP and resume", async () => { + const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 60_000 }); + await 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(); + await expect(limiter.consume({ trustedClient: "203.0.113.8", resumeId: "resume-1" })).resolves.toBeUndefined(); + await expect(limiter.consume({ trustedClient: "203.0.113.7", resumeId: "resume-2" })).resolves.toBeUndefined(); }); - it("refills the bounded bucket over time", () => { - let now = 0; - const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 1_000, now: () => now }); + it("refills the bounded bucket over time", async () => { + const limiter = createPublicRenderRateLimiter({ capacity: 1, refillWindowMs: 1_000 }); const input = { trustedClient: "203.0.113.7", resumeId: "resume-1" }; - limiter.consume(input); - expect(() => limiter.consume(input)).toThrow(); + await limiter.consume(input); + await expect(limiter.consume(input)).rejects.toThrow(); - now = 1_000; + vi.advanceTimersByTime(1_001); - expect(() => limiter.consume(input)).not.toThrow(); + await expect(limiter.consume(input)).resolves.toBeUndefined(); }); }); diff --git a/packages/api/src/features/resume/public-render-rate-limit.ts b/packages/api/src/features/resume/public-render-rate-limit.ts index b2ee462b9..a0730bf50 100644 --- a/packages/api/src/features/resume/public-render-rate-limit.ts +++ b/packages/api/src/features/resume/public-render-rate-limit.ts @@ -1,3 +1,4 @@ +import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory"; import { ORPCError } from "@orpc/server"; type PublicRenderRateLimitInput = { @@ -7,51 +8,28 @@ type PublicRenderRateLimitInput = { }; export type PublicRenderRateLimiter = { - consume(input: PublicRenderRateLimitInput): void; + consume(input: PublicRenderRateLimitInput): Promise; }; -type Bucket = { - tokens: number; - updatedAt: number; -}; - -const MAX_BUCKETS = 50_000; - export function createPublicRenderRateLimiter( - options: { capacity?: number; refillWindowMs?: number; now?: () => number } = {}, + options: { capacity?: number; refillWindowMs?: 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(); + const limiter = new MemoryRatelimiter({ + maxRequests: options.capacity ?? 6, + window: options.refillWindowMs ?? 60_000, + }); return { - consume(input) { - const currentTime = now(); + async consume(input) { 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); + const { success } = await limiter.limit(`${trustedClient}:${input.resumeId}`); - if (tokens < 1) { + if (!success) { 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); }, }; } diff --git a/packages/api/src/features/resume/social-meta.ts b/packages/api/src/features/resume/social-meta.ts index 00de6be3f..fed24c79e 100644 --- a/packages/api/src/features/resume/social-meta.ts +++ b/packages/api/src/features/resume/social-meta.ts @@ -4,10 +4,6 @@ import { parseStoredResumeData } from "./resume-data-validation"; export type PublicResumeSocialMetaInput = { username: string; slug: string }; -export type PublicResumeSocialMetaDependencies = { - findResume(input: PublicResumeSocialMetaInput): Promise<{ name: string; data: unknown } | null>; -}; - // Only public, password-free resumes are matched. Password-protected resumes must not leak their // summary to an unauthenticated crawler, and this read deliberately skips the view counting and // access gating in resumeService.getBySlug — a card render is not a visit. @@ -33,13 +29,8 @@ const findResume = async ({ username, slug }: PublicResumeSocialMetaInput) => { return resume ?? null; }; -const defaultDependencies: PublicResumeSocialMetaDependencies = { findResume }; - -export async function getPublicResumeSocialMeta( - input: PublicResumeSocialMetaInput, - dependencies: PublicResumeSocialMetaDependencies = defaultDependencies, -): Promise { - const resume = await dependencies.findResume(input); +export async function getPublicResumeSocialMeta(input: PublicResumeSocialMetaInput): Promise { + const resume = await findResume(input); if (!resume) return null; return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name); diff --git a/packages/api/src/features/resume/view-dedup.ts b/packages/api/src/features/resume/view-dedup.ts index a699931a7..ad7b96d26 100644 --- a/packages/api/src/features/resume/view-dedup.ts +++ b/packages/api/src/features/resume/view-dedup.ts @@ -1,4 +1,4 @@ -import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit"; +export { getClientKey as clientKeyFromHeaders } from "../../middleware/rate-limit"; // ponytail: in-memory per-process dedup window. Single-instance is the default deploy; for // multi-instance, swap the Map for a Redis SETNX+EXPIRE keyed the same way (REDIS_URL already @@ -25,21 +25,3 @@ export function shouldCountView(key: string, now: number): boolean { seen.set(key, now + WINDOW_MS); return true; } - -// Mirrors the rate-limit middleware's client-key derivation so dedup and rate limiting agree on -// "who is this viewer": trusted proxy IP first, then a user-agent + language fingerprint fallback. -export function clientKeyFromHeaders(headers: Headers): string { - for (const headerName of TRUSTED_IP_HEADERS) { - const raw = headers.get(headerName)?.trim(); - if (!raw) continue; - - // Some proxies provide a comma-delimited chain; the first item is the original client. - const ip = raw.split(",")[0]?.trim(); - if (ip) return `ip:${ip}`; - } - - const userAgent = headers.get("user-agent")?.trim() ?? "unknown"; - const language = headers.get("accept-language")?.split(",")[0]?.trim() ?? "none"; - - return `fp:${userAgent.slice(0, 64)}:${language.slice(0, 16)}`; -} diff --git a/packages/api/src/features/statistics/router.ts b/packages/api/src/features/statistics/router.ts index d88d1943f..9c5d90efc 100644 --- a/packages/api/src/features/statistics/router.ts +++ b/packages/api/src/features/statistics/router.ts @@ -2,38 +2,6 @@ import z from "zod"; import { publicProcedure } from "../../context"; import { statisticsService } from "./service"; -const userRouter = { - getCount: publicProcedure - .route({ - method: "GET", - path: "/statistics/users", - tags: ["Platform Statistics"], - operationId: "getUserCount", - summary: "Get total number of users", - description: - "Returns the total number of registered users on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.", - successDescription: "The total number of registered users.", - }) - .output(z.number().describe("The total number of registered users.")) - .handler(() => statisticsService.user.getCount()), -}; - -const resumeRouter = { - getCount: publicProcedure - .route({ - method: "GET", - path: "/statistics/resumes", - tags: ["Platform Statistics"], - operationId: "getResumeCount", - summary: "Get total number of resumes", - description: - "Returns the total number of resumes created on this Reactive Resume instance. The count is cached for up to 6 hours for performance. No authentication required.", - successDescription: "The total number of resumes created.", - }) - .output(z.number().describe("The total number of resumes created.")) - .handler(() => statisticsService.resume.getCount()), -}; - const githubRouter = { getStarCount: publicProcedure .route({ @@ -70,7 +38,5 @@ export const statisticsRouter = { }), ) .handler(() => statisticsService.getTotals()), - user: userRouter, - resume: resumeRouter, github: githubRouter, }; diff --git a/packages/api/src/middleware/rate-limit/index.ts b/packages/api/src/middleware/rate-limit/index.ts index 2304fea49..fbd828f8b 100644 --- a/packages/api/src/middleware/rate-limit/index.ts +++ b/packages/api/src/middleware/rate-limit/index.ts @@ -26,7 +26,7 @@ function getTrustedIp(headers?: Headers): string | null { return null; } -function getClientKey(headers?: Headers): string { +export function getClientKey(headers?: Headers): string { const trustedIp = getTrustedIp(headers); if (trustedIp) return `ip:${trustedIp}`; diff --git a/packages/db/src/schema/resume.ts b/packages/db/src/schema/resume.ts index d08bfd904..d9ed1f802 100644 --- a/packages/db/src/schema/resume.ts +++ b/packages/db/src/schema/resume.ts @@ -37,7 +37,6 @@ export const resume = pg.pgTable( }, (t) => [ pg.unique().on(t.slug, t.userId), - pg.index().on(t.userId), pg.index().on(t.createdAt.asc()), pg.index().on(t.userId, t.updatedAt.desc()), pg.index().on(t.isPublic, t.slug, t.userId), diff --git a/packages/email/package.json b/packages/email/package.json index 3452cd739..bfdb9b738 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -22,7 +22,6 @@ "react-email": "^6.9.5" }, "devDependencies": { - "@react-email/ui": "^6.9.5", "@reactive-resume/config": "workspace:*", "@types/nodemailer": "^8.0.2", "@types/react": "^19.3.0", diff --git a/packages/fonts/package.json b/packages/fonts/package.json index 7f74cb2b6..0141eb6b3 100644 --- a/packages/fonts/package.json +++ b/packages/fonts/package.json @@ -4,8 +4,7 @@ "type": "module", "private": true, "exports": { - ".": "./src/index.ts", - "./webfontlist.json": "./src/webfontlist.json" + ".": "./src/index.ts" }, "scripts": { "typecheck": "tsgo --noEmit", diff --git a/packages/fonts/src/index.test.ts b/packages/fonts/src/index.test.ts index 7f9e82f0b..e3c3d1e47 100644 --- a/packages/fonts/src/index.test.ts +++ b/packages/fonts/src/index.test.ts @@ -10,9 +10,6 @@ import { isStandardPdfFontFamily, resolveBoldFontWeight, resolveLegacyFontAlias, - standardFontList, - webFontList, - webFontMap, } from "./index"; const sortFontFamilies = (families: string[]) => { @@ -32,21 +29,22 @@ describe("fontList", () => { expect(families).toContain("Times-Roman"); }); - it("merges standardFontList and webFontList without duplicates", () => { - const expectedSize = standardFontList.length + webFontList.length; - expect(fontList).toHaveLength(expectedSize); + it("contains no duplicate font families", () => { + const families = fontList.map((font) => font.family.toLowerCase()); + expect(new Set(families).size).toBe(families.length); }); }); -describe("standardFontList", () => { - it("contains only fonts not present in webFontMap", () => { - for (const font of standardFontList) { - expect(webFontMap.has(font.family)).toBe(false); +describe("standard PDF fonts", () => { + it("exposes standard fonts that are not part of the web font catalog", () => { + for (const family of ["Helvetica", "Courier", "Times-Roman"]) { + expect(isStandardPdfFontFamily(family)).toBe(true); + expect(getWebFont(family)).toBeUndefined(); } }); it("marks all standard fonts with type='standard'", () => { - for (const font of standardFontList) { + for (const font of fontList.filter((font) => isStandardPdfFontFamily(font.family))) { expect(font.type).toBe("standard"); } }); diff --git a/packages/fonts/src/index.ts b/packages/fonts/src/index.ts index 1c7d4a4a6..96c7487ef 100644 --- a/packages/fonts/src/index.ts +++ b/packages/fonts/src/index.ts @@ -96,9 +96,9 @@ const punctuationFallbackFonts = { sansSerif: "Noto Sans", } as const; -export const webFontList = webFontListJSON as WebFont[]; -export const webFontMap = new Map(webFontList.map((font) => [font.family, font])); -export const standardFontList = standardPdfFontList.filter((font) => !webFontMap.has(font.family)); +const webFontList = webFontListJSON as WebFont[]; +const webFontMap = new Map(webFontList.map((font) => [font.family, font])); +const standardFontList = standardPdfFontList.filter((font) => !webFontMap.has(font.family)); const fontMap = new Map(); const chinesePrioritySet = new Set(preferredChineseFontFamilies); diff --git a/packages/import/src/json-resume.tsx b/packages/import/src/json-resume.tsx index 9a8ac661c..591282911 100644 --- a/packages/import/src/json-resume.tsx +++ b/packages/import/src/json-resume.tsx @@ -4,12 +4,16 @@ import { getNetworkIcon } from "@reactive-resume/resume/icons"; import { resumeDataSchema } from "@reactive-resume/schema/resume/data"; import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { generateId } from "@reactive-resume/utils/string"; -import { createUrl } from "@reactive-resume/utils/url"; import { formatPeriod, formatSingleDate } from "./date"; import { rethrowAsImportError } from "./error"; import { arrayToHtmlList, toHtmlDescription } from "./html"; import { parseLevel } from "./level"; +const createUrl = (url?: string, label?: string): { url: string; label: string } => { + if (!url) return { url: "", label: "" }; + return { url, label: label || url }; +}; + const createItemWebsite = (url?: string, label?: string) => ({ ...createUrl(url, label), inlineLink: false, diff --git a/packages/import/src/reactive-resume-v4-json.tsx b/packages/import/src/reactive-resume-v4-json.tsx index 0f950e928..67026bdda 100644 --- a/packages/import/src/reactive-resume-v4-json.tsx +++ b/packages/import/src/reactive-resume-v4-json.tsx @@ -238,6 +238,29 @@ const hasV4Shape = (value: unknown): value is V4ResumeData => const NOT_V4_MESSAGE = "This file doesn't look like a Reactive Resume v4 export."; +type V4Item = { id?: string; visible?: boolean }; +type V4SectionHeader = { name?: string; columns?: number; visible?: boolean }; + +const baseSection = (section?: V4SectionHeader) => ({ + title: section?.name ?? "", + icon: "", + columns: section?.columns ?? 1, + hidden: !(section?.visible ?? true), + keepTogether: false, + startOnNewPage: false, +}); + +const itemBase = (item: V4Item) => ({ + id: item.id ?? generateId(), + hidden: !(item.visible ?? true), +}); + +const toWebsite = (url?: V4Url) => ({ + url: url?.href ?? "", + label: url?.label ?? "", + inlineLink: false, +}); + // ponytail: stateless single-method class → plain function export function parseReactiveResumeV4JSON(json: string): ResumeData { try { @@ -277,125 +300,74 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { })), }, summary: { - title: v4Data.sections.summary?.name ?? "", - icon: "", - columns: v4Data.sections.summary?.columns ?? 1, - hidden: !(v4Data.sections.summary?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.summary), content: v4Data.sections.summary?.content ?? "", }, sections: { profiles: { - title: v4Data.sections.profiles?.name ?? "", - icon: "", - columns: v4Data.sections.profiles?.columns ?? 1, - hidden: !(v4Data.sections.profiles?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.profiles), items: (v4Data.sections.profiles?.items ?? []) .filter((item) => item.network && item.network.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), icon: item.icon ?? "", iconColor: "", network: item.network ?? "", username: item.username ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), })), }, experience: { - title: v4Data.sections.experience?.name ?? "", - icon: "", - columns: v4Data.sections.experience?.columns ?? 1, - hidden: !(v4Data.sections.experience?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.experience), items: (v4Data.sections.experience?.items ?? []) .filter((item) => item.company && item.company.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), company: item.company ?? "", position: item.position ?? "", location: item.location ?? "", period: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), roles: [], description: item.summary ?? "", })), }, education: { - title: v4Data.sections.education?.name ?? "", - icon: "", - columns: v4Data.sections.education?.columns ?? 1, - hidden: !(v4Data.sections.education?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.education), items: (v4Data.sections.education?.items ?? []) .filter((item) => item.institution && item.institution.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), school: item.institution ?? "", degree: item.studyType ?? "", area: item.area ?? "", grade: item.score ?? "", location: "", period: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, projects: { - title: v4Data.sections.projects?.name ?? "", - icon: "", - columns: v4Data.sections.projects?.columns ?? 1, - hidden: !(v4Data.sections.projects?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.projects), items: (v4Data.sections.projects?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), name: item.name ?? "", period: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? item.description ?? "", })), }, skills: { - title: v4Data.sections.skills?.name ?? "", - icon: "", - columns: v4Data.sections.skills?.columns ?? 1, + ...baseSection(v4Data.sections.skills), layout: "default", keywordLayout: "inline", - hidden: !(v4Data.sections.skills?.visible ?? true), - keepTogether: false, - startOnNewPage: false, items: (v4Data.sections.skills?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), icon: "", iconColor: "", name: item.name ?? "", @@ -406,17 +378,11 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { })), }, languages: { - title: v4Data.sections.languages?.name ?? "", - icon: "", - columns: v4Data.sections.languages?.columns ?? 1, - hidden: !(v4Data.sections.languages?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.languages), items: (v4Data.sections.languages?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), language: item.name ?? "", fluency: item.description ?? "", // v4 stored language level as 0-10; scale down to v5's 0-5 range @@ -424,17 +390,11 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { })), }, interests: { - title: v4Data.sections.interests?.name ?? "", - icon: "", - columns: v4Data.sections.interests?.columns ?? 1, - hidden: !(v4Data.sections.interests?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.interests), items: (v4Data.sections.interests?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), icon: "", iconColor: "", name: item.name ?? "", @@ -442,117 +402,67 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { })), }, awards: { - title: v4Data.sections.awards?.name ?? "", - icon: "", - columns: v4Data.sections.awards?.columns ?? 1, - hidden: !(v4Data.sections.awards?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.awards), items: (v4Data.sections.awards?.items ?? []) .filter((item) => item.title && item.title.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), title: item.title ?? "", awarder: item.awarder ?? "", date: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, certifications: { - title: v4Data.sections.certifications?.name ?? "", - icon: "", - columns: v4Data.sections.certifications?.columns ?? 1, - hidden: !(v4Data.sections.certifications?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.certifications), items: (v4Data.sections.certifications?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), title: item.name ?? "", issuer: item.issuer ?? "", date: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, publications: { - title: v4Data.sections.publications?.name ?? "", - icon: "", - columns: v4Data.sections.publications?.columns ?? 1, - hidden: !(v4Data.sections.publications?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.publications), items: (v4Data.sections.publications?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), title: item.name ?? "", publisher: item.publisher ?? "", date: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, volunteer: { - title: v4Data.sections.volunteer?.name ?? "", - icon: "", - columns: v4Data.sections.volunteer?.columns ?? 1, - hidden: !(v4Data.sections.volunteer?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.volunteer), items: (v4Data.sections.volunteer?.items ?? []) .filter((item) => item.organization && item.organization.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), organization: item.organization ?? "", location: item.location ?? "", period: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, references: { - title: v4Data.sections.references?.name ?? "", - icon: "", - columns: v4Data.sections.references?.columns ?? 1, - hidden: !(v4Data.sections.references?.visible ?? true), - keepTogether: false, - startOnNewPage: false, + ...baseSection(v4Data.sections.references), items: (v4Data.sections.references?.items ?? []) .filter((item) => item.name && item.name.length > 0) .map((item) => ({ - id: item.id ?? generateId(), - hidden: !(item.visible ?? true), + ...itemBase(item), name: item.name ?? "", position: item.description ?? "", phone: "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), description: item.summary ?? "", })), }, @@ -577,11 +487,7 @@ export function parseReactiveResumeV4JSON(json: string): ResumeData { position: hasName ? (item.description ?? "") : "", location: item.location ?? "", period: item.date ?? "", - website: { - url: item.url?.href ?? "", - label: item.url?.label ?? "", - inlineLink: false, - }, + website: toWebsite(item.url), roles: [], // Prefer HTML summary; fall back to plain description // (for description-only items, description IS the body content) diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7b75163d7..1a4429bf1 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -1,3 +1,4 @@ +export { buildMcpServerInfo } from "./mcp-server-card"; export { MCP_TOOL_NAME } from "./mcp-tool-names"; export { registerPrompts } from "./prompts"; export { registerResources } from "./resources"; diff --git a/packages/mcp/src/mcp-server-card.ts b/packages/mcp/src/mcp-server-card.ts index 0ae6ca915..276f03fd1 100644 --- a/packages/mcp/src/mcp-server-card.ts +++ b/packages/mcp/src/mcp-server-card.ts @@ -1,7 +1,26 @@ import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js"; import { MCP_TOOL_NAME as T } from "./mcp-tool-names"; +import { PROMPT_META } from "./prompts"; import { TOOL_META } from "./tool-meta"; +const RESUME_ID_ARGUMENT = [{ name: "id", description: "Resume ID.", required: true }] as const; + +/** Shared server identity for both the live MCP server and the static server card. */ +export function buildMcpServerInfo(version: string) { + return { + name: "reactive-resume", + version, + title: "Reactive Resume", + websiteUrl: "https://rxresu.me", + description: + "Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.", + icons: [ + { src: "https://rxresu.me/icon/light.svg", mimeType: "image/svg+xml", theme: "light" as const }, + { src: "https://rxresu.me/icon/dark.svg", mimeType: "image/svg+xml", theme: "dark" as const }, + ], + }; +} + /** * Static MCP server card (SEP-1649 / well-known `mcp/server-card.json`). * Kept in sync with `registerTools`, `registerResources`, and `registerPrompts`. @@ -19,27 +38,11 @@ export function buildMcpServerCard(appVersion: string) { annotations, })); - const prompts = [ - { - name: "build_resume", - title: "Build Resume", - description: "Guide the user step-by-step through building a resume from scratch, section by section.", - arguments: [{ name: "id", description: "Resume ID.", required: true }], - }, - { - name: "improve_resume", - title: "Improve Resume", - description: "Review resume content and suggest concrete improvements to wording, impact, and structure.", - arguments: [{ name: "id", description: "Resume ID.", required: true }], - }, - { - name: "review_resume", - title: "Review Resume", - description: - "Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only: no changes are made.", - arguments: [{ name: "id", description: "Resume ID.", required: true }], - }, - ]; + const prompts = Object.entries(PROMPT_META).map(([name, meta]) => ({ + name, + ...meta, + arguments: [...RESUME_ID_ARGUMENT], + })); const resources = [ { @@ -91,18 +94,7 @@ export function buildMcpServerCard(appVersion: string) { }, }, }, - serverInfo: { - name: "reactive-resume", - version: appVersion, - title: "Reactive Resume", - websiteUrl: "https://rxresu.me", - description: - "Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.", - icons: [ - { src: "https://rxresu.me/icon/light.svg", mimeType: "image/svg+xml", theme: "light" as const }, - { src: "https://rxresu.me/icon/dark.svg", mimeType: "image/svg+xml", theme: "dark" as const }, - ], - }, + serverInfo: buildMcpServerInfo(appVersion), tools, prompts, resources, diff --git a/packages/mcp/src/prompts.ts b/packages/mcp/src/prompts.ts index 8c428db26..23896b7cb 100644 --- a/packages/mcp/src/prompts.ts +++ b/packages/mcp/src/prompts.ts @@ -10,6 +10,23 @@ const resumeIdArg = z `The ID of the resume. Use \`${T.listResumes}\` to find IDs, or \`${T.createResume}\` to create a new one first.`, ); +/** Shared prompt titles/descriptions, also consumed by the static MCP server card. */ +export const PROMPT_META = { + build_resume: { + title: "Build Resume", + description: "Guide the user step-by-step through building a resume from scratch, section by section.", + }, + improve_resume: { + title: "Improve Resume", + description: "Review resume content and suggest concrete improvements to wording, impact, and structure.", + }, + review_resume: { + title: "Review Resume", + description: + "Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only: no changes are made.", + }, +} as const; + /** Embeds the resume data and JSON schema as context messages. */ function resumeContext(id: string) { return [ @@ -68,8 +85,7 @@ export function registerPrompts(server: McpServer) { server.registerPrompt( "build_resume", { - title: "Build Resume", - description: "Guide the user step-by-step through building a resume from scratch, section by section.", + ...PROMPT_META.build_resume, argsSchema: { id: resumeIdArg }, }, ({ id }) => ({ @@ -109,8 +125,7 @@ export function registerPrompts(server: McpServer) { server.registerPrompt( "improve_resume", { - title: "Improve Resume", - description: "Review resume content and suggest concrete improvements to wording, impact, and structure.", + ...PROMPT_META.improve_resume, argsSchema: { id: resumeIdArg }, }, ({ id }) => ({ @@ -153,9 +168,7 @@ export function registerPrompts(server: McpServer) { server.registerPrompt( "review_resume", { - title: "Review Resume", - description: - "Get a structured, professional critique with a scorecard and prioritized recommendations. Read-only: no changes are made.", + ...PROMPT_META.review_resume, argsSchema: { id: resumeIdArg }, }, ({ id }) => ({ diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index f19e04400..f351c03d5 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -371,200 +371,140 @@ export function registerTools(server: McpServer, client: RouterClient json(await client.coverLetters.list(params as never))), - ); + // ── Independent Cover Letters + Applications ──────────────────── + type CoverLetterOrApplicationTool = readonly [ + name: (typeof T)[keyof typeof T], + label: string, + handler: (params: never) => Promise, + ]; - server.registerTool( - T.readCoverLetter, - TOOL_META[T.readCoverLetter], - withErrorHandling("reading cover letter", async ({ id }: { id: string }) => - json(await client.coverLetters.getById({ id })), - ), - ); - - server.registerTool( - T.createCoverLetter, - TOOL_META[T.createCoverLetter], - withErrorHandling("creating cover letter", async (params) => - json(await client.coverLetters.create(params as never)), - ), - ); - - server.registerTool( - T.updateCoverLetter, - TOOL_META[T.updateCoverLetter], - withErrorHandling("updating cover letter", async (params) => - json(await client.coverLetters.update(params as never)), - ), - ); - - server.registerTool( - T.refreshCoverLetterStyle, - TOOL_META[T.refreshCoverLetterStyle], - withErrorHandling("refreshing cover letter style", async (params) => - json(await client.coverLetters.refreshStyle(params as never)), - ), - ); - - server.registerTool( - T.duplicateCoverLetter, - TOOL_META[T.duplicateCoverLetter], - withErrorHandling("duplicating cover letter", async (params) => - json(await client.coverLetters.duplicate(params as never)), - ), - ); - - server.registerTool( - T.deleteCoverLetter, - TOOL_META[T.deleteCoverLetter], - withErrorHandling("deleting cover letter", async (params) => { - await client.coverLetters.delete(params as never); - return text(`Deleted cover letter (${(params as { id: string }).id}).`); - }), - ); - - server.registerTool( - T.copyEmbeddedCoverLetter, - TOOL_META[T.copyEmbeddedCoverLetter], - withErrorHandling("copying embedded cover letter", async (params) => - json(await client.coverLetters.copyEmbedded(params as never)), - ), - ); - - server.registerTool( - T.exportCoverLetter, - TOOL_META[T.exportCoverLetter], - withErrorHandling("exporting cover letter", async ({ id }: { id: string }) => - json(await client.coverLetters.export({ id })), - ), - ); - - server.registerTool( - T.importCoverLetter, - TOOL_META[T.importCoverLetter], - withErrorHandling("importing cover letter", async ({ document }: { document: unknown }) => - json(await client.coverLetters.import({ document } as never)), - ), - ); - - // ── Applications ────────────────────────────────────────────── - server.registerTool( - T.listApplications, - TOOL_META[T.listApplications], - withErrorHandling("listing applications", async (params) => json(await client.applications.list(params as never))), - ); - - server.registerTool( - T.readApplication, - TOOL_META[T.readApplication], - withErrorHandling("reading application", async ({ id }: { id: string }) => - json(await client.applications.getById({ id })), - ), - ); - - server.registerTool( - T.listApplicationTags, - TOOL_META[T.listApplicationTags], - withErrorHandling("listing application tags", async () => json(await client.applications.tags())), - ); - - server.registerTool( - T.getApplicationStats, - TOOL_META[T.getApplicationStats], - withErrorHandling("getting application stats", async () => json(await client.applications.stats())), - ); - - server.registerTool( - T.createApplication, - TOOL_META[T.createApplication], - withErrorHandling("creating application", async (params) => { - const id = await client.applications.create(coerceFollowUpAt(params as Record) as never); - return json({ id }); - }), - ); - - server.registerTool( - T.updateApplication, - TOOL_META[T.updateApplication], - withErrorHandling("updating application", async (params) => { - return json(await client.applications.update(coerceFollowUpAt(params as Record) as never)); - }), - ); - - server.registerTool( - T.addApplicationNote, - TOOL_META[T.addApplicationNote], - withErrorHandling( + const coverLetterAndApplicationTools: CoverLetterOrApplicationTool[] = [ + [ + T.listCoverLetters, + "listing cover letters", + async (params) => json(await client.coverLetters.list(params as never)), + ], + [ + T.readCoverLetter, + "reading cover letter", + async ({ id }: { id: string }) => json(await client.coverLetters.getById({ id })), + ], + [ + T.createCoverLetter, + "creating cover letter", + async (params) => json(await client.coverLetters.create(params as never)), + ], + [ + T.updateCoverLetter, + "updating cover letter", + async (params) => json(await client.coverLetters.update(params as never)), + ], + [ + T.refreshCoverLetterStyle, + "refreshing cover letter style", + async (params) => json(await client.coverLetters.refreshStyle(params as never)), + ], + [ + T.duplicateCoverLetter, + "duplicating cover letter", + async (params) => json(await client.coverLetters.duplicate(params as never)), + ], + [ + T.deleteCoverLetter, + "deleting cover letter", + async (params) => { + await client.coverLetters.delete(params as never); + return text(`Deleted cover letter (${(params as { id: string }).id}).`); + }, + ], + [ + T.copyEmbeddedCoverLetter, + "copying embedded cover letter", + async (params) => json(await client.coverLetters.copyEmbedded(params as never)), + ], + [ + T.exportCoverLetter, + "exporting cover letter", + async ({ id }: { id: string }) => json(await client.coverLetters.export({ id })), + ], + [ + T.importCoverLetter, + "importing cover letter", + async ({ document }: { document: unknown }) => json(await client.coverLetters.import({ document } as never)), + ], + [ + T.listApplications, + "listing applications", + async (params) => json(await client.applications.list(params as never)), + ], + [ + T.readApplication, + "reading application", + async ({ id }: { id: string }) => json(await client.applications.getById({ id })), + ], + [T.listApplicationTags, "listing application tags", async () => json(await client.applications.tags())], + [T.getApplicationStats, "getting application stats", async () => json(await client.applications.stats())], + [ + T.createApplication, + "creating application", + async (params) => { + const id = await client.applications.create(coerceFollowUpAt(params as Record) as never); + return json({ id }); + }, + ], + [ + T.updateApplication, + "updating application", + async (params) => + json(await client.applications.update(coerceFollowUpAt(params as Record) as never)), + ], + [ + T.addApplicationNote, "adding application note", - async ({ id, text: noteText, date }: { id: string; text: string; date?: string | undefined }) => { - return json(await client.applications.addNote({ id, text: noteText, date })); - }, - ), - ); - - server.registerTool( - T.updateApplicationTimelineEntry, - TOOL_META[T.updateApplicationTimelineEntry], - withErrorHandling("updating application timeline entry", async (params) => { - return json(await client.applications.updateTimelineEntry(params as never)); - }), - ); - - server.registerTool( - T.deleteApplicationTimelineEntry, - TOOL_META[T.deleteApplicationTimelineEntry], - withErrorHandling( + async ({ id, text: noteText, date }: { id: string; text: string; date?: string | undefined }) => + json(await client.applications.addNote({ id, text: noteText, date })), + ], + [ + T.updateApplicationTimelineEntry, + "updating application timeline entry", + async (params) => json(await client.applications.updateTimelineEntry(params as never)), + ], + [ + T.deleteApplicationTimelineEntry, "deleting application timeline entry", - async ({ id, entryId }: { id: string; entryId: string }) => { - return json(await client.applications.deleteTimelineEntry({ id, entryId })); + async ({ id, entryId }: { id: string; entryId: string }) => + json(await client.applications.deleteTimelineEntry({ id, entryId })), + ], + [ + T.deleteApplication, + "deleting application", + async ({ id }: { id: string }) => { + await client.applications.delete({ id }); + return text(`Deleted application (${id}).`); }, - ), - ); - - server.registerTool( - T.deleteApplication, - TOOL_META[T.deleteApplication], - withErrorHandling("deleting application", async ({ id }: { id: string }) => { - await client.applications.delete({ id }); - return text(`Deleted application (${id}).`); - }), - ); - - server.registerTool( - T.bulkUpdateApplications, - TOOL_META[T.bulkUpdateApplications], - withErrorHandling("bulk updating applications", async (params) => - json(await client.applications.bulkUpdate(params as never)), - ), - ); - - server.registerTool( - T.bulkDeleteApplications, - TOOL_META[T.bulkDeleteApplications], - withErrorHandling("bulk deleting applications", async ({ ids }: { ids: string[] }) => { - return json(await client.applications.bulkDelete({ ids })); - }), - ); - - server.registerTool( - T.importApplications, - TOOL_META[T.importApplications], - withErrorHandling("importing applications", async (params) => { - const input = params as { items: Array> }; - return json( - await client.applications.import({ items: input.items.map((item) => coerceFollowUpAt(item)) } as never), - ); - }), - ); - - server.registerTool( - T.attachApplicationDocument, - TOOL_META[T.attachApplicationDocument], - withErrorHandling( + ], + [ + T.bulkUpdateApplications, + "bulk updating applications", + async (params) => json(await client.applications.bulkUpdate(params as never)), + ], + [ + T.bulkDeleteApplications, + "bulk deleting applications", + async ({ ids }: { ids: string[] }) => json(await client.applications.bulkDelete({ ids })), + ], + [ + T.importApplications, + "importing applications", + async (params) => { + const input = params as { items: Array> }; + return json( + await client.applications.import({ items: input.items.map((item) => coerceFollowUpAt(item)) } as never), + ); + }, + ], + [ + T.attachApplicationDocument, "attaching application document", async ({ id, @@ -582,51 +522,37 @@ export function registerTools(server: McpServer, client: RouterClient { - return json(await client.applications.removeDocument({ id, kind })); - }, - ), - ); - - server.registerTool( - T.autofillApplicationFromJob, - TOOL_META[T.autofillApplicationFromJob], - withErrorHandling("autofilling application from job", async (params) => - json(await client.applications.ai.autofill(params as never)), - ), - ); - - server.registerTool( - T.scoreApplicationMatch, - TOOL_META[T.scoreApplicationMatch], - withErrorHandling("scoring application match", async ({ id }: { id: string }) => { - return json(await client.applications.ai.matchScore({ id })); - }), - ); - - server.registerTool( - T.tailorResumeForApplication, - TOOL_META[T.tailorResumeForApplication], - withErrorHandling("tailoring resume for application", async ({ id }: { id: string }) => { - return json(await client.applications.ai.tailorResume({ id })); - }), - ); - - server.registerTool( - T.draftApplicationMessage, - TOOL_META[T.draftApplicationMessage], - withErrorHandling( + async ({ id, kind }: { id: string; kind: "resume" | "cover-letter" }) => + json(await client.applications.removeDocument({ id, kind })), + ], + [ + T.autofillApplicationFromJob, + "autofilling application from job", + async (params) => json(await client.applications.ai.autofill(params as never)), + ], + [ + T.scoreApplicationMatch, + "scoring application match", + async ({ id }: { id: string }) => json(await client.applications.ai.matchScore({ id })), + ], + [ + T.tailorResumeForApplication, + "tailoring resume for application", + async ({ id }: { id: string }) => json(await client.applications.ai.tailorResume({ id })), + ], + [ + T.draftApplicationMessage, "drafting application message", async ({ id, kind }: { id: string; kind: "cover-letter" | "follow-up" }) => json(await client.applications.ai.draftMessage({ id, kind })), - ), - ); + ], + ]; + + for (const [name, label, handler] of coverLetterAndApplicationTools) { + server.registerTool(name, TOOL_META[name], withErrorHandling(label, handler) as never); + } } diff --git a/packages/pdf/package.json b/packages/pdf/package.json index 4364fc22a..c284fb86d 100644 --- a/packages/pdf/package.json +++ b/packages/pdf/package.json @@ -8,7 +8,6 @@ "./document": "./src/document.tsx", "./semantic": "./src/semantic/index.ts", "./semantic-legacy": "./src/semantic/legacy-converter.ts", - "./semantic-manifest": "./src/semantic/template-manifest.ts", "./semantic-tree": "./src/semantic/tree.ts", "./section-title": "./src/section-title.ts", "./server": "./src/server.tsx" diff --git a/packages/pdf/src/rtl-fixture.test.ts b/packages/pdf/src/rtl-fixture.test.ts index 5f78f0487..2f1597d66 100644 --- a/packages/pdf/src/rtl-fixture.test.ts +++ b/packages/pdf/src/rtl-fixture.test.ts @@ -19,9 +19,11 @@ describe("RTL PDF fixture", () => { it.each(templatePages)("%s wires shared RTL helpers and alignEnd slot", (_template, pagePath) => { const source = readFileSync(pagePath, "utf8"); - expect(source).toContain("createRtlStyleHelpers"); - // ponytail: alignEnd moved to createBaseTemplateStyles factory; either direct or factory counts. - expect(source.includes("alignEnd") || source.includes("createBaseTemplateStyles")).toBe(true); + // ponytail: RTL helpers and alignEnd moved to the shared template base; either direct or via useTemplateBase counts. + expect(source.includes("createRtlStyleHelpers") || source.includes("useTemplateBase")).toBe(true); + expect( + source.includes("alignEnd") || source.includes("createBaseTemplateStyles") || source.includes("useTemplateBase"), + ).toBe(true); expect(source).not.toContain("alignRight"); expect(source).not.toContain('from "@reactive-resume/utils/locale"'); }); diff --git a/packages/pdf/src/semantic/binding-inventory.test.ts b/packages/pdf/src/semantic/binding-inventory.test.ts index 395e6129f..52fe3dcba 100644 --- a/packages/pdf/src/semantic/binding-inventory.test.ts +++ b/packages/pdf/src/semantic/binding-inventory.test.ts @@ -33,8 +33,7 @@ describe("semantic binding inventory", () => { "template-part": { type: "primitive", primitive: "View", source: "synthetic" }, }); - expect(inventory.unboundNodeKeys).toEqual(["resume/part"]); - expect(inventory.syntheticWrapperCount).toBe(1); + expect(inventory.bindings["resume/part"]).toEqual({ type: "primitive", primitive: "View", source: "synthetic" }); }); it("reports an alias unbound when its canonical primitive owner is absent", () => { @@ -48,7 +47,6 @@ describe("semantic binding inventory", () => { }); expect(inventory.bindings).toEqual({}); - expect(inventory.unboundNodeKeys).toEqual(["rich"]); }); it("reports a rich-text alias unbound when its existing canonical node has the wrong kind", () => { @@ -63,8 +61,6 @@ describe("semantic binding inventory", () => { }); expect(inventory.bindings.rich).toBeUndefined(); - expect(inventory.unboundNodeKeys).toContain("rich"); - expect(inventory.unboundNodeKeys).not.toEqual([]); }); it("reports a rich-text alias unbound when its field owner binding is synthetic or non-primitive", () => { @@ -96,21 +92,8 @@ describe("semantic binding inventory", () => { }, ); - expect({ - syntheticBinding: synthetic.bindings["rich-synthetic"], - syntheticAliasUnbound: synthetic.unboundNodeKeys.includes("rich-synthetic"), - syntheticInventoryClean: synthetic.unboundNodeKeys.length === 0, - nonPrimitiveBinding: nonPrimitive.bindings["rich-non-primitive"], - nonPrimitiveAliasUnbound: nonPrimitive.unboundNodeKeys.includes("rich-non-primitive"), - nonPrimitiveInventoryClean: nonPrimitive.unboundNodeKeys.length === 0, - }).toEqual({ - syntheticBinding: undefined, - syntheticAliasUnbound: true, - syntheticInventoryClean: false, - nonPrimitiveBinding: undefined, - nonPrimitiveAliasUnbound: true, - nonPrimitiveInventoryClean: false, - }); + expect(synthetic.bindings["rich-synthetic"]).toBeUndefined(); + expect(nonPrimitive.bindings["rich-non-primitive"]).toBeUndefined(); }); it("resolves conditional bindings to the primitive the existing renderer uses", () => { @@ -156,8 +139,6 @@ describe("semantic binding inventory", () => { const nodeCount = (candidate: SemanticNode): number => 1 + candidate.children.reduce((count, child) => count + nodeCount(child), 0); - expect(inventory.unboundNodeKeys).toEqual([]); - expect(inventory.syntheticWrapperCount).toBe(0); expect(Object.keys(inventory.bindings)).toHaveLength(nodeCount(tree)); expect( Object.values(inventory.bindings).every((binding) => binding.type === "alias" || binding.source === "existing"), @@ -402,6 +383,5 @@ describe("semantic binding inventory", () => { const inventory = createBindingInventory(tree, getTemplateSemanticBindingRegistry(template)); expect(inventory.bindings[part.key]).toEqual({ type: "primitive", primitive, source: "existing" }); - expect(inventory.syntheticWrapperCount).toBe(0); }); }); diff --git a/packages/pdf/src/semantic/binding-inventory.ts b/packages/pdf/src/semantic/binding-inventory.ts index 26be1d8c1..d49aadf14 100644 --- a/packages/pdf/src/semantic/binding-inventory.ts +++ b/packages/pdf/src/semantic/binding-inventory.ts @@ -86,9 +86,6 @@ export type SemanticBindingRegistry = Readonly< export type BindingInventory = { bindings: Readonly>; - aliasTokensByNodeKey: Readonly>; - unboundNodeKeys: readonly string[]; - syntheticWrapperCount: number; }; const existing = (primitive: PrimitiveBinding["primitive"]): PrimitiveBinding => ({ @@ -165,28 +162,14 @@ export function createBindingInventory( registry: SemanticBindingRegistry = SHARED_BINDING_REGISTRY, ): BindingInventory { const bindings: Record = {}; - const aliasTokensByNodeKey: Record = {}; - const unboundNodeKeys: string[] = []; const nodes = new Map(); - let syntheticWrapperCount = 0; const visit = (node: SemanticNode, parent?: SemanticNode) => { nodes.set(node.key, node); - const aliasTokens = node.attributes.part?.split(" ").filter(Boolean); - if (aliasTokens?.length) aliasTokensByNodeKey[node.key] = aliasTokens; + const declaration = registry[node.kind]; const binding = typeof declaration === "function" ? declaration(node, { parent }) : declaration; - - if (!binding) { - unboundNodeKeys.push(node.key); - } else { - bindings[node.key] = binding; - - if (binding.type === "primitive" && binding.source === "synthetic") { - syntheticWrapperCount += 1; - unboundNodeKeys.push(node.key); - } - } + if (binding) bindings[node.key] = binding; for (const child of node.children) visit(child, node); }; @@ -204,9 +187,8 @@ export function createBindingInventory( canonicalBinding.source !== "existing" ) { delete bindings[nodeKey]; - unboundNodeKeys.push(nodeKey); } } - return { bindings, aliasTokensByNodeKey, unboundNodeKeys, syntheticWrapperCount }; + return { bindings }; } diff --git a/packages/pdf/src/semantic/combined-field-bindings.test.tsx b/packages/pdf/src/semantic/combined-field-bindings.test.tsx index 503be0d3a..78b800772 100644 --- a/packages/pdf/src/semantic/combined-field-bindings.test.tsx +++ b/packages/pdf/src/semantic/combined-field-bindings.test.tsx @@ -248,7 +248,9 @@ describe("combined PDF field bindings", () => { } else { expect(aliases).toEqual([]); } - expect(inventory.syntheticWrapperCount).toBe(0); + expect( + Object.values(inventory.bindings).every((binding) => binding.type === "alias" || binding.source === "existing"), + ).toBe(true); } for (const [template, data, text] of [ diff --git a/packages/pdf/src/semantic/index.ts b/packages/pdf/src/semantic/index.ts index 6d945999b..2e65dc41f 100644 --- a/packages/pdf/src/semantic/index.ts +++ b/packages/pdf/src/semantic/index.ts @@ -3,7 +3,6 @@ export type { ResolveResumePresentationInput, } from "./resolve"; export * from "./legacy-converter"; -export * from "./legacy-parity"; export { resolveResumePresentation, resolveResumeRuntime, diff --git a/packages/pdf/src/semantic/legacy-parity.test.ts b/packages/pdf/src/semantic/legacy-parity.test.ts deleted file mode 100644 index 179e63819..000000000 --- a/packages/pdf/src/semantic/legacy-parity.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -import type { ResumeData, StyleRule } from "@reactive-resume/schema/resume/data"; -import type { Template } from "@reactive-resume/schema/templates"; -import type { LegacyParityHostNode } from "./legacy-parity"; -import { readFileSync } from "node:fs"; -import { describe, expect, it } from "vitest"; -import { pdf } from "@react-pdf/renderer"; -import { createElement } from "react"; -import { styleRulesSchema } from "@reactive-resume/schema/resume/data"; -import { defaultResumeData } from "@reactive-resume/schema/resume/default"; -import { sampleResumeData } from "@reactive-resume/schema/resume/sample"; -import { ResumeDocument } from "../document"; -import { convertLegacyStyleRules } from "./legacy-converter"; -import { compareLegacyParityHostNodes, compareLegacySemanticPresentation } from "./legacy-parity"; - -const fixtureNames = [ - "all-templates-smoke", - "array-order-tie", - "award-unbold", - "clamped-spacing", - "combined-text-host", - "custom-section-type", - "disabled-rules", - "icon-level-size", - "link-underline-3134", - "merge-specificity", - "primary-text-bold-3146", - "rich-text-all-slots", - "sanitized-intent-3199", - "section-id-uuid", -] as const; - -const templates = [ - "azurill", - "bronzor", - "chikorita", - "ditgar", - "ditto", - "gengar", - "glalie", - "kakuna", - "lapras", - "leafish", - "meowth", - "onyx", - "pikachu", - "rhyhorn", - "scizor", -] as const satisfies readonly Template[]; - -const readRules = (name: string): StyleRule[] => - styleRulesSchema.parse( - JSON.parse(readFileSync(new URL(`./__fixtures__/legacy/${name}.json`, import.meta.url), "utf8")), - ); - -const buildFixture = (rules: StyleRule[]): ResumeData => { - const data = structuredClone(defaultResumeData); - data.picture.hidden = true; - data.basics = { - name: "Ada Lovelace", - headline: "Engineer", - email: "ada@example.com", - phone: "", - location: "London", - website: { url: "", label: "" }, - customFields: [], - }; - data.summary.hidden = false; - data.summary.content = - '

    Paragraph bold mark link

    • List item
    '; - data.sections.experience.items = [ - { - id: "experience-1", - hidden: false, - company: "Analytical Engines", - position: "Engineer", - location: "London", - period: "1842", - website: { url: "https://example.com/work", label: "Work", inlineLink: true }, - description: "

    Built engines.

    ", - roles: [ - { - id: "role-1", - position: "Senior Engineer", - period: "1843", - description: "

    Led the engine team.

    ", - }, - ], - }, - ]; - data.sections.education.items = [ - { - id: "education-1", - hidden: false, - school: "University of London", - degree: "BSc", - area: "Mathematics", - grade: "First", - location: "London", - period: "1835", - website: { url: "", label: "", inlineLink: false }, - description: "

    Studied analytical engines.

    ", - }, - ]; - data.sections.skills.items = [ - { - id: "skill-1", - hidden: false, - icon: "code", - iconColor: "", - name: "Mathematics", - proficiency: "Expert", - level: 3, - keywords: ["Analysis"], - }, - ]; - data.sections.awards.items = [ - { - id: "award-1", - hidden: false, - title: "Prize", - awarder: "Society", - date: "1843", - website: { url: "", label: "", inlineLink: false }, - description: "

    First programmer.

    ", - }, - ]; - data.customSections = [ - { - id: "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37", - type: "experience", - title: "Consulting", - icon: "briefcase", - columns: 1, - hidden: false, - keepTogether: false, - showHeading: true, - startOnNewPage: false, - items: [ - { - id: "custom-experience-1", - hidden: false, - company: "Difference Engines", - position: "Consultant", - location: "London", - period: "1844", - website: { url: "", label: "", inlineLink: false }, - description: "

    Advised builders.

    ", - roles: [], - }, - ], - }, - ]; - data.metadata.layout.pages = [ - { - fullWidth: true, - main: ["summary", "experience", "education", "skills", "awards", "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37"], - sidebar: [], - }, - ]; - data.metadata.styleRules = rules; - return data; -}; - -const nodeText = (node: LegacyParityHostNode): string => - node.value ?? (node.children ?? []).map((child) => nodeText(child)).join(""); - -const renderHostDocument = async (data: ResumeData): Promise => { - const element = createElement(ResumeDocument, { data, template: "gengar" }) as unknown as Parameters[0]; - const instance = pdf(element); - await expect.poll(() => instance.container.document).not.toBeNull(); - return instance.container.document as LegacyParityHostNode; -}; - -const findEmptyTextNodes = (node: LegacyParityHostNode): LegacyParityHostNode[] => [ - ...(node.type === "TEXT" && nodeText(node).trim() === "" ? [node] : []), - ...(node.children ?? []).flatMap(findEmptyTextNodes), -]; - -const hasFontSize = (node: LegacyParityHostNode, fontSize: number): boolean => - (Array.isArray(node.style) ? node.style : [node.style]).some( - (style) => - style !== null && typeof style === "object" && (style as Readonly>).fontSize === fontSize, - ); - -describe("compareLegacySemanticPresentation", () => { - it("renders the mandatory target shapes in the shared parity document", () => { - const data = buildFixture([]); - - expect(data.sections.experience.items[0]?.roles[0]?.position).toBe("Senior Engineer"); - expect(data.customSections).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - id: "1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37", - type: "experience", - }), - ]), - ); - expect(data.metadata.layout.pages[0]?.main).toContain("1d7312cb-9ba2-4d42-9ca8-2a9ca05f9f37"); - }); - - it.each(fixtureNames)("matches final primitive props for %s", async (fixture) => { - const data = buildFixture(readRules(fixture)); - const conversion = convertLegacyStyleRules(data); - const comparison = await compareLegacySemanticPresentation({ - data, - convertedSource: conversion.source, - templates: ["onyx"], - }); - - expect(comparison.pageCountMismatches).toEqual([]); - expect(comparison.primitivePropMismatches).toEqual([]); - expect(comparison.mismatches).toEqual([]); - }); - - it("matches final primitive props on every template", async () => { - const data = buildFixture(readRules("all-templates-smoke")); - const conversion = convertLegacyStyleRules(data); - const comparison = await compareLegacySemanticPresentation({ - data, - convertedSource: conversion.source, - templates, - }); - - expect(comparison.pageCountMismatches).toEqual([]); - expect(comparison.primitivePropMismatches).toEqual([]); - expect(comparison.mismatches).toEqual([]); - }, 30_000); - - it("matches the real sample on every template for a substantive custom-section rule", async () => { - const data = structuredClone(sampleResumeData); - data.metadata.styleRules = readRules("custom-section-type"); - const conversion = convertLegacyStyleRules(data); - const comparison = await compareLegacySemanticPresentation({ - data, - convertedSource: conversion.source, - templates, - }); - - expect(comparison.pageCountMismatches).toEqual([]); - expect(comparison.primitivePropMismatches).toEqual([]); - expect(comparison.mismatches).toEqual([]); - }, 30_000); - - it("preserves Scizor template-after Bold color for converted legacy text rules", async () => { - const data = buildFixture(readRules("primary-text-bold-3146")); - data.metadata.template = "scizor"; - const conversion = convertLegacyStyleRules(data); - const comparison = await compareLegacySemanticPresentation({ - data, - convertedSource: conversion.source, - templates: ["scizor"], - }); - - expect(comparison.pageCountMismatches).toEqual([]); - expect(comparison.primitivePropMismatches).toEqual([]); - expect(comparison.mismatches).toEqual([]); - }); - - it("omits empty Gengar skill proficiency text while preserving populated text", async () => { - const data = buildFixture([]); - data.metadata.template = "gengar"; - data.metadata.layout.pages = [{ fullWidth: false, main: [], sidebar: ["skills"] }]; - data.metadata.typography.body.fontSize = 9; - const [skill] = data.sections.skills.items; - if (!skill) throw new Error("Expected Gengar skill fixture."); - for (const proficiency of ["", " \t"]) { - skill.proficiency = proficiency; - expect(findEmptyTextNodes(await renderHostDocument(data)).filter((node) => hasFontSize(node, 9))).toEqual([]); - } - - skill.proficiency = "Expert"; - expect(nodeText(await renderHostDocument(data))).toContain("Expert"); - }); - - it.each(["onyx", "meowth"] as const)( - "matches combined separator and box-style primitives on %s", - async (template) => { - const data = buildFixture(readRules("combined-text-host")); - const conversion = convertLegacyStyleRules(data); - const comparison = await compareLegacySemanticPresentation({ - data, - convertedSource: conversion.source, - templates: [template], - }); - - expect(comparison.mismatches).toEqual([]); - }, - ); - - it.each([ - ["text", { type: "TEXT", value: "legacy" }, { type: "TEXT", value: "semantic" }], - [ - "props", - { type: "LINK", props: { src: "https://legacy.example" } }, - { type: "LINK", props: { src: "https://semantic.example" } }, - ], - ["alpha", { type: "TEXT", style: { color: "#11223380" } }, { type: "TEXT", style: { color: "#112233" } }], - ["transparent", { type: "TEXT", style: { color: "transparent" } }, { type: "TEXT", style: { color: "black" } }], - ["unsupported color", { type: "TEXT", style: { color: "brand-ink" } }, { type: "TEXT", style: { color: "black" } }], - ["units", { type: "TEXT", style: { fontSize: "12pt" } }, { type: "TEXT", style: { fontSize: "12px" } }], - ["hierarchy", { type: "VIEW", children: [{ type: "TEXT", value: "same" }] }, { type: "TEXT", value: "same" }], - ["geometry", { type: "VIEW", style: { width: 10 } }, { type: "VIEW", style: { width: 11 } }], - [ - "split wrapper inherited style", - { - type: "TEXT", - style: { color: "#112233" }, - children: [{ type: "TEXT", style: { color: "#112233" }, value: "A" }], - }, - { - type: "TEXT", - style: { color: "#000000" }, - children: [{ type: "TEXT", style: { color: "#112233" }, value: "A" }], - }, - ], - ] as const)("detects %s tampering", (_name, legacy, semantic) => { - expect(compareLegacyParityHostNodes(legacy, semantic)).not.toEqual([]); - }); - - it("normalizes only presentation-neutral empty Text artifacts", () => { - expect( - compareLegacyParityHostNodes( - { - type: "VIEW", - style: { color: "#112233" }, - children: [{ type: "TEXT" }, { type: "TEXT", value: "same" }], - }, - { - type: "VIEW", - style: { color: "#112233" }, - children: [{ type: "TEXT", value: "same" }], - }, - ), - ).toEqual([]); - - for (const meaningfulEmptyText of [ - { type: "TEXT", style: { backgroundColor: "#112233" } }, - { type: "TEXT", style: { width: 1 } }, - { type: "TEXT", props: { id: "semantic-payload" } }, - ] satisfies LegacyParityHostNode[]) { - expect( - compareLegacyParityHostNodes({ type: "VIEW", children: [meaningfulEmptyText] }, { type: "VIEW", children: [] }), - ).not.toEqual([]); - } - }); - - it("retains legitimate inheritance and renderer-default equivalence", () => { - expect( - compareLegacyParityHostNodes( - { - type: "TEXT", - style: { color: "rgb(18, 52, 86)", fontWeight: 400 }, - children: [{ type: "TEXT", value: "same" }], - }, - { type: "TEXT", style: { color: "#123456" }, children: [{ type: "TEXT", value: "same" }] }, - ), - ).toEqual([]); - }); -}); diff --git a/packages/pdf/src/semantic/legacy-parity.ts b/packages/pdf/src/semantic/legacy-parity.ts deleted file mode 100644 index 6e5e0ec61..000000000 --- a/packages/pdf/src/semantic/legacy-parity.ts +++ /dev/null @@ -1,311 +0,0 @@ -import type { ResumeData } from "@reactive-resume/schema/resume/data"; -import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet"; -import type { Template } from "@reactive-resume/schema/templates"; -import { pdf } from "@react-pdf/renderer"; -import { createElement } from "react"; -import { PROPERTY_REGISTRY_V1 } from "@reactive-resume/resume/stylesheet"; -import { styleRulesSchema } from "@reactive-resume/schema/resume/data"; -import { ResumeDocument } from "../document"; - -export type CompareLegacySemanticPresentationInput = { - data: ResumeData; - convertedSource: StylesheetSource; - templates: readonly Template[]; -}; - -export type LegacySemanticPresentationComparison = { - pageCountMismatches: readonly string[]; - primitivePropMismatches: readonly string[]; - mismatches: readonly string[]; -}; - -export type LegacyParityHostNode = { - type: string; - props?: Readonly>; - style?: unknown; - value?: string; - children?: readonly LegacyParityHostNode[]; -}; - -type PrimitiveSnapshot = { - type: string; - text: string; - props: Readonly>; - style: Readonly>; - children: readonly PrimitiveSnapshot[]; -}; - -const primitiveTypes = new Set(["PAGE", "VIEW", "TEXT", "LINK", "IMAGE", "SVG"]); -const inheritableProperties = new Set( - Object.entries(PROPERTY_REGISTRY_V1) - .filter(([, definition]) => definition?.inheritable) - .map(([property]) => property.replace(/-([a-z])/g, (_match, letter: string) => letter.toUpperCase())), -); -const textPrimitiveTypes = new Set(["TEXT", "LINK"]); -const textOnlyInheritedProperties = new Set([ - "fontFamily", - "fontSize", - "fontStyle", - "fontWeight", - "letterSpacing", - "lineHeight", - "textAlign", - "textIndent", - "textTransform", -]); -const rendererInheritedProperties = new Set([ - ...inheritableProperties, - ...textOnlyInheritedProperties, - "color", - "direction", -]); -const rendererDefaults: Readonly> = { fontWeight: 400 }; -const emptyTextResetStyle: Readonly> = { - flexShrink: 1, - maxWidth: "100%", - minWidth: 0, -}; - -const nodeText = (node: LegacyParityHostNode): string => - node.value ?? (node.children ?? []).map((child) => nodeText(child)).join(""); - -const normalizeValue = (value: unknown): unknown => { - if (value === undefined) return "[undefined]"; - if (typeof value === "function") return `[function:${value.name || "anonymous"}]`; - if (Array.isArray(value)) return value.map(normalizeValue); - if (value && typeof value === "object") { - return Object.fromEntries( - Object.entries(value as Record) - .filter(([, entry]) => entry !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([key, entry]) => [key, normalizeValue(entry)]), - ); - } - return value; -}; - -const normalizeHexColor = (value: string): string | undefined => { - const match = /^#([\da-f]{3,4}|[\da-f]{6}|[\da-f]{8})$/i.exec(value); - if (!match) return; - const digits = match[1]?.toLowerCase(); - if (!digits) return; - return digits.length <= 4 ? `#${[...digits].map((digit) => `${digit}${digit}`).join("")}` : `#${digits}`; -}; - -const normalizeRgbColor = (value: string): string | undefined => { - const match = - /^rgba?\(\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)\s*,\s*(\d+(?:\.\d+)?)(?:\s*,\s*(\d*\.?\d+)\s*)?\)$/i.exec(value); - if (!match) return; - const channels = match.slice(1, 4).map(Number); - if (channels.some((channel) => !Number.isFinite(channel) || channel < 0 || channel > 255)) return; - const alpha = match[4] === undefined ? 1 : Number(match[4]); - if (!Number.isFinite(alpha) || alpha < 0 || alpha > 1) return; - const hex = channels.map((channel) => Math.round(channel).toString(16).padStart(2, "0")).join(""); - const alphaHex = Math.round(alpha * 255) - .toString(16) - .padStart(2, "0"); - return `#${hex}${alphaHex === "ff" ? "" : alphaHex}`; -}; - -const normalizeColor = (value: string): string => { - const normalized = value.trim().toLowerCase(); - if (normalized === "transparent") return "#00000000"; - return normalizeHexColor(normalized) ?? normalizeRgbColor(normalized) ?? normalized; -}; - -const normalizeStyleValue = (property: string, value: unknown): unknown => { - if (typeof value === "string" && (property === "color" || property.endsWith("Color"))) { - return normalizeColor(value); - } - if (typeof value === "string" && /^-?\d+(?:\.\d+)?$/.test(value)) return Number.parseFloat(value); - return normalizeValue(value); -}; - -const mergedStyle = (style: unknown): Readonly> => { - const entries = Array.isArray(style) ? style : style ? [style] : []; - const merged = Object.assign({}, ...entries) as Record; - if (merged.borderWidth !== undefined) { - for (const side of ["Top", "Right", "Bottom", "Left"]) { - const property = `border${side}Width`; - if (merged[property] === undefined) merged[property] = merged.borderWidth; - } - delete merged.borderWidth; - } - if (merged.borderRadius !== undefined) { - for (const corner of ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]) { - const property = `border${corner}Radius`; - if (merged[property] === undefined) merged[property] = merged.borderRadius; - } - delete merged.borderRadius; - } - return Object.fromEntries( - Object.entries(merged) - .filter(([, value]) => value !== undefined) - .sort(([left], [right]) => left.localeCompare(right)) - .map(([property, value]) => [property, normalizeStyleValue(property, value)]), - ); -}; - -const collectPrimitiveSnapshots = ( - node: LegacyParityHostNode, - inheritedStyle: Readonly> = rendererDefaults, -): PrimitiveSnapshot[] => { - const localStyle = mergedStyle(node.style); - const computedStyle: Record = { ...localStyle }; - for (const property of rendererInheritedProperties) { - if (!(property in computedStyle) && property in inheritedStyle) computedStyle[property] = inheritedStyle[property]; - } - const nextInherited = Object.fromEntries( - [...rendererInheritedProperties].flatMap((property) => - property in computedStyle ? [[property, computedStyle[property]]] : [], - ), - ); - const children = (node.children ?? []).flatMap((child) => collectPrimitiveSnapshots(child, nextInherited)); - if (!primitiveTypes.has(node.type)) return children; - const snapshotStyle = Object.fromEntries( - Object.entries(computedStyle).filter( - ([property]) => textPrimitiveTypes.has(node.type) || !textOnlyInheritedProperties.has(property), - ), - ); - const props = Object.fromEntries( - Object.entries(node.props ?? {}).filter(([property]) => property !== "children" && property !== "style"), - ); - const isInheritedOnlyStyle = Object.entries(snapshotStyle).every(([property, value]) => - Object.is(value, inheritedStyle[property]), - ); - const hasCompleteEmptyTextReset = - Object.entries(emptyTextResetStyle).every(([property, value]) => Object.is(snapshotStyle[property], value)) && - (snapshotStyle.direction === "ltr" || snapshotStyle.direction === "rtl"); - const isRendererEmptyTextStyle = - hasCompleteEmptyTextReset && - Object.entries(snapshotStyle).every(([property, value]) => { - if (property === "direction") return true; - if (property in emptyTextResetStyle) return Object.is(value, emptyTextResetStyle[property]); - return Object.is(value, inheritedStyle[property]); - }); - const isPresentationNeutralEmptyText = - node.type === "TEXT" && - nodeText(node) === "" && - children.length === 0 && - Object.keys(props).length === 0 && - (isInheritedOnlyStyle || isRendererEmptyTextStyle); - if (isPresentationNeutralEmptyText) return []; - return [ - { - type: node.type, - text: nodeText(node), - props: normalizeValue(props) as Readonly>, - style: snapshotStyle, - children, - }, - ]; -}; - -const waitForDocument = async (instance: ReturnType): Promise => { - const deadline = Date.now() + 5_000; - while (!instance.container.document) { - if (Date.now() >= deadline) throw new Error("Timed out while rendering legacy parity document."); - await new Promise((resolve) => setTimeout(resolve, 0)); - } - return instance.container.document as LegacyParityHostNode; -}; - -const renderSnapshots = async (data: ResumeData, template: Template): Promise => { - const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters[0]; - return collectPrimitiveSnapshots(await waitForDocument(pdf(element))); -}; - -const arrayEntryLabel = (value: unknown): string => { - if (!value || typeof value !== "object" || Array.isArray(value)) return ""; - const entry = value as { type?: unknown; text?: unknown }; - if (typeof entry.type !== "string") return ""; - const text = typeof entry.text === "string" ? entry.text.replace(/\s+/g, " ").trim().slice(0, 32) : ""; - return `<${entry.type}${text ? `:${JSON.stringify(text)}` : ""}>`; -}; - -const diffValues = (legacy: unknown, semantic: unknown, path: string, mismatches: string[]): void => { - if (Object.is(legacy, semantic)) return; - if (Array.isArray(legacy) && Array.isArray(semantic)) { - if (legacy.length !== semantic.length) { - mismatches.push(`${path}.length: legacy=${legacy.length} semantic=${semantic.length}`); - } - for (let index = 0; index < Math.max(legacy.length, semantic.length); index++) { - const label = arrayEntryLabel(legacy[index] ?? semantic[index]); - diffValues(legacy[index], semantic[index], `${path}[${index}]${label}`, mismatches); - } - return; - } - if ( - legacy && - semantic && - typeof legacy === "object" && - typeof semantic === "object" && - !Array.isArray(legacy) && - !Array.isArray(semantic) - ) { - const keys = new Set([ - ...Object.keys(legacy as Record), - ...Object.keys(semantic as Record), - ]); - for (const key of [...keys].sort()) { - diffValues( - (legacy as Record)[key], - (semantic as Record)[key], - `${path}.${key}`, - mismatches, - ); - } - return; - } - mismatches.push(`${path}: legacy=${JSON.stringify(legacy)} semantic=${JSON.stringify(semantic)}`); -}; - -export function compareLegacyParityHostNodes( - legacy: LegacyParityHostNode, - semantic: LegacyParityHostNode, -): readonly string[] { - const mismatches: string[] = []; - diffValues(collectPrimitiveSnapshots(legacy), collectPrimitiveSnapshots(semantic), "root", mismatches); - return mismatches; -} - -export async function compareLegacySemanticPresentation( - input: CompareLegacySemanticPresentationInput, -): Promise { - const sanitizedRules = styleRulesSchema.parse(input.data.metadata.styleRules ?? []); - const { stylesheet: _stylesheet, ...metadata } = input.data.metadata; - const legacyData: ResumeData = { - ...input.data, - metadata: { ...metadata, styleRules: sanitizedRules }, - }; - const semanticData: ResumeData = { - ...input.data, - metadata: { - ...input.data.metadata, - styleRules: sanitizedRules, - stylesheet: { - mode: "semantic", - source: input.convertedSource, - }, - }, - }; - const pageCountMismatches: string[] = []; - const primitivePropMismatches: string[] = []; - - for (const template of input.templates) { - const legacy = await renderSnapshots(legacyData, template); - const semantic = await renderSnapshots(semanticData, template); - const legacyPages = legacy.filter(({ type }) => type === "PAGE").length; - const semanticPages = semantic.filter(({ type }) => type === "PAGE").length; - if (legacyPages !== semanticPages) { - pageCountMismatches.push(`${template}: legacy=${legacyPages} semantic=${semanticPages}`); - } - diffValues(legacy, semantic, template, primitivePropMismatches); - } - - return { - pageCountMismatches, - primitivePropMismatches, - mismatches: [...pageCountMismatches, ...primitivePropMismatches], - }; -} diff --git a/packages/pdf/src/semantic/shared-parts.ts b/packages/pdf/src/semantic/shared-parts.ts index e49e1a4bc..1c35ebc74 100644 --- a/packages/pdf/src/semantic/shared-parts.ts +++ b/packages/pdf/src/semantic/shared-parts.ts @@ -1,4 +1,29 @@ -import type { TemplateSemanticPrimitivePart } from "./template-manifest"; +import type { Template } from "@reactive-resume/schema/templates"; +import type { + TemplateSemanticManifest, + TemplateSemanticPart, + TemplateSemanticPrimitivePart, +} from "./template-manifest"; + +export const standardRegions = [ + { name: "header", placement: "main", origins: [] }, + { name: "main", placement: "main", origins: ["main"] }, + { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, +] as const; + +export const baseManifest = ( + template: T, + parts: P, + canonicalBindings?: TemplateSemanticManifest["canonicalBindings"], +) => + ({ + template, + regions: standardRegions, + header: { region: "header", placement: "main" }, + specialSummary: null, + parts, + ...(canonicalBindings ? { canonicalBindings } : {}), + }) as const satisfies TemplateSemanticManifest; /** * Sections whose item header is a single title/date row rendered by the shared split-row layout. diff --git a/packages/pdf/src/semantic/template-manifest.test.ts b/packages/pdf/src/semantic/template-manifest.test.ts index 0f2826c5a..eabff01db 100644 --- a/packages/pdf/src/semantic/template-manifest.test.ts +++ b/packages/pdf/src/semantic/template-manifest.test.ts @@ -7,11 +7,7 @@ import { TEMPLATE_PART_CHILD_KINDS_V1 } from "@reactive-resume/resume/stylesheet import { defaultResumeData } from "@reactive-resume/schema/resume/default"; import { templateSchema } from "@reactive-resume/schema/templates"; import { createBindingInventory } from "./binding-inventory"; -import { - getTemplateSemanticBindingRegistry, - getTemplateSemanticManifest, - validateTemplateSemanticManifest, -} from "./template-manifest"; +import { getTemplateSemanticBindingRegistry, getTemplateSemanticManifest } from "./template-manifest"; import { buildSemanticTree } from "./tree"; const EXPECTED_ITEM_HEADER_ROW = { @@ -567,171 +563,6 @@ describe("template semantic manifests", () => { }, ); - it("rejects duplicate part names and keys", () => { - const duplicateName = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - const duplicateKey = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - const first = duplicateName.parts[0]; - if (!first) throw new Error("Missing Ditto part fixture"); - - (duplicateName.parts as TemplateSemanticManifest["parts"][number][]).push({ - ...first, - key: "another-key", - }); - (duplicateKey.parts as TemplateSemanticManifest["parts"][number][]).push({ - ...first, - name: "another-name", - }); - - expect(() => validateTemplateSemanticManifest(duplicateName)).toThrow(/duplicate part name/); - expect(() => validateTemplateSemanticManifest(duplicateKey)).toThrow(/duplicate part key/); - }); - - it("rejects unknown placements, owner lies, synthetic wrappers, and missing or invented chrome", () => { - const unknownRegionPlacement = structuredClone( - getTemplateSemanticManifest("chikorita"), - ) as TemplateSemanticManifest; - const unknownHeaderPlacement = structuredClone( - getTemplateSemanticManifest("chikorita"), - ) as TemplateSemanticManifest; - const ownerLie = structuredClone(getTemplateSemanticManifest("bronzor")) as TemplateSemanticManifest; - const synthetic = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - const missing = structuredClone(getTemplateSemanticManifest("azurill")) as TemplateSemanticManifest; - const invented = structuredClone(getTemplateSemanticManifest("chikorita")) as TemplateSemanticManifest; - - (unknownRegionPlacement.regions[0] as { placement: string }).placement = "footer"; - (unknownHeaderPlacement.header as { placement: string }).placement = "footer"; - const alias = ownerLie.parts.find((part) => part.name === "interleaved-section-row")?.binding; - if (alias?.type !== "alias") throw new Error("Missing Bronzor alias fixture"); - (alias as { canonicalKind: string }).canonicalKind = "item"; - const primitive = synthetic.parts[0]?.binding; - if (primitive?.type !== "primitive") throw new Error("Missing Ditto primitive fixture"); - (primitive as unknown as { source: string }).source = "synthetic"; - (missing.parts as TemplateSemanticManifest["parts"][number][]).pop(); - (invented.parts as TemplateSemanticManifest["parts"][number][]).push({ - name: "invented-wrapper", - key: "invented-wrapper", - owner: { kind: "header", key: "header" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "start" }, - }); - - expect(() => validateTemplateSemanticManifest(unknownRegionPlacement)).toThrow(/unknown region placement/); - expect(() => validateTemplateSemanticManifest(unknownHeaderPlacement)).toThrow(/unknown header placement/); - expect(() => validateTemplateSemanticManifest(ownerLie)).toThrow(/aliases a non-owner primitive/); - expect(() => validateTemplateSemanticManifest(synthetic)).toThrow(/synthetic wrapper/); - expect(() => validateTemplateSemanticManifest(missing)).toThrow(/frozen renderer contract/); - expect(() => validateTemplateSemanticManifest(invented)).toThrow(/frozen renderer contract/); - }); - - it("rejects exact renderer-contract mutations across layout, routing, bindings, conditions, and chrome", () => { - const removedMain = structuredClone(getTemplateSemanticManifest("chikorita")) as TemplateSemanticManifest; - const wrongOrigin = structuredClone(getTemplateSemanticManifest("chikorita")) as TemplateSemanticManifest; - const wrongFlow = structuredClone(getTemplateSemanticManifest("bronzor")) as TemplateSemanticManifest; - const wrongPrimitive = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - const primitiveToAlias = structuredClone(getTemplateSemanticManifest("rhyhorn")) as TemplateSemanticManifest; - const aliasToPrimitive = structuredClone(getTemplateSemanticManifest("bronzor")) as TemplateSemanticManifest; - const wrongSummary = structuredClone(getTemplateSemanticManifest("leafish")) as TemplateSemanticManifest; - const unknownOwner = structuredClone(getTemplateSemanticManifest("bronzor")) as TemplateSemanticManifest; - const changedCondition = structuredClone(getTemplateSemanticManifest("azurill")) as TemplateSemanticManifest; - const changedSelectorCondition = structuredClone(getTemplateSemanticManifest("meowth")) as TemplateSemanticManifest; - const missingChrome = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - const extraChrome = structuredClone(getTemplateSemanticManifest("ditto")) as TemplateSemanticManifest; - - (removedMain.regions as TemplateSemanticManifest["regions"][number][]).splice(1, 1); - (wrongOrigin.regions[1]?.origins as string[])[0] = "sidebar"; - (wrongFlow.regions[1] as { flow: string }).flow = "sequential"; - const headerBand = wrongPrimitive.parts.find((part) => part.name === "header-band"); - if (headerBand?.binding.type !== "primitive") throw new Error("Missing Ditto header band"); - (headerBand.binding as { primitive: string }).primitive = "Text"; - const content = primitiveToAlias.parts.find((part) => part.name === "contact-item-content"); - if (!content) throw new Error("Missing Rhyhorn content"); - (content as unknown as { binding: object; route?: object }).binding = { - type: "alias", - canonicalKind: "contact-item", - token: "contact-item-content", - }; - delete (content as unknown as { route?: object }).route; - const row = aliasToPrimitive.parts.find((part) => part.name === "interleaved-section-row"); - if (!row) throw new Error("Missing Bronzor row"); - (row as unknown as { binding: object; route?: object }).binding = { - type: "primitive", - primitive: "View", - source: "existing", - }; - (row as unknown as { route?: object }).route = { parent: "owner", at: "start" }; - if (!wrongSummary.specialSummary) throw new Error("Missing Leafish summary"); - (wrongSummary.specialSummary as { source: string }).source = "main-with-header"; - (unknownOwner.parts[0]?.owner as unknown as { key: string }).key = "unknown"; - const line = changedCondition.parts.find((part) => part.name === "timeline-line"); - if (!line || !("columns" in line.owner)) throw new Error("Missing Azurill timeline line"); - delete (line.owner as { columns?: number }).columns; - const leading = changedSelectorCondition.parts.find((part) => part.name === "inline-item-header-leading"); - if (leading?.binding.type !== "primitive" || !leading.route || !Array.isArray(leading.route.take)) { - throw new Error("Missing Meowth leading selectors"); - } - (leading.route.take[0]?.sectionTypes as string[])[0] = "education"; - (missingChrome.parts as TemplateSemanticManifest["parts"][number][]).pop(); - const extra = structuredClone(extraChrome.parts[0]); - if (!extra) throw new Error("Missing Ditto chrome"); - (extra as { name: string; key: string }).name = "extra"; - (extra as { name: string; key: string }).key = "extra"; - (extraChrome.parts as TemplateSemanticManifest["parts"][number][]).push(extra); - - for (const mutation of [ - removedMain, - wrongOrigin, - wrongFlow, - wrongPrimitive, - primitiveToAlias, - aliasToPrimitive, - wrongSummary, - unknownOwner, - changedCondition, - changedSelectorCondition, - missingChrome, - extraChrome, - ]) { - expect(() => validateTemplateSemanticManifest(mutation)).toThrow(); - } - }); - - it("rejects empty and unknown selector section conditions before exact-contract comparison", () => { - const empty = structuredClone(getTemplateSemanticManifest("meowth")) as TemplateSemanticManifest; - const unknown = structuredClone(getTemplateSemanticManifest("meowth")) as TemplateSemanticManifest; - const getFirstSelector = (manifest: TemplateSemanticManifest) => { - const leading = manifest.parts.find((part) => part.name === "inline-item-header-leading"); - if ( - leading?.binding.type !== "primitive" || - !leading.route || - !Array.isArray(leading.route.take) || - !leading.route.take[0] - ) { - throw new Error("Missing Meowth leading selector"); - } - return leading.route.take[0]; - }; - - (getFirstSelector(empty) as { sectionTypes: string[] }).sectionTypes = []; - (getFirstSelector(unknown) as { sectionTypes: string[] }).sectionTypes = ["unknown"]; - - expect(() => validateTemplateSemanticManifest(empty)).toThrow(/selector section types must not be empty/); - expect(() => validateTemplateSemanticManifest(unknown)).toThrow(/selector has an unknown section type/); - }); - - it("rejects cyclic and unsupported-depth primitive routing before exact-contract comparison", () => { - const cycle = structuredClone(getTemplateSemanticManifest("azurill")) as TemplateSemanticManifest; - const tooDeep = structuredClone(getTemplateSemanticManifest("azurill")) as TemplateSemanticManifest; - const marker = cycle.parts.find((part) => part.name === "timeline-marker"); - if (marker?.binding.type !== "primitive") throw new Error("Missing Azurill marker"); - (marker.route as { parent: string }).parent = "timeline-dot"; - const content = tooDeep.parts.find((part) => part.name === "timeline-content"); - if (content?.binding.type !== "primitive") throw new Error("Missing Azurill content"); - (content.route as { parent: string }).parent = "timeline-dot"; - - expect(() => validateTemplateSemanticManifest(cycle)).toThrow(/cyclic part routing/); - expect(() => validateTemplateSemanticManifest(tooDeep)).toThrow(/unsupported part routing depth/); - }); - it.each(templateSchema.options)("%s builds its manifest-backed tree without key collisions", (template) => { const tree = buildFixtureTree(template); const nodes = flattenTree(tree); @@ -981,8 +812,8 @@ describe("template semantic manifests", () => { ); expect(aliasedSections.length).toBeGreaterThan(0); - expect(aliasedSections.map((node) => inventory.aliasTokensByNodeKey[node.key])).toEqual( - aliasedSections.map(() => ["interleaved-section-row"]), + expect(aliasedSections.map((node) => node.attributes.part)).toEqual( + aliasedSections.map(() => "interleaved-section-row"), ); expect( Object.values(inventory.bindings).some( @@ -1034,7 +865,6 @@ describe("template semantic manifests", () => { ).toEqual([contacts[0]?.key, linkedContent?.key]); expect(contacts[0]?.attributes.part).toBeUndefined(); expect(contacts[1]?.attributes.part).toBe("contact-item-last"); - expect(inventory.aliasTokensByNodeKey[contacts[1]?.key ?? ""]).toEqual(["contact-item-last"]); expect(findPart(tree, "contact-item-last")).toBeUndefined(); }); @@ -1379,8 +1209,6 @@ describe("template semantic manifests", () => { const inventory = createBindingInventory(tree, getTemplateSemanticBindingRegistry(template)); const nodeCount = flattenTree(tree).length; - expect(inventory.unboundNodeKeys).toEqual([]); - expect(inventory.syntheticWrapperCount).toBe(0); expect(Object.keys(inventory.bindings)).toHaveLength(nodeCount); for (const partNode of findNodes(tree, (node) => node.kind === "template-part")) { const binding = inventory.bindings[partNode.key]; diff --git a/packages/pdf/src/semantic/template-manifest.ts b/packages/pdf/src/semantic/template-manifest.ts index 21a7f9887..a6b011626 100644 --- a/packages/pdf/src/semantic/template-manifest.ts +++ b/packages/pdf/src/semantic/template-manifest.ts @@ -2,7 +2,6 @@ import type { SemanticNodeKind } from "@reactive-resume/resume/stylesheet/types" import type { CustomSectionType } from "@reactive-resume/schema/resume/data"; import type { Template } from "@reactive-resume/schema/templates"; import type { PrimitiveBinding, SemanticBindingRegistry } from "./binding-inventory"; -import { templateSchema } from "@reactive-resume/schema/templates"; import { azurillSemanticManifest } from "../templates/azurill/semantic"; import { bronzorSemanticManifest } from "../templates/bronzor/semantic"; import { chikoritaSemanticManifest } from "../templates/chikorita/semantic"; @@ -18,7 +17,7 @@ import { onyxSemanticManifest } from "../templates/onyx/semantic"; import { pikachuSemanticManifest } from "../templates/pikachu/semantic"; import { rhyhornSemanticManifest } from "../templates/rhyhorn/semantic"; import { scizorSemanticManifest } from "../templates/scizor/semantic"; -import { SHARED_BINDING_REGISTRY, STANDARD_FIELD_REGISTRY } from "./binding-inventory"; +import { SHARED_BINDING_REGISTRY } from "./binding-inventory"; export type TemplateSemanticPlacement = "main" | "sidebar"; type TemplateSemanticRegionName = "header" | "main" | "sidebar" | "featured"; @@ -132,175 +131,6 @@ export type TemplateSemanticManifest = { canonicalBindings?: readonly TemplateSemanticCanonicalBinding[]; }; -const OWNER_KEYS = { - header: "header", - region: undefined, - section: "section", - "section-items": "section-items", - item: "item", - "item-header": "item-header", - "contact-list": "contact-list", - "contact-item": "contact-item", -} as const satisfies Readonly>; - -const PLACEMENTS = new Set(["main", "sidebar"]); -const REGION_NAMES = new Set(["header", "main", "sidebar", "featured"]); -const PRIMITIVES = new Set(["Document", "Page", "View", "Text", "Link", "Image", "Svg"]); -const SECTION_TYPES = new Set( - Object.keys(STANDARD_FIELD_REGISTRY).filter( - (sectionType) => sectionType !== "experience-role", - ) as CustomSectionType[], -); -const MAX_PART_DEPTH = 1; - -const assert: (condition: unknown, message: string) => asserts condition = (condition, message) => { - if (!condition) throw new Error(message); -}; -const isPrimitivePart = (part: TemplateSemanticPart): part is TemplateSemanticPrimitivePart => - part.binding.type === "primitive"; - -const deepFreeze = (value: T): Readonly => { - if (typeof value !== "object" || value === null || Object.isFrozen(value)) return value; - - for (const child of Object.values(value)) deepFreeze(child); - return Object.freeze(value); -}; - -function validateTemplateSemanticManifestShape(manifest: TemplateSemanticManifest): void { - const regionNames = new Set(); - const partNames = new Set(); - const partKeys = new Set(); - - assert(templateSchema.options.includes(manifest.template), `Unknown template: ${manifest.template}`); - for (const region of manifest.regions) { - assert(REGION_NAMES.has(region.name), `${manifest.template}: unknown region ${region.name}`); - assert(!regionNames.has(region.name), `${manifest.template}: duplicate region ${region.name}`); - assert(PLACEMENTS.has(region.placement), `${manifest.template}: unknown region placement ${region.placement}`); - assert( - region.origins.every((origin) => PLACEMENTS.has(origin)), - `${manifest.template}: unknown origin in region ${region.name}`, - ); - assert( - region.flow !== "interleaved" || region.origins.length === 2, - `${manifest.template}: interleaved regions require two origins`, - ); - regionNames.add(region.name); - } - - assert(regionNames.has(manifest.header.region), `${manifest.template}: header region is not registered`); - assert(PLACEMENTS.has(manifest.header.placement), `${manifest.template}: unknown header placement`); - const headerRegion = manifest.regions.find((region) => region.name === manifest.header.region); - assert( - headerRegion?.placement === manifest.header.placement, - `${manifest.template}: header placement disagrees with region`, - ); - - if (manifest.specialSummary) { - const summaryRegion = manifest.regions.find((region) => region.name === manifest.specialSummary?.region); - assert(summaryRegion, `${manifest.template}: special summary region is not registered`); - assert( - summaryRegion.placement === manifest.specialSummary.placement, - `${manifest.template}: special summary placement disagrees with region`, - ); - } - - for (const part of manifest.parts) { - assert(!partNames.has(part.name), `${manifest.template}: duplicate part name ${part.name}`); - assert(!partKeys.has(part.key), `${manifest.template}: duplicate part key ${part.key}`); - assert(part.name.length > 0 && part.key.length > 0, `${manifest.template}: part names and keys must not be empty`); - - const expectedOwnerKey = OWNER_KEYS[part.owner.kind]; - if (part.owner.kind === "region") { - assert(regionNames.has(part.owner.key), `${manifest.template}: part ${part.name} owns an unknown region`); - } else { - assert(part.owner.key === expectedOwnerKey, `${manifest.template}: part ${part.name} has an invalid owner key`); - } - - if ("placement" in part.owner && part.owner.placement !== undefined) { - assert(PLACEMENTS.has(part.owner.placement), `${manifest.template}: part ${part.name} has an unknown placement`); - } - - if (isPrimitivePart(part)) { - assert(part.binding.source === "existing", `${manifest.template}: part ${part.name} claims a synthetic wrapper`); - const primitives = - typeof part.binding.primitive === "string" - ? [part.binding.primitive] - : [part.binding.primitive.present, part.binding.primitive.absent]; - assert( - primitives.every((primitive) => PRIMITIVES.has(primitive)), - `${manifest.template}: unknown primitive`, - ); - assert(part.route.parent.length > 0, `${manifest.template}: part ${part.name} has no routing parent`); - if (part.route.takeFrom) { - assert(part.owner.kind === "item", `${manifest.template}: lifted part ${part.name} must be owned by an item`); - assert( - part.route.parent === "owner", - `${manifest.template}: lifted part ${part.name} must route directly under its owner`, - ); - assert(Array.isArray(part.route.take), `${manifest.template}: lifted part ${part.name} requires selectors`); - } - const selectors: readonly TemplateSemanticChildSelector[] = [ - ...(Array.isArray(part.route.take) ? part.route.take : []), - ...(typeof part.route.at === "object" - ? ["before" in part.route.at ? part.route.at.before : part.route.at.after] - : []), - ]; - for (const selector of selectors) { - if (!selector.sectionTypes) continue; - assert(selector.sectionTypes.length > 0, `${manifest.template}: selector section types must not be empty`); - assert( - selector.sectionTypes.every((sectionType) => SECTION_TYPES.has(sectionType)), - `${manifest.template}: selector has an unknown section type`, - ); - } - } else { - assert( - part.binding.canonicalKind === part.owner.kind, - `${manifest.template}: part ${part.name} aliases a non-owner primitive`, - ); - assert(part.binding.token.length > 0, `${manifest.template}: part ${part.name} has an empty alias token`); - } - - partNames.add(part.name); - partKeys.add(part.key); - } - - for (const part of manifest.parts) { - if (!isPrimitivePart(part) || part.route.parent === "owner") continue; - const parentPart = manifest.parts.find((candidate) => candidate.name === part.route.parent); - assert(parentPart, `${manifest.template}: part ${part.name} owns an unknown parent part`); - assert( - parentPart.binding.type === "primitive", - `${manifest.template}: nested part ${part.name} requires an existing primitive parent`, - ); - assert( - JSON.stringify(parentPart.owner) === JSON.stringify(part.owner), - `${manifest.template}: nested part ${part.name} disagrees with its semantic owner`, - ); - } - - for (const part of manifest.parts) { - if (!isPrimitivePart(part)) continue; - const seen = new Set([part.name]); - let parent = part.route.parent; - let depth = 0; - while (parent !== "owner") { - assert(!seen.has(parent), `${manifest.template}: cyclic part routing`); - seen.add(parent); - const parentPart = manifest.parts.find((candidate) => candidate.name === parent); - assert(parentPart && isPrimitivePart(parentPart), `${manifest.template}: unknown part routing parent`); - depth += 1; - assert(depth <= MAX_PART_DEPTH, `${manifest.template}: unsupported part routing depth`); - parent = parentPart.route.parent; - } - } - - for (const override of manifest.canonicalBindings ?? []) { - assert(override.binding.source === "existing", `${manifest.template}: canonical binding must be existing`); - assert(PRIMITIVES.has(override.binding.primitive), `${manifest.template}: canonical binding has unknown primitive`); - } -} - const TEMPLATE_SEMANTIC_MANIFESTS = { azurill: azurillSemanticManifest, bronzor: bronzorSemanticManifest, @@ -319,18 +149,6 @@ const TEMPLATE_SEMANTIC_MANIFESTS = { scizor: scizorSemanticManifest, } as const satisfies Readonly>; -for (const manifest of Object.values(TEMPLATE_SEMANTIC_MANIFESTS)) validateTemplateSemanticManifestShape(manifest); -deepFreeze(TEMPLATE_SEMANTIC_MANIFESTS); - -export function validateTemplateSemanticManifest(manifest: TemplateSemanticManifest): void { - validateTemplateSemanticManifestShape(manifest); - const expected = TEMPLATE_SEMANTIC_MANIFESTS[manifest.template]; - assert( - JSON.stringify(manifest) === JSON.stringify(expected), - `${manifest.template}: manifest differs from its frozen renderer contract`, - ); -} - export function getTemplateSemanticManifest(template: Template): TemplateSemanticManifest { return TEMPLATE_SEMANTIC_MANIFESTS[template]; } diff --git a/packages/pdf/src/semantic/tree.ts b/packages/pdf/src/semantic/tree.ts index 5c813f853..cb1159df5 100644 --- a/packages/pdf/src/semantic/tree.ts +++ b/packages/pdf/src/semantic/tree.ts @@ -1002,8 +1002,4 @@ export type { TemplateSemanticManifest } from "./template-manifest"; export { shouldShowResumeHeader } from "../templates/shared/cover-letter"; export { createBindingInventory, STANDARD_FIELD_REGISTRY, STANDARD_ROLE_REGISTRY } from "./binding-inventory"; export { semanticNodeKeys } from "./node-keys"; -export { - getTemplateSemanticBindingRegistry, - getTemplateSemanticManifest, - validateTemplateSemanticManifest, -} from "./template-manifest"; +export { getTemplateSemanticBindingRegistry, getTemplateSemanticManifest } from "./template-manifest"; diff --git a/packages/pdf/src/templates/azurill/AzurillPage.tsx b/packages/pdf/src/templates/azurill/AzurillPage.tsx index c3c3ceead..f50652ed9 100644 --- a/packages/pdf/src/templates/azurill/AzurillPage.tsx +++ b/packages/pdf/src/templates/azurill/AzurillPage.tsx @@ -8,34 +8,17 @@ import type { TemplateStyleSlots, } from "../shared/types"; import { Fragment, useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; -import { - CustomFieldContactItem, - EmailContactItem, - LocationContactItem, - PhoneContactItem, - WebsiteContactItem, -} from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; -import { hasTemplatePicture } from "../shared/picture"; -import { - Heading, - SemanticContactListView, - SemanticHeaderPicture, - SemanticHeaderView, - SemanticRegionView, - Text, -} from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; +import { SemanticRegionView } from "../shared/primitives"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { createIconSlot, TemplateHeader, useTemplateBase } from "../shared/template-base"; type AzurillStyles = Omit & { page: Style; @@ -117,46 +100,26 @@ export const AzurillPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pa ); }; -const Header = ({ styles }: AzurillHeaderProps) => { - const { basics, picture } = useRender(); - const hasPicture = hasTemplatePicture(picture); - - return ( - - {hasPicture && } - - - - {basics.name} - {basics.headline} - - - - - - - - - {basics.customFields.map((field) => ( - - ))} - - - ); -}; +const Header = ({ styles }: AzurillHeaderProps) => ( + +); const useAzurillTemplate = (): AzurillTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -285,12 +248,21 @@ const useAzurillTemplate = (): AzurillTemplate => { richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }), richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }), sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies AzurillStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + primary, + metrics.page.paddingVertical, + metrics.page.paddingHorizontal, + metrics.gapX, + metrics.headerGap, + metrics.columnGap, + base, + metrics.gapY, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/azurill/semantic.ts b/packages/pdf/src/templates/azurill/semantic.ts index bc8c6fca7..50633b721 100644 --- a/packages/pdf/src/templates/azurill/semantic.ts +++ b/packages/pdf/src/templates/azurill/semantic.ts @@ -1,44 +1,34 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const azurillSemanticManifest = { - template: "azurill", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ - itemHeaderRowPart, - { - name: "timeline-line", - key: "timeline-line", - owner: { kind: "section-items", key: "section-items", placement: "main", columns: 1 }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "start" }, - }, - { - name: "timeline-marker", - key: "timeline-marker", - owner: { kind: "item", key: "item", placement: "main", columns: 1 }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "start" }, - }, - { - name: "timeline-dot", - key: "timeline-dot", - owner: { kind: "item", key: "item", placement: "main", columns: 1 }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "timeline-marker", at: "start" }, - }, - { - name: "timeline-content", - key: "timeline-content", - owner: { kind: "item", key: "item", placement: "main", columns: 1 }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "end", take: "all" }, - }, - ], -} as const satisfies TemplateSemanticManifest; +export const azurillSemanticManifest = baseManifest("azurill", [ + itemHeaderRowPart, + { + name: "timeline-line", + key: "timeline-line", + owner: { kind: "section-items", key: "section-items", placement: "main", columns: 1 }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: "start" }, + }, + { + name: "timeline-marker", + key: "timeline-marker", + owner: { kind: "item", key: "item", placement: "main", columns: 1 }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: "start" }, + }, + { + name: "timeline-dot", + key: "timeline-dot", + owner: { kind: "item", key: "item", placement: "main", columns: 1 }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "timeline-marker", at: "start" }, + }, + { + name: "timeline-content", + key: "timeline-content", + owner: { kind: "item", key: "item", placement: "main", columns: 1 }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: "end", take: "all" }, + }, +]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/bronzor/BronzorPage.tsx b/packages/pdf/src/templates/bronzor/BronzorPage.tsx index df28f0b83..c127eb130 100644 --- a/packages/pdf/src/templates/bronzor/BronzorPage.tsx +++ b/packages/pdf/src/templates/bronzor/BronzorPage.tsx @@ -2,34 +2,17 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; -import { Page, StyleSheet, View } from "#react-pdf-renderer"; +import { Page, StyleSheet } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; -import { - CustomFieldContactItem, - EmailContactItem, - LocationContactItem, - PhoneContactItem, - WebsiteContactItem, -} from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; -import { hasTemplatePicture } from "../shared/picture"; -import { - Heading, - SemanticContactListView, - SemanticHeaderPicture, - SemanticHeaderView, - SemanticRegionView, - Text, -} from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; +import { SemanticRegionView } from "../shared/primitives"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { TemplateHeader, useTemplateBase } from "../shared/template-base"; type BronzorStyles = Omit & { page: Style; @@ -107,46 +90,26 @@ export const BronzorPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pa ); }; -const Header = ({ styles }: BronzorHeaderProps) => { - const { basics, picture } = useRender(); - const hasPicture = hasTemplatePicture(picture); - - return ( - - {hasPicture && } - - - - {basics.name} - {basics.headline} - - - - - - - - - {basics.customFields.map((field) => ( - - ))} - - - ); -}; +const Header = ({ styles }: BronzorHeaderProps) => ( + +); const useBronzorTemplate = (): BronzorTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -215,5 +178,19 @@ const useBronzorTemplate = (): BronzorTemplate => { }); return { colors, styles: baseStyles satisfies BronzorStyles }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.sectionHeadingTextAlign, + r.row, + primary, + metrics.page.paddingVertical, + metrics.page.paddingHorizontal, + metrics.headerGap, + metrics.columnGap, + metrics.gapY, + foreground, + base, + metrics.gapX, + background, + ]); }; diff --git a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx index de3de8a84..4b938fdd1 100644 --- a/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx +++ b/packages/pdf/src/templates/chikorita/ChikoritaPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { Fragment, useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -29,9 +27,9 @@ import { SemanticRegionView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type ChikoritaStyles = Omit & { page: Style; @@ -161,13 +159,9 @@ const Header = ({ styles }: ChikoritaHeaderProps) => { }; const useChikoritaTemplate = (): ChikoritaTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, @@ -175,9 +169,6 @@ const useChikoritaTemplate = (): ChikoritaTemplate => { sidebarForeground: background, sidebarBackground: primary, }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -285,12 +276,8 @@ const useChikoritaTemplate = (): ChikoritaTemplate => { }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies ChikoritaStyles, }; - }, [picture, metadata, rtl]); + }, [metadata, r.row, r.headerIdentity, metrics.gapX, primary, foreground, base, metrics.gapY, background]); }; diff --git a/packages/pdf/src/templates/chikorita/semantic.ts b/packages/pdf/src/templates/chikorita/semantic.ts index aea218858..66a8d64e4 100644 --- a/packages/pdf/src/templates/chikorita/semantic.ts +++ b/packages/pdf/src/templates/chikorita/semantic.ts @@ -1,45 +1,35 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const chikoritaSemanticManifest = { - template: "chikorita", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ - itemHeaderRowPart, - { - name: "contact-row-primary", - key: "contact-row-primary", - owner: { kind: "contact-list", key: "contact-list" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { - parent: "owner", - at: "start", - take: [ - { kind: "contact-item", name: "email" }, - { kind: "contact-item", name: "phone" }, - { kind: "contact-item", name: "location" }, - ], - }, +export const chikoritaSemanticManifest = baseManifest("chikorita", [ + itemHeaderRowPart, + { + name: "contact-row-primary", + key: "contact-row-primary", + owner: { kind: "contact-list", key: "contact-list" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { + parent: "owner", + at: "start", + take: [ + { kind: "contact-item", name: "email" }, + { kind: "contact-item", name: "phone" }, + { kind: "contact-item", name: "location" }, + ], }, - { - name: "contact-row-secondary", - key: "contact-row-secondary", - owner: { kind: "contact-list", key: "contact-list" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { - parent: "owner", - at: "end", - take: [ - { kind: "contact-item", name: "website" }, - { kind: "contact-item", name: "custom" }, - ], - }, + }, + { + name: "contact-row-secondary", + key: "contact-row-secondary", + owner: { kind: "contact-list", key: "contact-list" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { + parent: "owner", + at: "end", + take: [ + { kind: "contact-item", name: "website" }, + { kind: "contact-item", name: "custom" }, + ], }, - ], -} as const satisfies TemplateSemanticManifest; + }, +]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/ditgar/DitgarPage.tsx b/packages/pdf/src/templates/ditgar/DitgarPage.tsx index 3d793445d..76e519dd2 100644 --- a/packages/pdf/src/templates/ditgar/DitgarPage.tsx +++ b/packages/pdf/src/templates/ditgar/DitgarPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { getPrimaryTint } from "../shared/color-helpers"; import { CustomFieldContactItem, @@ -31,9 +29,9 @@ import { SemanticRegionView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { useTemplateBase } from "../shared/template-base"; type DitgarStyles = Omit & { page: Style; @@ -202,13 +200,9 @@ const Header = ({ styles, colors }: DitgarHeaderProps) => { }; const useDitgarTemplate = (): DitgarTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2); const colors: TemplateColorRoles = { foreground, @@ -217,9 +211,6 @@ const useDitgarTemplate = (): DitgarTemplate => { sidebarForeground: foreground, sidebarBackground: primaryTint, }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -351,5 +342,17 @@ const useDitgarTemplate = (): DitgarTemplate => { }), } satisfies DitgarStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.headerIdentity, + primary, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/ditto/DittoPage.tsx b/packages/pdf/src/templates/ditto/DittoPage.tsx index 01aabcfaa..0c29d13d3 100644 --- a/packages/pdf/src/templates/ditto/DittoPage.tsx +++ b/packages/pdf/src/templates/ditto/DittoPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { Fragment, useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -28,9 +26,9 @@ import { SemanticTemplatePartView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { useTemplateBase } from "../shared/template-base"; type DittoStyles = Omit & { page: Style; @@ -158,19 +156,12 @@ const Header = ({ styles }: DittoHeaderProps) => { }; const useDittoTemplate = (): DittoTemplate => { - const { picture, metadata, rtl } = useRender(); + const { picture, metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); const hasPicture = hasTemplatePicture(picture); - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); - const baseStyles = StyleSheet.create({ ...base, picture: { @@ -304,5 +295,18 @@ const useDittoTemplate = (): DittoTemplate => { }, } satisfies DittoStyles, }; - }, [picture, metadata, rtl]); + }, [ + picture, + metadata, + r.row, + r.headerIdentity, + primary, + metrics.page.paddingVertical, + metrics.gapX, + metrics.page.paddingHorizontal, + foreground, + base, + metrics.gapY, + background, + ]); }; diff --git a/packages/pdf/src/templates/ditto/semantic.ts b/packages/pdf/src/templates/ditto/semantic.ts index 44c5cb16d..ffd8ee11d 100644 --- a/packages/pdf/src/templates/ditto/semantic.ts +++ b/packages/pdf/src/templates/ditto/semantic.ts @@ -1,41 +1,31 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const dittoSemanticManifest = { - template: "ditto", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ - itemHeaderRowPart, - { - name: "header-band", - key: "header-band", - owner: { kind: "header", key: "header" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { - parent: "owner", - at: "start", - take: [{ kind: "picture" }, { kind: "name" }, { kind: "headline" }], - }, +export const dittoSemanticManifest = baseManifest("ditto", [ + itemHeaderRowPart, + { + name: "header-band", + key: "header-band", + owner: { kind: "header", key: "header" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { + parent: "owner", + at: "start", + take: [{ kind: "picture" }, { kind: "name" }, { kind: "headline" }], }, - { - name: "picture-anchor", - key: "picture-anchor", - owner: { kind: "header", key: "header" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "header-band", at: "start", take: [{ kind: "picture" }] }, - }, - { - name: "contact-offset", - key: "contact-offset", - owner: { kind: "header", key: "header" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: { before: { kind: "contact-list" } } }, - }, - ], -} as const satisfies TemplateSemanticManifest; + }, + { + name: "picture-anchor", + key: "picture-anchor", + owner: { kind: "header", key: "header" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "header-band", at: "start", take: [{ kind: "picture" }] }, + }, + { + name: "contact-offset", + key: "contact-offset", + owner: { kind: "header", key: "header" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: { before: { kind: "contact-list" } } }, + }, +]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/gengar/GengarPage.tsx b/packages/pdf/src/templates/gengar/GengarPage.tsx index 52947690a..632e27798 100644 --- a/packages/pdf/src/templates/gengar/GengarPage.tsx +++ b/packages/pdf/src/templates/gengar/GengarPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { Fragment, useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { getPrimaryTint } from "../shared/color-helpers"; import { CustomFieldContactItem, @@ -30,9 +28,9 @@ import { SemanticRegionView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { useTemplateBase } from "../shared/template-base"; type GengarStyles = Omit & { page: Style; @@ -185,13 +183,9 @@ const Header = ({ styles, colors }: GengarHeaderProps) => { }; const useGengarTemplate = (): GengarTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2); const colors: TemplateColorRoles = { foreground, @@ -200,9 +194,6 @@ const useGengarTemplate = (): GengarTemplate => { sidebarForeground: foreground, sidebarBackground: primaryTint, }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -329,5 +320,18 @@ const useGengarTemplate = (): GengarTemplate => { }), } satisfies GengarStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.headerIdentity, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/glalie/GlaliePage.tsx b/packages/pdf/src/templates/glalie/GlaliePage.tsx index 1cd14e923..21db46e9d 100644 --- a/packages/pdf/src/templates/glalie/GlaliePage.tsx +++ b/packages/pdf/src/templates/glalie/GlaliePage.tsx @@ -2,37 +2,19 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { getPrimaryTint } from "../shared/color-helpers"; -import { - CustomFieldContactItem, - EmailContactItem, - LocationContactItem, - PhoneContactItem, - WebsiteContactItem, -} from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; import { PageMarginBackground } from "../shared/page-margin-background"; -import { hasTemplatePicture } from "../shared/picture"; -import { - Heading, - SemanticContactListView, - SemanticHeaderPicture, - SemanticHeaderView, - SemanticRegionView, - SemanticTemplatePartView, - Text, -} from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; +import { SemanticRegionView, SemanticTemplatePartView } from "../shared/primitives"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { createIconSlot, TemplateHeader, useTemplateBase } from "../shared/template-base"; type GlalieStyles = Omit & { page: Style; @@ -135,42 +117,25 @@ export const GlaliePage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag ); }; -const Header = ({ styles }: GlalieHeaderProps) => { - const { basics, picture } = useRender(); - const hasPicture = hasTemplatePicture(picture); - - return ( - - {hasPicture && } - - - - {basics.name} - {basics.headline} - - - - - - - - - {basics.customFields.map((field) => ( - - ))} - - - ); -}; +const Header = ({ styles }: GlalieHeaderProps) => ( + +); const useGlalieTemplate = (): GlalieTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const primaryTint = getPrimaryTint(metadata.design.colors.primary, 0.2); const colors: TemplateColorRoles = { foreground, @@ -179,9 +144,6 @@ const useGlalieTemplate = (): GlalieTemplate => { sidebarForeground: foreground, sidebarBackground: primaryTint, }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -285,12 +247,21 @@ const useGlalieTemplate = (): GlalieTemplate => { sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies GlalieStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.anchorToStart, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/index.ts b/packages/pdf/src/templates/index.ts index 485979653..2cef7dcd1 100644 --- a/packages/pdf/src/templates/index.ts +++ b/packages/pdf/src/templates/index.ts @@ -39,8 +39,4 @@ const defaultTemplatePage = AzurillPage; export const getTemplatePage = (template: Template): TemplatePage => templatePages[template] ?? defaultTemplatePage; export type { TemplateSemanticManifest } from "../semantic/template-manifest"; -export { - getTemplateSemanticBindingRegistry, - getTemplateSemanticManifest, - validateTemplateSemanticManifest, -} from "../semantic/template-manifest"; +export { getTemplateSemanticBindingRegistry, getTemplateSemanticManifest } from "../semantic/template-manifest"; diff --git a/packages/pdf/src/templates/kakuna/KakunaPage.tsx b/packages/pdf/src/templates/kakuna/KakunaPage.tsx index 6af58ca7e..a656dc839 100644 --- a/packages/pdf/src/templates/kakuna/KakunaPage.tsx +++ b/packages/pdf/src/templates/kakuna/KakunaPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -27,9 +25,9 @@ import { SemanticRegionView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type KakunaStyles = Omit & { page: Style; @@ -122,17 +120,10 @@ const Header = ({ styles }: KakunaHeaderProps) => { }; const useKakunaTemplate = (): KakunaTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -221,12 +212,20 @@ const useKakunaTemplate = (): KakunaTemplate => { }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies KakunaStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/kakuna/semantic.ts b/packages/pdf/src/templates/kakuna/semantic.ts index 174552b5a..9bf44c804 100644 --- a/packages/pdf/src/templates/kakuna/semantic.ts +++ b/packages/pdf/src/templates/kakuna/semantic.ts @@ -1,14 +1,4 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const kakunaSemanticManifest = { - template: "kakuna", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [itemHeaderRowPart], -} as const satisfies TemplateSemanticManifest; +export const kakunaSemanticManifest = baseManifest("kakuna", [itemHeaderRowPart]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/lapras/LaprasPage.tsx b/packages/pdf/src/templates/lapras/LaprasPage.tsx index 3c8a4aae7..8df4bd539 100644 --- a/packages/pdf/src/templates/lapras/LaprasPage.tsx +++ b/packages/pdf/src/templates/lapras/LaprasPage.tsx @@ -2,34 +2,17 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; -import { Page, StyleSheet, View } from "#react-pdf-renderer"; +import { Page, StyleSheet } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; -import { - CustomFieldContactItem, - EmailContactItem, - LocationContactItem, - PhoneContactItem, - WebsiteContactItem, -} from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; -import { hasTemplatePicture } from "../shared/picture"; -import { - Heading, - SemanticContactListView, - SemanticHeaderPicture, - SemanticHeaderView, - SemanticRegionView, - Text, -} from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; +import { SemanticRegionView } from "../shared/primitives"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, TemplateHeader, useTemplateBase } from "../shared/template-base"; type LaprasStyles = Omit & { page: Style; @@ -92,49 +75,28 @@ export const LaprasPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pag ); }; -const Header = ({ styles }: LaprasHeaderProps) => { - const { basics, picture } = useRender(); - const hasPicture = hasTemplatePicture(picture); - - return ( - - {hasPicture && } - - - - {basics.name} - {basics.headline} - - - - - - - - {basics.customFields.map((field) => ( - - ))} - - - - ); -}; +const Header = ({ styles }: LaprasHeaderProps) => ( + +); const useLaprasTemplate = (): LaprasTemplate => { - const { picture, metadata, rtl } = useRender(); + const { picture, metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const borderColor = "#CCCCCC"; const pictureBorderRadius = Math.min(picture.borderRadius, 30); const headingNegativeMargin = metadata.typography.heading.fontSize + 6; const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -215,12 +177,21 @@ const useLaprasTemplate = (): LaprasTemplate => { ...baseStyles, levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies LaprasStyles, }; - }, [picture, metadata, rtl]); + }, [ + picture, + metadata, + r.row, + r.headerIdentity, + primary, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/lapras/semantic.ts b/packages/pdf/src/templates/lapras/semantic.ts index f71a4a416..4487b080f 100644 --- a/packages/pdf/src/templates/lapras/semantic.ts +++ b/packages/pdf/src/templates/lapras/semantic.ts @@ -1,14 +1,4 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const laprasSemanticManifest = { - template: "lapras", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [itemHeaderRowPart], -} as const satisfies TemplateSemanticManifest; +export const laprasSemanticManifest = baseManifest("lapras", [itemHeaderRowPart]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/leafish/LeafishPage.tsx b/packages/pdf/src/templates/leafish/LeafishPage.tsx index 5cdcb5df2..b05fb32c6 100644 --- a/packages/pdf/src/templates/leafish/LeafishPage.tsx +++ b/packages/pdf/src/templates/leafish/LeafishPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { getPrimaryTint as getPrimaryAlpha } from "../shared/color-helpers"; import { CustomFieldContactItem, @@ -29,9 +27,9 @@ import { SemanticTemplatePartView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type LeafishStyles = Omit & { page: Style; @@ -152,19 +150,12 @@ const Header = ({ styles }: LeafishHeaderProps) => { }; const useLeafishTemplate = (): LeafishTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const primaryTintLight = getPrimaryAlpha(metadata.design.colors.primary, 0.1); const primaryTintDark = getPrimaryAlpha(metadata.design.colors.primary, 0.2); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -250,12 +241,21 @@ const useLeafishTemplate = (): LeafishTemplate => { sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies LeafishStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.headerIdentity, + primary, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + metrics.columnGap, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/meowth/MeowthPage.tsx b/packages/pdf/src/templates/meowth/MeowthPage.tsx index 0e3de5fca..a4575674c 100644 --- a/packages/pdf/src/templates/meowth/MeowthPage.tsx +++ b/packages/pdf/src/templates/meowth/MeowthPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -27,9 +25,9 @@ import { SemanticRegionView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type MeowthStyles = Omit & { page: Style; @@ -126,16 +124,10 @@ const Header = ({ styles }: MeowthHeaderProps) => { }; const useMeowthTemplate = (): MeowthTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -234,12 +226,22 @@ const useMeowthTemplate = (): MeowthTemplate => { }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies MeowthStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.sectionHeadingTextAlign, + r.row, + r.headerIdentity, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/meowth/semantic.ts b/packages/pdf/src/templates/meowth/semantic.ts index 794648ea3..d24f2b3d2 100644 --- a/packages/pdf/src/templates/meowth/semantic.ts +++ b/packages/pdf/src/templates/meowth/semantic.ts @@ -1,72 +1,62 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; const inlineHeaderSectionTypes = ["experience", "education", "volunteer"] as const; -export const meowthSemanticManifest = { - template: "meowth", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ - itemHeaderRowPart, - { - name: "inline-item-header-leading", - key: "inline-item-header-leading", - owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { - parent: "owner", - at: "start", - take: [ - { kind: "field", name: "position", sectionTypes: ["experience", "volunteer"] }, - { kind: "field", name: "location", sectionTypes: ["experience", "volunteer"] }, - { kind: "field", name: "area", sectionTypes: ["education"] }, - { kind: "field", name: "degree", sectionTypes: ["education"] }, - ], - }, +export const meowthSemanticManifest = baseManifest("meowth", [ + itemHeaderRowPart, + { + name: "inline-item-header-leading", + key: "inline-item-header-leading", + owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { + parent: "owner", + at: "start", + take: [ + { kind: "field", name: "position", sectionTypes: ["experience", "volunteer"] }, + { kind: "field", name: "location", sectionTypes: ["experience", "volunteer"] }, + { kind: "field", name: "area", sectionTypes: ["education"] }, + { kind: "field", name: "degree", sectionTypes: ["education"] }, + ], }, - { - name: "inline-item-header-middle", - key: "inline-item-header-middle", - owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { - parent: "owner", - at: "start", - take: [ - { kind: "field", name: "company" }, - { kind: "field", name: "school" }, - { kind: "field", name: "organization" }, - { kind: "link" }, - ], - }, + }, + { + name: "inline-item-header-middle", + key: "inline-item-header-middle", + owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { + parent: "owner", + at: "start", + take: [ + { kind: "field", name: "company" }, + { kind: "field", name: "school" }, + { kind: "field", name: "organization" }, + { kind: "link" }, + ], }, - { - name: "inline-item-header-trailing", - key: "inline-item-header-trailing", - owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "start", take: [{ kind: "field", name: "period" }] }, + }, + { + name: "inline-item-header-trailing", + key: "inline-item-header-trailing", + owner: { kind: "item-header", key: "item-header", sectionTypes: inlineHeaderSectionTypes }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: "start", take: [{ kind: "field", name: "period" }] }, + }, + { + name: "education-grade-row", + key: "education-grade-row", + owner: { kind: "item", key: "item" }, + binding: { type: "primitive", primitive: "Text", source: "existing" }, + route: { + parent: "owner", + at: { after: { kind: "item-header" } }, + take: [ + { kind: "field", name: "grade", sectionTypes: ["education"] }, + { kind: "field", name: "location", sectionTypes: ["education"] }, + ], + takeFrom: "item-header", }, - { - name: "education-grade-row", - key: "education-grade-row", - owner: { kind: "item", key: "item" }, - binding: { type: "primitive", primitive: "Text", source: "existing" }, - route: { - parent: "owner", - at: { after: { kind: "item-header" } }, - take: [ - { kind: "field", name: "grade", sectionTypes: ["education"] }, - { kind: "field", name: "location", sectionTypes: ["education"] }, - ], - takeFrom: "item-header", - }, - }, - ], -} as const satisfies TemplateSemanticManifest; + }, +]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/onyx/OnyxPage.tsx b/packages/pdf/src/templates/onyx/OnyxPage.tsx index df488d39d..cbe8b8772 100644 --- a/packages/pdf/src/templates/onyx/OnyxPage.tsx +++ b/packages/pdf/src/templates/onyx/OnyxPage.tsx @@ -2,34 +2,17 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; -import { Page, StyleSheet, View } from "#react-pdf-renderer"; +import { Page, StyleSheet } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; -import { - CustomFieldContactItem, - EmailContactItem, - LocationContactItem, - PhoneContactItem, - WebsiteContactItem, -} from "../shared/contact-item"; import { TemplateProvider } from "../shared/context"; import { filterSections } from "../shared/filtering"; import { getTemplateMetrics } from "../shared/metrics"; -import { hasTemplatePicture } from "../shared/picture"; -import { - Heading, - SemanticContactListView, - SemanticHeaderPicture, - SemanticHeaderView, - SemanticRegionView, - Text, -} from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; +import { SemanticRegionView } from "../shared/primitives"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, TemplateHeader, useTemplateBase } from "../shared/template-base"; type OnyxStyles = Omit & { page: Style; @@ -92,45 +75,25 @@ export const OnyxPage = ({ page, pageSize, pageMinHeightStyle, showHeader, pageN ); }; -const Header = ({ styles }: OnyxHeaderProps) => { - const { basics, picture } = useRender(); - const hasPicture = hasTemplatePicture(picture); - - return ( - - {hasPicture && } - - - - {basics.name} - {basics.headline} - - - - - - - - {basics.customFields.map((field) => ( - - ))} - - - - ); -}; +const Header = ({ styles }: OnyxHeaderProps) => ( + +); const useOnyxTemplate = (): OnyxTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -198,12 +161,21 @@ const useOnyxTemplate = (): OnyxTemplate => { ...baseStyles, levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies OnyxStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.headerIdentity, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/onyx/semantic.ts b/packages/pdf/src/templates/onyx/semantic.ts index 026740259..fab050cc3 100644 --- a/packages/pdf/src/templates/onyx/semantic.ts +++ b/packages/pdf/src/templates/onyx/semantic.ts @@ -1,14 +1,4 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const onyxSemanticManifest = { - template: "onyx", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [itemHeaderRowPart], -} as const satisfies TemplateSemanticManifest; +export const onyxSemanticManifest = baseManifest("onyx", [itemHeaderRowPart]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/pikachu/PikachuPage.tsx b/packages/pdf/src/templates/pikachu/PikachuPage.tsx index 8b39e69f2..38355ccc6 100644 --- a/packages/pdf/src/templates/pikachu/PikachuPage.tsx +++ b/packages/pdf/src/templates/pikachu/PikachuPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -28,9 +26,9 @@ import { SemanticTemplatePartView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight, resolvePlacementColor } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type PikachuStyles = Omit & { page: Style; @@ -179,17 +177,10 @@ const Header = ({ styles, colors }: PikachuHeaderProps) => { }; const usePikachuTemplate = (): PikachuTemplate => { - const { picture, metadata, rtl } = useRender(); + const { picture, metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -294,12 +285,22 @@ const usePikachuTemplate = (): PikachuTemplate => { sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies PikachuStyles, }; - }, [picture, metadata, rtl]); + }, [ + picture, + metadata, + r.row, + r.headerIdentity, + primary, + metrics.page.paddingVertical, + metrics.page.paddingHorizontal, + metrics.columnGap, + metrics.gapY, + foreground, + base, + metrics.gapX, + background, + ]); }; diff --git a/packages/pdf/src/templates/pikachu/semantic.ts b/packages/pdf/src/templates/pikachu/semantic.ts index 2a18c1f9d..6cdfca591 100644 --- a/packages/pdf/src/templates/pikachu/semantic.ts +++ b/packages/pdf/src/templates/pikachu/semantic.ts @@ -1,23 +1,13 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const pikachuSemanticManifest = { - template: "pikachu", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ - itemHeaderRowPart, - { - name: "header-divider", - key: "header-divider", - owner: { kind: "header", key: "header" }, - binding: { type: "primitive", primitive: "View", source: "existing" }, - route: { parent: "owner", at: "start", take: [{ kind: "name" }, { kind: "headline" }] }, - }, - ], -} as const satisfies TemplateSemanticManifest; +export const pikachuSemanticManifest = baseManifest("pikachu", [ + itemHeaderRowPart, + { + name: "header-divider", + key: "header-divider", + owner: { kind: "header", key: "header" }, + binding: { type: "primitive", primitive: "View", source: "existing" }, + route: { parent: "owner", at: "start", take: [{ kind: "name" }, { kind: "headline" }] }, + }, +]) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx index 2d6638985..c45645f7c 100644 --- a/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx +++ b/packages/pdf/src/templates/rhyhorn/RhyhornPage.tsx @@ -3,13 +3,11 @@ import type { ReactNode } from "react"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { resolvedPdfFlowProps } from "../../semantic/adapter"; import { useRenderedSectionIds, useResolvedNode, useSemanticNodeVisible } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -30,9 +28,9 @@ import { semanticTemplatePartNodeKey, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type RhyhornStyles = Omit & { page: Style; @@ -231,19 +229,12 @@ const Header = ({ styles }: RhyhornHeaderProps) => { }; const useRhyhornTemplate = (): RhyhornTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); const contactGap = metrics.gapX(0.5); - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); - const baseStyles = StyleSheet.create({ ...base, page: { @@ -319,12 +310,24 @@ const useRhyhornTemplate = (): RhyhornTemplate => { sectionHeading: (context) => ({ ...baseStyles.sectionHeading, color: accentFor(context) }), levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies RhyhornStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.sectionHeadingTextAlign, + r.row, + r.headerIdentity, + r.contactSeparatorClear, + r.contactSeparator, + primary, + metrics.sectionGap, + metrics.gapY, + metrics.page.paddingVertical, + metrics.gapX, + base, + metrics.page.paddingHorizontal, + foreground, + background, + ]); }; diff --git a/packages/pdf/src/templates/rhyhorn/semantic.ts b/packages/pdf/src/templates/rhyhorn/semantic.ts index e49b9cb93..5e058e335 100644 --- a/packages/pdf/src/templates/rhyhorn/semantic.ts +++ b/packages/pdf/src/templates/rhyhorn/semantic.ts @@ -1,16 +1,9 @@ import type { TemplateSemanticManifest } from "../../semantic/template-manifest"; -import { itemHeaderRowPart } from "../../semantic/shared-parts"; +import { baseManifest, itemHeaderRowPart } from "../../semantic/shared-parts"; -export const rhyhornSemanticManifest = { - template: "rhyhorn", - regions: [ - { name: "header", placement: "main", origins: [] }, - { name: "main", placement: "main", origins: ["main"] }, - { name: "sidebar", placement: "sidebar", origins: ["sidebar"] }, - ], - header: { region: "header", placement: "main" }, - specialSummary: null, - parts: [ +export const rhyhornSemanticManifest = baseManifest( + "rhyhorn", + [ itemHeaderRowPart, { name: "contact-item-content", @@ -30,5 +23,5 @@ export const rhyhornSemanticManifest = { binding: { type: "alias", canonicalKind: "contact-item", token: "contact-item-last" }, }, ], - canonicalBindings: [{ kind: "contact-item", binding: { type: "primitive", primitive: "View", source: "existing" } }], -} as const satisfies TemplateSemanticManifest; + [{ kind: "contact-item", binding: { type: "primitive", primitive: "View", source: "existing" } }], +) satisfies TemplateSemanticManifest; diff --git a/packages/pdf/src/templates/scizor/ScizorPage.tsx b/packages/pdf/src/templates/scizor/ScizorPage.tsx index 9a80c199a..fc07bf60c 100644 --- a/packages/pdf/src/templates/scizor/ScizorPage.tsx +++ b/packages/pdf/src/templates/scizor/ScizorPage.tsx @@ -2,12 +2,10 @@ import type { Style } from "@react-pdf/types"; import type { TemplatePageProps } from "../../document"; import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types"; import { useMemo } from "react"; -import { rgbaStringToHex } from "@reactive-resume/utils/color"; import { Page, StyleSheet, View } from "#react-pdf-renderer"; import { useRender } from "../../context"; import { useRenderedSectionIds, useResolvedNode } from "../../semantic/context"; import { semanticNodeKeys } from "../../semantic/node-keys"; -import { createBaseTemplateStyles } from "../shared/base-template-styles"; import { CustomFieldContactItem, EmailContactItem, @@ -28,9 +26,9 @@ import { SemanticTemplatePartView, Text, } from "../shared/primitives"; -import { createRtlStyleHelpers } from "../shared/rtl"; import { Section } from "../shared/sections"; import { composeStyles, headerNameLineHeight } from "../shared/styles"; +import { createIconSlot, useTemplateBase } from "../shared/template-base"; type ScizorStyles = Omit & { page: Style; @@ -112,17 +110,11 @@ const Header = ({ styles }: ScizorHeaderProps) => { }; const useScizorTemplate = (): ScizorTemplate => { - const { picture, metadata, rtl } = useRender(); + const { metadata, r, foreground, background, primary, metrics, base } = useTemplateBase(); return useMemo(() => { - const r = createRtlStyleHelpers(rtl); - const foreground = rgbaStringToHex(metadata.design.colors.text); - const background = rgbaStringToHex(metadata.design.colors.background); - const primary = rgbaStringToHex(metadata.design.colors.primary); const divider = "#D8DCE2"; const colors: TemplateColorRoles = { foreground, background, primary }; - const metrics = getTemplateMetrics(metadata.page); - const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); const baseStyles = StyleSheet.create({ ...base, @@ -219,12 +211,25 @@ const useScizorTemplate = (): ScizorTemplate => { }, levelItem: (context) => ({ borderColor: accentFor(context) }), levelItemActive: (context) => ({ backgroundColor: accentFor(context) }), - icon: (context) => ({ - display: metadata.page.hideIcons ? "none" : "flex", - size: metadata.typography.body.fontSize, - color: accentFor(context), - }), + icon: createIconSlot({ metadata, accentFor }), } satisfies ScizorStyles, }; - }, [picture, metadata, rtl]); + }, [ + metadata, + r.row, + r.headerIdentity, + primary, + metrics.sectionGap, + metrics.page.paddingVertical, + metrics.page.paddingHorizontal, + metrics.itemGapY, + foreground, + metrics.gapY, + base.page, + background, + base.heading, + metrics.gapX, + base.bold, + base, + ]); }; diff --git a/packages/pdf/src/templates/shared/rich-text-template-styles.test.ts b/packages/pdf/src/templates/shared/rich-text-template-styles.test.ts index b7133d9e5..ab11e6abb 100644 --- a/packages/pdf/src/templates/shared/rich-text-template-styles.test.ts +++ b/packages/pdf/src/templates/shared/rich-text-template-styles.test.ts @@ -32,9 +32,9 @@ describe("rich text template styles", () => { (_name, file) => { const source = readFileSync(file, "utf8"); - if (source.includes("createBaseTemplateStyles")) { + if (source.includes("createBaseTemplateStyles") || source.includes("useTemplateBase")) { // Factory handles richListItemContent; guard is on the factory test above. - expect(source).toContain("createBaseTemplateStyles"); + expect(source.includes("createBaseTemplateStyles") || source.includes("useTemplateBase")).toBe(true); return; } diff --git a/packages/pdf/src/templates/shared/split-row.test.ts b/packages/pdf/src/templates/shared/split-row.test.ts index 468c1f5a2..547099c54 100644 --- a/packages/pdf/src/templates/shared/split-row.test.ts +++ b/packages/pdf/src/templates/shared/split-row.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { hasSplitRowText, promoteSplitRowRight } from "./split-row"; +import { hasSplitRowText } from "./split-row"; describe("hasSplitRowText", () => { it("returns true only for non-empty text", () => { @@ -8,20 +8,3 @@ describe("hasSplitRowText", () => { expect(hasSplitRowText(undefined)).toBe(false); }); }); - -describe("promoteSplitRowRight", () => { - it("keeps both right cells when the top-right cell has content", () => { - expect(promoteSplitRowRight({ top: "Remote", bottom: "2019 - 2024" })).toEqual({ - top: "Remote", - bottom: "2019 - 2024", - }); - }); - - it("moves bottom-right content to the top right when top-right content is missing", () => { - expect(promoteSplitRowRight({ top: "", bottom: "2019 - 2024" })).toEqual({ top: "2019 - 2024", bottom: "" }); - }); - - it("treats whitespace-only cells as missing", () => { - expect(promoteSplitRowRight({ top: " ", bottom: "\t" })).toEqual({ top: "", bottom: "" }); - }); -}); diff --git a/packages/pdf/src/templates/shared/split-row.ts b/packages/pdf/src/templates/shared/split-row.ts index a4a61cfcf..ddbe5133d 100644 --- a/packages/pdf/src/templates/shared/split-row.ts +++ b/packages/pdf/src/templates/shared/split-row.ts @@ -1,11 +1,3 @@ export const hasSplitRowText = (value: string | undefined): value is string => { return typeof value === "string" && value.trim().length > 0; }; - -type SplitRowContent = { top: string; bottom: string }; - -export const promoteSplitRowRight = ({ top, bottom }: SplitRowContent): SplitRowContent => { - if (hasSplitRowText(top)) return { top, bottom: hasSplitRowText(bottom) ? bottom : "" }; - - return { top: hasSplitRowText(bottom) ? bottom : "", bottom: "" }; -}; diff --git a/packages/pdf/src/templates/shared/template-base.tsx b/packages/pdf/src/templates/shared/template-base.tsx new file mode 100644 index 000000000..1c3b63188 --- /dev/null +++ b/packages/pdf/src/templates/shared/template-base.tsx @@ -0,0 +1,96 @@ +import type { Style } from "@react-pdf/types"; +import type { ResumeData } from "@reactive-resume/schema/resume/data"; +import type { TemplateIconSlot, TemplateStyleContext } from "./types"; +import { useMemo } from "react"; +import { rgbaStringToHex } from "@reactive-resume/utils/color"; +import { View } from "#react-pdf-renderer"; +import { useRender } from "../../context"; +import { createBaseTemplateStyles } from "./base-template-styles"; +import { + CustomFieldContactItem, + EmailContactItem, + LocationContactItem, + PhoneContactItem, + WebsiteContactItem, +} from "./contact-item"; +import { getTemplateMetrics } from "./metrics"; +import { hasTemplatePicture } from "./picture"; +import { Heading, SemanticContactListView, SemanticHeaderPicture, SemanticHeaderView, Text } from "./primitives"; +import { createRtlStyleHelpers } from "./rtl"; + +export const useTemplateBase = () => { + const { picture, metadata, rtl } = useRender(); + + return useMemo(() => { + const r = createRtlStyleHelpers(rtl); + const foreground = rgbaStringToHex(metadata.design.colors.text); + const background = rgbaStringToHex(metadata.design.colors.background); + const primary = rgbaStringToHex(metadata.design.colors.primary); + const metrics = getTemplateMetrics(metadata.page); + const base = createBaseTemplateStyles({ metadata, foreground, background, r, metrics, picture }); + + return { picture, metadata, rtl, r, foreground, background, primary, metrics, base }; + }, [picture, metadata, rtl]); +}; + +export const createIconSlot = ({ + metadata, + accentFor, +}: { + metadata: ResumeData["metadata"]; + accentFor: (context: TemplateStyleContext) => string; +}): TemplateIconSlot => { + return (context) => ({ + display: metadata.page.hideIcons ? "none" : "flex", + size: metadata.typography.body.fontSize, + color: accentFor(context), + }); +}; + +type TemplateHeaderStyles = { + header: Style; + picture: Style; + title: Style; + identity: Style; + name: Style; + contactList: Style; + contactItem: Style; +}; + +export const TemplateHeader = ({ + styles, + contactListOutsideTitle = false, +}: { + styles: TemplateHeaderStyles; + contactListOutsideTitle?: boolean; +}) => { + const { basics, picture } = useRender(); + const hasPicture = hasTemplatePicture(picture); + const contactList = ( + + + + + + {basics.customFields.map((field) => ( + + ))} + + ); + + return ( + + {hasPicture && } + + + + {basics.name} + {basics.headline} + + {!contactListOutsideTitle && contactList} + + + {contactListOutsideTitle && contactList} + + ); +}; diff --git a/packages/resume/src/ats-pdf/catalog.test.ts b/packages/resume/src/ats-pdf/catalog.test.ts index f4acf9ae8..99cb738f4 100644 --- a/packages/resume/src/ats-pdf/catalog.test.ts +++ b/packages/resume/src/ats-pdf/catalog.test.ts @@ -11,12 +11,10 @@ import { const CATEGORIES = ["parseability", "layout", "sections", "contact", "dates", "content"]; describe("PDF_ATS_RULE_CATALOG_V1", () => { - it("gives every rule a category, a meaning and an action", () => { + it("gives every rule a category", () => { for (const code of PDF_ATS_RULE_CODES) { const rule = PDF_ATS_RULE_CATALOG_V1[code]; expect(CATEGORIES).toContain(rule.category); - expect(rule.meaning.length).toBeGreaterThan(0); - expect(rule.action.length).toBeGreaterThan(0); } }); diff --git a/packages/resume/src/ats-pdf/catalog.ts b/packages/resume/src/ats-pdf/catalog.ts index 32da9ed40..10ce5d3dc 100644 --- a/packages/resume/src/ats-pdf/catalog.ts +++ b/packages/resume/src/ats-pdf/catalog.ts @@ -10,9 +10,9 @@ import type { PdfCategory } from "./types"; * reported as an unscored checklist. */ type PdfRuleReference = - | { severity: "blocker"; category: PdfCategory; cap: number; meaning: string; action: string } - | { severity: "warning"; category: PdfCategory; deduction: 5 | 10 | 15; meaning: string; action: string } - | { severity: "tip"; category: PdfCategory; meaning: string; action: string }; + | { severity: "blocker"; category: PdfCategory; cap: number } + | { severity: "warning"; category: PdfCategory; deduction: 5 | 10 | 15 } + | { severity: "tip"; category: PdfCategory }; export const PDF_ATS_RULE_CATALOG_V1 = { // ------------------------------------------------------------------------- @@ -22,187 +22,132 @@ export const PDF_ATS_RULE_CATALOG_V1 = { severity: "blocker", category: "parseability", cap: 10, - meaning: "The file carries no extractable text, so a parser recovers nothing from it.", - action: "Export the resume as a text PDF rather than an image or a scan.", }, IMAGE_ONLY_DOCUMENT: { severity: "blocker", category: "parseability", cap: 10, - meaning: "The pages are images with little or no text behind them, which is what a scan looks like.", - action: "Export from the original document instead of scanning or screenshotting it.", }, GARBLED_TEXT: { severity: "blocker", category: "parseability", cap: 25, - meaning: "The extracted text is largely unreadable, so the words a parser stores will not be your words.", - action: "Re-export the file, embedding standard fonts, and open the result to confirm the text copies cleanly.", }, TYPE3_FONT: { severity: "blocker", category: "parseability", cap: 25, - meaning: "The file uses Type 3 fonts, which carry drawing instructions rather than characters.", - action: "Re-export using a normal TrueType or OpenType font.", }, INVALID_EMBEDDED_FONT: { severity: "blocker", category: "parseability", cap: 25, - meaning: "An embedded font could not be read, so its glyphs may extract as the wrong characters.", - action: "Re-export the file from the original document with fonts embedded properly.", }, FILE_TOO_LARGE: { severity: "blocker", category: "parseability", cap: 40, - meaning: "The file exceeds the upload limit many application systems enforce.", - action: "Keep the file under 2.5 MB — usually by removing or downsampling images.", }, ENCRYPTED_PDF: { severity: "blocker", category: "parseability", cap: 40, - meaning: "The file is encrypted, so a parser may be unable to open it at all.", - action: "Save an unprotected copy without a password or permissions restrictions.", }, XFA_FORM: { severity: "blocker", category: "parseability", cap: 40, - meaning: "The file is an XFA form. Most parsers read the static shell and miss the real content.", - action: "Print or export the form to a flat PDF before sending it.", }, PDF_PORTFOLIO: { severity: "warning", category: "parseability", deduction: 15, - meaning: "The file is a PDF Portfolio: the visible page is a cover, and the content sits in attachments.", - action: "Send the resume itself rather than a portfolio wrapper.", }, REPLACEMENT_CHARACTERS: { severity: "warning", category: "parseability", deduction: 15, - meaning: "Some characters extract as the replacement character, so those words are lost.", - action: "Re-export with fonts embedded, and check that the text copies out cleanly.", }, PRIVATE_USE_CHARACTERS: { severity: "warning", category: "parseability", deduction: 15, - meaning: "Some glyphs map into the private-use area, which carries no agreed meaning outside this file.", - action: "Replace icon fonts with plain text, and re-export.", }, LIGATURE_CHARACTERS: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Ligature glyphs extract as single characters, so words such as 'workflow' can lose letters.", - action: "Disable discretionary ligatures when exporting, or use a font that maps them back to letters.", }, NON_EMBEDDED_FONTS: { severity: "warning", category: "parseability", deduction: 5, - meaning: "A font is referenced but not embedded, so the reader substitutes one and character mapping can drift.", - action: "Embed all fonts when exporting.", }, INVISIBLE_TEXT: { severity: "warning", category: "parseability", deduction: 15, - meaning: "The file contains text drawn invisibly. A reader sees nothing there, but a parser reads it.", - action: "Remove hidden text. If it came from OCR, that is expected; otherwise it looks like keyword stuffing.", }, WHITE_TEXT: { severity: "warning", category: "parseability", deduction: 15, - meaning: "The file contains white-on-white text, a pattern recruiters treat as deliberate keyword stuffing.", - action: "Delete the hidden text.", }, LOW_TEXT_DENSITY: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Very little text was recovered for the number of pages, which suggests most content did not extract.", - action: "Open the exported file and confirm you can select and copy every part of it.", }, HIGH_IMAGE_COVERAGE: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Images cover most of the page, so any words inside them are invisible to a parser.", - action: "Keep resume content as real text rather than as pictures of text.", }, LOST_WORD_SPACING: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Words run together in the extracted text, so keyword search will not find them.", - action: "Re-export from the original document rather than converting from another format.", }, SPLIT_CHARACTER_SPACING: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Text is emitted one character at a time, which commonly breaks words apart on extraction.", - action: "Avoid heavy letter-spacing effects, and re-export.", }, VERTICAL_TEXT: { severity: "warning", category: "parseability", deduction: 10, - meaning: "Some text is rotated. Rotated runs are frequently dropped or misplaced during extraction.", - action: "Lay the text out horizontally.", }, ROTATED_PAGES: { severity: "warning", category: "parseability", deduction: 5, - meaning: "A page is rotated, which shifts where extracted text is thought to sit.", - action: "Save the file with all pages upright.", }, ACROFORM_FIELDS: { severity: "warning", category: "parseability", deduction: 10, - meaning: - "The file contains form fields. Text typed into a field can be missed by parsers that read page content only.", - action: "Flatten the form before sending it.", }, NON_STANDARD_PAGE_SIZE: { severity: "warning", category: "parseability", deduction: 5, - meaning: "The page is not a common resume size, which can shift layout when the file is re-rendered.", - action: "Use A4 or US Letter.", }, UNTAGGED_PDF: { severity: "tip", category: "parseability", - meaning: "The file has no accessibility tags. Tags give a parser an explicit reading order to follow.", - action: "Export with tagged-PDF or accessibility options enabled where your tool offers them.", }, MISSING_DOCUMENT_LANGUAGE: { severity: "tip", category: "parseability", - meaning: "The file declares no language, which some tools use to pick tokenisation rules.", - action: "Set the document language when exporting.", }, LARGE_FILE_SIZE: { severity: "tip", category: "parseability", - meaning: "The file is large for a resume, which slows uploads and occasionally trips size limits.", - action: "Compress or remove large images.", }, TRUNCATED_ANALYSIS: { severity: "tip", category: "parseability", - meaning: "Only the first pages were analysed, so this report does not cover the whole file.", - action: "Nothing to fix — the remaining pages were simply not checked.", }, // ------------------------------------------------------------------------- @@ -212,98 +157,69 @@ export const PDF_ATS_RULE_CATALOG_V1 = { severity: "blocker", category: "layout", cap: 55, - meaning: "The page is laid out in columns and the text extracts out of order, interleaving the columns.", - action: "Use a single-column layout for anything a parser must read in sequence.", }, READING_ORDER_INVERSION: { severity: "blocker", category: "layout", cap: 55, - meaning: - "The order text is stored in disagrees badly with the order it is read in, so extracted lines are scrambled.", - action: "Re-export from a single-column layout, then copy the text out and check that it reads top to bottom.", }, READING_ORDER_RISK: { severity: "warning", category: "layout", deduction: 15, - meaning: "Stored order and reading order disagree in places, so some extracted lines may arrive out of sequence.", - action: "Copy the text out of the exported file and read it through; fix whatever arrives in the wrong order.", }, COLUMN_GUTTER: { severity: "warning", category: "layout", deduction: 15, - meaning: "A full-height empty band splits the page, which is the shape parsers most often read out of order.", - action: "Prefer a single column, or keep the side column to short, self-contained lists.", }, TABLE_LIKE_LAYOUT: { severity: "warning", category: "layout", deduction: 10, - meaning: "Content is arranged in a grid. Table cells extract in storage order, which is rarely reading order.", - action: "Replace tables with plain paragraphs and lists.", }, TEXT_IN_MARGIN_ZONE: { severity: "warning", category: "layout", deduction: 10, - meaning: "Contact details sit in the header or footer strip, where some parsers discard them entirely.", - action: "Move your email and phone number into the body of the page.", }, TIGHT_LINE_SPACING: { severity: "warning", category: "layout", deduction: 5, - meaning: "Lines are packed tightly enough that extraction can merge two lines into one.", - action: "Increase line spacing.", }, SMALL_BODY_TEXT: { severity: "warning", category: "layout", deduction: 10, - meaning: "The body text is very small, which hurts both human readers and any re-rendered copy.", - action: "Use at least 9pt for body text.", }, MANY_DISTINCT_FONTS: { severity: "warning", category: "layout", deduction: 5, - meaning: "The file uses many different fonts, which raises the chance one of them maps badly.", - action: "Keep to two or three font families.", }, NARROW_PAGE_MARGINS: { severity: "warning", category: "layout", deduction: 5, - meaning: "Margins are narrow enough that content sits at the very edge of the page.", - action: "Leave at least half an inch of margin.", }, TEXT_OUTSIDE_PAGE: { severity: "warning", category: "layout", deduction: 10, - meaning: "Some text falls outside the visible page box, so it prints and reads inconsistently.", - action: "Move all content inside the page boundaries and re-export.", }, NON_STANDARD_BULLET_GLYPHS: { severity: "warning", category: "layout", deduction: 5, - meaning: "Bullets are drawn with unusual glyphs that can extract as stray characters mid-sentence.", - action: "Use your editor's normal bullet list rather than a decorative character.", }, REPEATED_HEADER_FOOTER: { severity: "tip", category: "layout", - meaning: "The same line repeats at the top or bottom of every page, and will repeat in extracted text too.", - action: "Keep repeated running heads short, or drop them.", }, DENSE_PAGE: { severity: "tip", category: "layout", - meaning: "The page is very densely packed, which makes it hard for a person to skim.", - action: "Add whitespace between sections.", }, // ------------------------------------------------------------------------- @@ -313,70 +229,50 @@ export const PDF_ATS_RULE_CATALOG_V1 = { severity: "blocker", category: "sections", cap: 60, - meaning: "No work-experience section was found, and experience is the field most screening systems rank on.", - action: "Add a section headed 'Experience' or 'Work Experience'.", }, NO_RECOGNIZED_HEADINGS: { severity: "warning", category: "sections", deduction: 15, - meaning: "No conventional section headings were found, so a parser has no way to segment the document.", - action: "Use plain headings such as 'Experience', 'Education', and 'Skills'.", }, FEW_SECTION_HEADINGS: { severity: "warning", category: "sections", deduction: 10, - meaning: "Only one recognisable section heading was found, which leaves most of the resume unsegmented.", - action: "Break the resume into the usual sections.", }, NO_EDUCATION_SECTION: { severity: "warning", category: "sections", deduction: 10, - meaning: "No education section was found. Many systems have a required education field.", - action: "Add a section headed 'Education'.", }, NO_SKILLS_SECTION: { severity: "warning", category: "sections", deduction: 10, - meaning: "No skills section was found, and recruiters search skills far more than any other field.", - action: "Add a section headed 'Skills' listing the tools and technologies you use.", }, HEADINGS_NOT_DISTINGUISHED: { severity: "warning", category: "sections", deduction: 10, - meaning: "Section headings are set in the same size and case as body text, so they are hard to detect.", - action: "Make headings visibly larger, bolder, or capitalised.", }, VERY_SHORT_DOCUMENT: { severity: "warning", category: "sections", deduction: 15, - meaning: "Very few words were recovered for a resume, which usually means most content did not extract.", - action: "Confirm the exported file contains everything you expect, and that all of it is selectable text.", }, NO_BULLET_POINTS: { severity: "warning", category: "sections", deduction: 5, - meaning: "No bullet lists were found. Solid paragraphs are harder to segment and harder to skim.", - action: "Describe each role with short bullets.", }, NO_ROLE_LINES: { severity: "warning", category: "sections", deduction: 10, - meaning: "No lines look like a job title paired with an employer, which is the pattern parsers key roles on.", - action: "Write each role as a clear title and company line, with the dates alongside.", }, NO_SUMMARY_SECTION: { severity: "tip", category: "sections", - meaning: "There is no summary or profile paragraph to orient a reader in the first few seconds.", - action: "Add two or three lines at the top describing what you do.", }, // ------------------------------------------------------------------------- @@ -386,67 +282,47 @@ export const PDF_ATS_RULE_CATALOG_V1 = { severity: "blocker", category: "contact", cap: 50, - meaning: "No email address was found. Email is the field most systems key a candidate record on.", - action: "Put a plain email address near the top of the first page.", }, NO_PHONE: { severity: "warning", category: "contact", deduction: 15, - meaning: "No phone number was found, and some application forms require one before you can submit.", - action: "Add a phone number with its country code.", }, NO_NAME_LINE: { severity: "warning", category: "contact", deduction: 15, - meaning: "The top of the first page does not look like a name, so the record may be filed without one.", - action: "Start the resume with your name on its own line.", }, CONTACT_NOT_ON_FIRST_PAGE: { severity: "warning", category: "contact", deduction: 10, - meaning: "Contact details are not on the first page, where nearly every parser looks for them.", - action: "Move your email and phone number to the top of page one.", }, EMAIL_SPLIT_ACROSS_ITEMS: { severity: "warning", category: "contact", deduction: 15, - meaning: "The email address appears broken across text runs, so it may extract as fragments.", - action: "Write the address as one uninterrupted piece of text, with no special spacing.", }, LINK_TEXT_URL_MISMATCH: { severity: "warning", category: "contact", deduction: 10, - meaning: "A link points somewhere other than the address it displays, so the visible text is misleading.", - action: "Make the visible text and the link destination match.", }, MULTIPLE_EMAILS: { severity: "tip", category: "contact", - meaning: "More than one email address appears, and a parser may pick the wrong one.", - action: "Keep a single address on the resume.", }, NO_PROFESSIONAL_LINK: { severity: "tip", category: "contact", - meaning: "There is no link to a profile, portfolio, or repository for a reader to follow.", - action: "Add one link that shows your work.", }, BARE_URL_WITHOUT_PROTOCOL: { severity: "tip", category: "contact", - meaning: "A web address is written without https://, so it may not be recognised as a link.", - action: "Write the full address, including https://.", }, PHONE_FORMAT_UNUSUAL: { severity: "tip", category: "contact", - meaning: "The phone number is written in a shape some parsers will not recognise.", - action: "Use a plain international form such as +1 555 010 1234.", }, // ------------------------------------------------------------------------- @@ -456,55 +332,39 @@ export const PDF_ATS_RULE_CATALOG_V1 = { severity: "blocker", category: "dates", cap: 60, - meaning: "No dates were found, so no system can place your roles on a timeline or compute years of experience.", - action: "Add a period to every role, such as 'Jan 2020 - Present'.", }, FEW_DATES: { severity: "warning", category: "dates", deduction: 15, - meaning: "Very few dates were found for the amount of history on the page, so entries are missing periods.", - action: "Give every role and qualification a date range.", }, UNPARSEABLE_DATE_RANGE: { severity: "warning", category: "dates", deduction: 15, - meaning: "A date range is written in a form most parsers cannot read.", - action: "Use a recognised form such as 'Jan 2020 - Mar 2022' or '2020 - 2022'.", }, REVERSED_DATE_RANGE: { severity: "warning", category: "dates", deduction: 15, - meaning: "A date range ends before it starts.", - action: "Swap the start and end dates.", }, FUTURE_DATED_ENTRY: { severity: "warning", category: "dates", deduction: 10, - meaning: "A date is in the future, which reads as a typo to a reviewer and to a parser.", - action: "Correct the year, or write 'Present' for ongoing work.", }, MIXED_DATE_FORMATS: { severity: "warning", category: "dates", deduction: 5, - meaning: "Dates are written several different ways, and some parsers only recognise one of them.", - action: "Pick one date format and use it everywhere.", }, NO_CURRENT_ROLE_MARKER: { severity: "tip", category: "dates", - meaning: "Nothing marks a role as ongoing, so a reader cannot tell what you are doing now.", - action: "Write 'Present' as the end date of your current role.", }, AMBIGUOUS_NUMERIC_DATE: { severity: "tip", category: "dates", - meaning: "A numeric date such as 03/04/2022 reads differently in different regions.", - action: "Spell the month, as in 'Mar 2022'.", }, // ------------------------------------------------------------------------- @@ -513,50 +373,34 @@ export const PDF_ATS_RULE_CATALOG_V1 = { NO_QUANTIFIED_IMPACT: { severity: "tip", category: "content", - meaning: "Almost no bullet carries a number, so the scale of your work is left to the reader's imagination.", - action: "Add figures where you have them: volumes, percentages, timeframes, team sizes.", }, WEAK_ACTION_VERBS: { severity: "tip", category: "content", - meaning: "Most bullets open with a passive or filler phrase rather than something you did.", - action: "Start each bullet with a concrete verb: built, led, reduced, migrated.", }, FIRST_PERSON_PRONOUNS: { severity: "tip", category: "content", - meaning: "The resume uses first-person pronouns, which is unconventional in most markets.", - action: "Drop 'I' and 'my' and start from the verb.", }, LONG_BULLETS: { severity: "tip", category: "content", - meaning: "Several bullets run long enough that a skim-reader will not finish them.", - action: "Keep bullets to roughly two lines.", }, HIGH_PAGE_COUNT: { severity: "tip", category: "content", - meaning: "The resume is long. Length is not a defect, but every extra page competes for the same attention.", - action: "Keep the pages that earn their place. There is no universal one-page rule.", }, EMPLOYMENT_GAP: { severity: "tip", category: "content", - meaning: "There is a gap between two dated roles. A gap is not a problem; an unexplained gap invites a guess.", - action: "If you want to, add a one-line explanation. Many strong resumes simply leave it.", }, ALL_CAPS_RUNS: { severity: "tip", category: "content", - meaning: "Long stretches are set in capitals, which slows reading and can defeat case-sensitive search.", - action: "Reserve capitals for short headings.", }, REPEATED_PHRASES: { severity: "tip", category: "content", - meaning: "The same phrase opens many bullets, which makes the whole section read as boilerplate.", - action: "Vary how each bullet starts.", }, } as const satisfies Readonly>; diff --git a/packages/resume/src/ats-pdf/index.ts b/packages/resume/src/ats-pdf/index.ts index a77f5b797..d79f8523f 100644 --- a/packages/resume/src/ats-pdf/index.ts +++ b/packages/resume/src/ats-pdf/index.ts @@ -149,16 +149,7 @@ export type { ResumeSemantics, } from "./types"; export type { PdfRuleCode }; -export { - PDF_ATS_RULE_CATALOG_V1, - PDF_ATS_RULE_CODES, - pdfRuleCap, - pdfRuleCategory, - pdfRuleDeduction, - pdfRuleSeverity, -} from "./catalog"; +export { PDF_ATS_RULE_CODES } from "./catalog"; export { buildExtractedDocument } from "./extract"; -export { HARVEST_DEFAULTS, HarvestAbortedError, harvestPdfDocument } from "./harvest"; -export { matchJobDescription } from "./jd/match"; -export { IMAGE_PAINT_OPS, PDF_OPS } from "./pdf-ops"; -export { CATEGORY_WEIGHTS, SCORED_CATEGORIES } from "./score"; +export { HARVEST_DEFAULTS, harvestPdfDocument } from "./harvest"; +export { PDF_OPS } from "./pdf-ops"; diff --git a/packages/resume/src/ats/catalog.test.ts b/packages/resume/src/ats/catalog.test.ts index 623dbc88e..4629bf5bf 100644 --- a/packages/resume/src/ats/catalog.test.ts +++ b/packages/resume/src/ats/catalog.test.ts @@ -30,12 +30,10 @@ describe("ATS_RULE_CATALOG_V1", () => { `); }); - it("gives every rule a severity, a meaning and an action", () => { + it("gives every rule a severity", () => { for (const code of ATS_RULE_CODES) { const rule = ATS_RULE_CATALOG_V1[code]; expect(["error", "warning", "info"]).toContain(rule.severity); - expect(rule.meaning.length).toBeGreaterThan(0); - expect(rule.action.length).toBeGreaterThan(0); } }); diff --git a/packages/resume/src/ats/catalog.ts b/packages/resume/src/ats/catalog.ts index 14237a891..eb40a93de 100644 --- a/packages/resume/src/ats/catalog.ts +++ b/packages/resume/src/ats/catalog.ts @@ -2,119 +2,75 @@ import type { AtsSeverity } from "./types"; type AtsRuleReference = { severity: AtsSeverity; - meaning: string; - action: string; }; export const ATS_RULE_CATALOG_V1 = { MISSING_NAME: { severity: "error", - meaning: "The resume has no name, so a parser has nothing to file the application under.", - action: "Fill in your full name under Basics.", }, MISSING_EMAIL: { severity: "error", - meaning: "The resume has no email address, which is the field most parsers key a candidate record on.", - action: "Add an email address under Basics.", }, MALFORMED_EMAIL: { severity: "error", - meaning: "The email address is not in a shape a parser will recognize.", - action: "Use a plain address such as name@example.com, with no surrounding text.", }, MISSING_PHONE: { severity: "warning", - meaning: "The resume has no phone number, which some applicant systems require before submission.", - action: "Add a phone number under Basics.", }, MISSING_LOCATION: { severity: "info", - meaning: "The resume has no location, which many systems use to match against a role's region.", - action: "Add at least a city and country under Basics.", }, MALFORMED_URL: { severity: "warning", - meaning: "A link is missing its protocol or is not a valid URL, so it may be dropped or mis-parsed.", - action: "Write the full address including https://.", }, PICTURE_PRESENT: { severity: "info", - meaning: "The resume includes a photo. Some parsers mishandle images, and some regions advise against them.", - action: "Hide the picture if you are applying where photos are not customary.", }, EMPTY_PERIOD: { severity: "warning", - meaning: "A dated entry has no period, so a parser cannot place it on your timeline.", - action: "Add a period such as 'Jan 2020 - Present'.", }, UNPARSEABLE_PERIOD: { severity: "error", - meaning: "A period is written in a format most parsers cannot read.", - action: "Use a recognized form such as 'Jan 2020 - Mar 2022', '2020 - 2022', or '03/2020 - Present'.", }, UNPARSEABLE_DATE: { severity: "warning", - meaning: "A single date is written in a format most parsers cannot read.", - action: "Use a recognized form such as 'March 2022' or '2022'.", }, REVERSED_PERIOD: { severity: "error", - meaning: "A period ends before it starts.", - action: "Swap the start and end dates.", }, FUTURE_DATED_PERIOD: { severity: "warning", - meaning: "A period starts in the future, which reads as a typo to a reviewer and to a parser.", - action: "Correct the year, or use 'Present' for ongoing work.", }, SECTION_MISSING_FROM_LAYOUT: { severity: "error", - meaning: "A section has visible content but is not placed on any page, so it never renders or exports.", - action: "Place the section on a page from the Layout panel, or hide it if it is intentionally unused.", }, NO_VISIBLE_EXPERIENCE: { severity: "warning", - meaning: "The resume shows no work experience, which most screening systems rank on.", - action: "Add at least one experience entry, or use projects and volunteer work to show equivalent history.", }, MISSING_EXPERIENCE_DESCRIPTION: { severity: "warning", - meaning: "An experience entry has no description, so it contributes no keywords for matching.", - action: "Describe what you did in that role.", }, NON_STANDARD_SECTION_TITLE: { severity: "info", - meaning: "A section heading is not one of the conventional names parsers look for when segmenting a resume.", - action: "Prefer a conventional heading such as 'Work Experience' or 'Education'.", }, MULTI_COLUMN_PROSE_SECTION: { severity: "warning", - meaning: "A prose-heavy section is split across columns, which commonly scrambles extracted reading order.", - action: "Set the section to a single column.", }, PROSE_SECTION_IN_SIDEBAR: { severity: "warning", - meaning: "A prose-heavy section sits in the narrow sidebar, where extraction interleaves it with the main column.", - action: "Move the section into the main column and keep the sidebar for short lists.", }, SMALL_BODY_FONT: { severity: "warning", - meaning: "The body font is small enough that scanned or re-rendered copies lose accuracy.", - action: "Use a body size of at least 9pt.", }, TIGHT_LINE_HEIGHT: { severity: "warning", - meaning: "Lines are packed tightly enough that extraction can merge them into a single run of text.", - action: "Use a line height of at least 1.15.", }, TIGHT_PAGE_MARGINS: { severity: "warning", - meaning: "Page margins are narrow enough that content can fall outside the reliably extracted area.", - action: "Increase the page margins.", }, } as const satisfies Readonly>; diff --git a/packages/resume/src/ats/index.ts b/packages/resume/src/ats/index.ts index b69742b3f..654d72cd1 100644 --- a/packages/resume/src/ats/index.ts +++ b/packages/resume/src/ats/index.ts @@ -50,6 +50,5 @@ export type { AtsRuleCode } from "./catalog"; export type { ParsedPeriod, PeriodEndpoint } from "./period"; export type { AtsFinding, AtsFindingParams, AtsReport, AtsSeverity } from "./types"; export type { SectionPlacement, WalkedItem, WalkedSection } from "./walk"; -export { ATS_RULE_CATALOG_V1, ATS_RULE_CODES, atsRuleSeverity } from "./catalog"; +export { ATS_RULE_CODES } from "./catalog"; export { isFutureEndpoint, isReversedPeriod, parsePeriod, parseSingleDate } from "./period"; -export { escapePointerToken, isRenderedSection, walkSections } from "./walk"; diff --git a/packages/resume/src/stylesheet/cache.ts b/packages/resume/src/stylesheet/cache.ts index 839f53d6e..b29d45826 100644 --- a/packages/resume/src/stylesheet/cache.ts +++ b/packages/resume/src/stylesheet/cache.ts @@ -1,53 +1,26 @@ import type { CompileStylesheetResult } from "./types"; const MAX_ENTRIES = 128; -const MAX_SERIALIZED_BYTES = 16 * 1024 * 1024; +const cache = new Map(); -type CacheEntry = { - value: CompileStylesheetResult; - size: number; -}; - -function serializedSize(value: CompileStylesheetResult): number { - return new TextEncoder().encode(JSON.stringify(value)).byteLength; +export function getCachedStylesheet(key: string): CompileStylesheetResult | undefined { + const value = cache.get(key); + if (value === undefined) return; + cache.delete(key); + cache.set(key, value); + return value; } -export class StylesheetCompilationCache { - readonly #entries = new Map(); - #size = 0; - - get(key: string): CompileStylesheetResult | undefined { - const entry = this.#entries.get(key); - if (!entry) return; - this.#entries.delete(key); - this.#entries.set(key, entry); - return entry.value; - } - - set(key: string, value: CompileStylesheetResult): void { - const previous = this.#entries.get(key); - if (previous) { - this.#size -= previous.size; - this.#entries.delete(key); - } - - const entry = { value, size: serializedSize(value) }; - if (entry.size > MAX_SERIALIZED_BYTES) return; - this.#entries.set(key, entry); - this.#size += entry.size; - - while (this.#entries.size > MAX_ENTRIES || this.#size > MAX_SERIALIZED_BYTES) { - const oldestKey = this.#entries.keys().next().value; - if (typeof oldestKey !== "string") break; - const oldest = this.#entries.get(oldestKey); - this.#entries.delete(oldestKey); - this.#size -= oldest?.size ?? 0; - } +export function setCachedStylesheet(key: string, value: CompileStylesheetResult): void { + cache.delete(key); + cache.set(key, value); + while (cache.size > MAX_ENTRIES) { + const oldestKey = cache.keys().next().value; + if (oldestKey === undefined) break; + cache.delete(oldestKey); } } -export const stylesheetCompilationCache = new StylesheetCompilationCache(); - const SEMANTIC_CSS_COMPILER_BUILD_ID = "semantic-css-v1-values-2"; export function stylesheetCacheKey(languageVersion: number, source: string, registry: string): string { diff --git a/packages/resume/src/stylesheet/compile.ts b/packages/resume/src/stylesheet/compile.ts index 6317502c0..a52bd2757 100644 --- a/packages/resume/src/stylesheet/compile.ts +++ b/packages/resume/src/stylesheet/compile.ts @@ -1,6 +1,6 @@ import type { StylesheetSource } from "@reactive-resume/schema/resume/stylesheet"; -import type { CompileStylesheetResult } from "./types"; -import { stylesheetCacheKey, stylesheetCompilationCache } from "./cache"; +import type { CompiledStyleRule, CompileStylesheetResult, StyleProgram } from "./types"; +import { getCachedStylesheet, setCachedStylesheet, stylesheetCacheKey } from "./cache"; import { createDiagnostic, isFatalStylesheetDiagnostic } from "./diagnostics"; import { SEMANTIC_CSS_LIMITS_V1 } from "./limits"; import { parseStylesheet } from "./parse"; @@ -8,12 +8,14 @@ import { PROPERTY_REGISTRY_V1 } from "./registry/properties"; import { SEMANTIC_NODE_KINDS } from "./registry/semantic"; import { SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables"; import { compileProgram, cssFunctionDepth } from "./values"; -import { getStylesheetCompiler } from "./version"; function isPositiveInteger(value: string): boolean { return /^[1-9]\d*$/.test(value); } +const compileVersionOne = (rules: readonly CompiledStyleRule[]): StyleProgram => + Object.freeze({ languageVersion: 1, rules: Object.freeze([...rules]) }); + export function compileStylesheet(source: StylesheetSource): CompileStylesheetResult { if (new TextEncoder().encode(source.text).byteLength > SEMANTIC_CSS_LIMITS_V1.maxSourceBytes) { return { @@ -35,7 +37,7 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe const registry = JSON.stringify([PROPERTY_REGISTRY_V1, SEMANTIC_NODE_KINDS, SYSTEM_VARIABLE_REGISTRY_V1]); const cacheKey = stylesheetCacheKey(source.languageVersion, source.text, registry); - const cached = stylesheetCompilationCache.get(cacheKey); + const cached = getCachedStylesheet(cacheKey); if (cached) return cached; const stylesheet = parseStylesheet(source.text); @@ -87,7 +89,7 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe } } - const compiler = getStylesheetCompiler(source.languageVersion); + const compiler = source.languageVersion === 1 ? compileVersionOne : undefined; if (!compiler) { diagnostics.push( createDiagnostic( @@ -109,6 +111,6 @@ export function compileStylesheet(source: StylesheetSource): CompileStylesheetRe } const result = { program: compiler(compiled.program.rules), diagnostics }; - stylesheetCompilationCache.set(cacheKey, result); + setCachedStylesheet(cacheKey, result); return result; } diff --git a/packages/resume/src/stylesheet/index.ts b/packages/resume/src/stylesheet/index.ts index 0a3227b99..b4a9e9359 100644 --- a/packages/resume/src/stylesheet/index.ts +++ b/packages/resume/src/stylesheet/index.ts @@ -46,6 +46,5 @@ export { TEMPLATE_PART_CHILD_KINDS_V1, } from "./registry/semantic"; export { createSystemVariables, SYSTEM_VARIABLE_REGISTRY_V1 } from "./registry/system-variables"; -export { compileSelector, createSelectorMatcher, getSpecificity, matchesSelector } from "./selector"; +export { compileSelector, createSelectorMatcher } from "./selector"; export { escapeCssComment, escapeCssString, serializeGeneratedStylesheet } from "./serialize"; -export { SUPPORTED_SEMANTIC_CSS_VERSIONS } from "./version"; diff --git a/packages/resume/src/stylesheet/selector.test.ts b/packages/resume/src/stylesheet/selector.test.ts index 3379a68db..a982f9951 100644 --- a/packages/resume/src/stylesheet/selector.test.ts +++ b/packages/resume/src/stylesheet/selector.test.ts @@ -2,7 +2,7 @@ import type { SemanticNode } from "./types"; import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import { compileStylesheet } from "./compile"; -import { compileSelector, getSpecificity, matchesSelector } from "./selector"; +import { compileSelector, createSelectorMatcher } from "./selector"; const experienceId = "229ad766-cb9a-4f16-aaf0-fdd8394a9b95"; @@ -58,9 +58,15 @@ const fixtureTree = node("resume", "resume", { ], }); +const selectorMatches = createSelectorMatcher(fixtureTree); + function matches(source: string, nodeKey: string): boolean { const result = compileSelector(source); - return result.selector ? matchesSelector(result.selector, fixtureTree, nodeKey) : false; + return result.selector ? selectorMatches(result.selector, nodeKey) : false; +} + +function getSpecificity(source: string): readonly number[] | null { + return compileSelector(source).selector?.selectors[0]?.specificity ?? null; } describe("semantic selector compilation", () => { diff --git a/packages/resume/src/stylesheet/selector.ts b/packages/resume/src/stylesheet/selector.ts index 5d7598fa7..9b8971d45 100644 --- a/packages/resume/src/stylesheet/selector.ts +++ b/packages/resume/src/stylesheet/selector.ts @@ -300,10 +300,6 @@ export function compileSelector(source: string | CssNode): CompileSelectorResult } } -export function getSpecificity(_source: string): Specificity | null { - return compileSelector(_source).selector?.selectors[0]?.specificity ?? null; -} - function buildTree(root: SemanticNode): Map { const nodes = new Map(); const rootNode: TreeNode = { node: root, parent: null, children: [] }; @@ -453,7 +449,3 @@ export function createSelectorMatcher(root: SemanticNode): (selector: CompiledSe return target ? selector.selectors.some((complex) => matchesComplex(complex, target)) : false; }; } - -export function matchesSelector(selector: CompiledSelector, root: SemanticNode, nodeKey: string): boolean { - return createSelectorMatcher(root)(selector, nodeKey); -} diff --git a/packages/resume/src/stylesheet/values.test.ts b/packages/resume/src/stylesheet/values.test.ts index 5f64e6209..b2880fac8 100644 --- a/packages/resume/src/stylesheet/values.test.ts +++ b/packages/resume/src/stylesheet/values.test.ts @@ -1,7 +1,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; import fc from "fast-check"; -import { StylesheetCompilationCache, stylesheetCacheKey } from "./cache"; +import { getCachedStylesheet, setCachedStylesheet, stylesheetCacheKey } from "./cache"; import { compileStylesheet } from "./compile"; import { SEMANTIC_CSS_LIMITS_V1 } from "./limits"; @@ -396,52 +396,16 @@ describe("Semantic CSS value compilation", () => { expect(stylesheetCacheKey(1, first, "registry")).not.toBe(stylesheetCacheKey(1, second, "registry")); }); - it("uses a bounded least-recently-used cache by entry count and aggregate bytes", () => { - const cache = new StylesheetCompilationCache(); + it("uses a bounded least-recently-used cache by entry count", () => { const result = compileStylesheet({ languageVersion: 1, text: "@version 1;" }); - cache.set("first", result); - for (let index = 0; index < 128; index++) cache.set(`next-${index}`, result); + setCachedStylesheet("first", result); + for (let index = 0; index < 128; index++) setCachedStylesheet(`next-${index}`, result); - expect(cache.get("first")).toBeUndefined(); - expect(cache.get("next-0")).toBe(result); - cache.set("last", result); - expect(cache.get("next-1")).toBeUndefined(); - - cache.set("oversized", { - program: null, - diagnostics: [ - { - code: "LARGE", - severity: "warning", - message: "x".repeat(16 * 1024 * 1024), - range: { - start: { line: 1, column: 1, offset: 0 }, - end: { line: 1, column: 1, offset: 0 }, - }, - }, - ], - }); - expect(cache.get("oversized")).toBeUndefined(); - - const large = (code: string) => ({ - program: null, - diagnostics: [ - { - code, - severity: "warning" as const, - message: "x".repeat(9 * 1024 * 1024), - range: { - start: { line: 1, column: 1, offset: 0 }, - end: { line: 1, column: 1, offset: 0 }, - }, - }, - ], - }); - cache.set("large-first", large("FIRST")); - cache.set("large-second", large("SECOND")); - expect(cache.get("large-first")).toBeUndefined(); - expect(cache.get("large-second")).toBeDefined(); + expect(getCachedStylesheet("first")).toBeUndefined(); + expect(getCachedStylesheet("next-0")).toBe(result); + setCachedStylesheet("last", result); + expect(getCachedStylesheet("next-1")).toBeUndefined(); }); it("never throws for malformed Unicode or case/escape-varied attack values", () => { diff --git a/packages/resume/src/stylesheet/version.ts b/packages/resume/src/stylesheet/version.ts deleted file mode 100644 index e25ceb146..000000000 --- a/packages/resume/src/stylesheet/version.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CompiledStyleRule, StyleProgram } from "./types"; - -export const SUPPORTED_SEMANTIC_CSS_VERSIONS = Object.freeze([1] as const); - -type StylesheetCompiler = (rules: readonly CompiledStyleRule[]) => StyleProgram; - -function compileVersionOne(rules: readonly CompiledStyleRule[]): StyleProgram { - return Object.freeze({ languageVersion: 1, rules: Object.freeze([...rules]) }); -} - -const COMPILERS = Object.freeze({ 1: compileVersionOne } satisfies Record< - (typeof SUPPORTED_SEMANTIC_CSS_VERSIONS)[number], - StylesheetCompiler ->); - -export function getStylesheetCompiler(version: number): StylesheetCompiler | undefined { - return COMPILERS[version as keyof typeof COMPILERS]; -} diff --git a/packages/ui/src/components/hover-card.test.tsx b/packages/ui/src/components/hover-card.test.tsx deleted file mode 100644 index ef0b7879d..000000000 --- a/packages/ui/src/components/hover-card.test.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { render, screen } from "@testing-library/react"; -import { describe, expect, it } from "vitest"; -import { HoverCard, HoverCardContent, HoverCardTrigger } from "./hover-card"; - -describe("HoverCard", () => { - it("trigger has data-slot='hover-card-trigger'", () => { - render( - - Hover - preview - , - ); - expect(screen.getByTestId("trigger")).toHaveAttribute("data-slot", "hover-card-trigger"); - }); - - it("does not render content by default", () => { - render( - - Hover - Hidden preview - , - ); - expect(screen.queryByText("Hidden preview")).not.toBeInTheDocument(); - }); - - it("renders content when controlled open", () => { - render( - - Hover - Visible preview - , - ); - expect(screen.getByText("Visible preview")).toBeInTheDocument(); - }); - - it("HoverCardContent uses data-slot='hover-card-content' when shown", () => { - render( - - x - visible - , - ); - expect(screen.getByText("visible")).toHaveAttribute("data-slot", "hover-card-content"); - }); - - it("merges custom className on HoverCardContent", () => { - render( - - x - visible - , - ); - expect(screen.getByText("visible")).toHaveClass("my-class"); - }); -}); diff --git a/packages/ui/src/components/hover-card.tsx b/packages/ui/src/components/hover-card.tsx deleted file mode 100644 index a9400619f..000000000 --- a/packages/ui/src/components/hover-card.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { PreviewCard as PreviewCardPrimitive } from "@base-ui/react/preview-card"; -import { cn } from "@reactive-resume/utils/style"; - -function HoverCard({ ...props }: PreviewCardPrimitive.Root.Props) { - return ; -} - -function HoverCardTrigger({ ...props }: PreviewCardPrimitive.Trigger.Props) { - return ; -} - -function HoverCardContent({ - className, - side = "bottom", - sideOffset = 4, - align = "center", - alignOffset = 4, - ...props -}: PreviewCardPrimitive.Popup.Props & - Pick) { - return ( - - - - - - ); -} - -export { HoverCard, HoverCardContent, HoverCardTrigger }; diff --git a/packages/ui/src/components/sidebar.test.tsx b/packages/ui/src/components/sidebar.test.tsx index 6cca22233..2825a405a 100644 --- a/packages/ui/src/components/sidebar.test.tsx +++ b/packages/ui/src/components/sidebar.test.tsx @@ -6,21 +6,12 @@ import { SidebarContent, SidebarFooter, SidebarGroup, - SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, - SidebarInput, - SidebarInset, SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, @@ -192,22 +183,6 @@ describe("SidebarRail", () => { }); }); -describe("SidebarInset", () => { - it("renders as
    with data-slot='sidebar-inset'", () => { - render(x); - const inset = screen.getByTestId("inset"); - expect(inset.tagName).toBe("MAIN"); - expect(inset).toHaveAttribute("data-slot", "sidebar-inset"); - }); -}); - -describe("SidebarInput", () => { - it("renders with data-slot='sidebar-input'", () => { - render(); - expect(screen.getByTestId("i")).toHaveAttribute("data-slot", "sidebar-input"); - }); -}); - describe("SidebarHeader / SidebarFooter / SidebarSeparator", () => { it("SidebarHeader uses data-slot='sidebar-header'", () => { render(x); @@ -245,11 +220,6 @@ describe("SidebarContent / SidebarGroup", () => { render(Group); expect(screen.getByTestId("l")).toHaveAttribute("data-slot", "sidebar-group-label"); }); - - it("SidebarGroupAction uses data-slot='sidebar-group-action'", () => { - render(+); - expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-group-action"); - }); }); describe("SidebarMenu and items", () => { @@ -287,48 +257,6 @@ describe("SidebarMenu and items", () => { // data-active is set as a presence attribute when isActive is truthy expect(screen.getByTestId("b")).toHaveAttribute("data-active"); }); - - it("SidebarMenuAction uses data-slot='sidebar-menu-action'", () => { - render(x); - expect(screen.getByTestId("a")).toHaveAttribute("data-slot", "sidebar-menu-action"); - }); - - it("SidebarMenuBadge uses data-slot='sidebar-menu-badge'", () => { - render(5); - expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-badge"); - }); - - it("SidebarMenuSkeleton renders without throwing", () => { - render(); - expect(screen.getByTestId("sk")).toBeInTheDocument(); - }); - - it("SidebarMenuSkeleton supports showIcon", () => { - const { container } = render(); - // Should render the icon Skeleton - expect(container.querySelectorAll("[data-slot=skeleton]").length).toBeGreaterThan(1); - }); -}); - -describe("SidebarMenuSub family", () => { - it("SidebarMenuSub uses data-slot='sidebar-menu-sub' and is
      ", () => { - render(); - const ms = screen.getByTestId("ms"); - expect(ms.tagName).toBe("UL"); - expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub"); - }); - - it("SidebarMenuSubItem uses data-slot='sidebar-menu-sub-item' and is
    • ", () => { - render(x); - const ms = screen.getByTestId("ms"); - expect(ms.tagName).toBe("LI"); - expect(ms).toHaveAttribute("data-slot", "sidebar-menu-sub-item"); - }); - - it("SidebarMenuSubButton uses data-slot='sidebar-menu-sub-button'", () => { - render(x); - expect(screen.getByTestId("b")).toHaveAttribute("data-slot", "sidebar-menu-sub-button"); - }); }); describe("Sidebar composition", () => { diff --git a/packages/ui/src/components/sidebar.tsx b/packages/ui/src/components/sidebar.tsx index 5a01bf9e9..50638c8b9 100644 --- a/packages/ui/src/components/sidebar.tsx +++ b/packages/ui/src/components/sidebar.tsx @@ -5,10 +5,8 @@ import { SidebarIcon } from "@phosphor-icons/react"; import { cva } from "class-variance-authority"; import * as React from "react"; import { Button } from "@reactive-resume/ui/components/button"; -import { Input } from "@reactive-resume/ui/components/input"; import { Separator } from "@reactive-resume/ui/components/separator"; import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "@reactive-resume/ui/components/sheet"; -import { Skeleton } from "@reactive-resume/ui/components/skeleton"; import { Tooltip, TooltipContent, TooltipTrigger } from "@reactive-resume/ui/components/tooltip"; import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile"; import { cn } from "@reactive-resume/utils/style"; @@ -289,30 +287,6 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) { ); } -function SidebarInset({ className, ...props }: React.ComponentProps<"main">) { - return ( -
      - ); -} - -function SidebarInput({ className, ...props }: React.ComponentProps) { - return ( - - ); -} - function SidebarHeader({ className, ...props }: React.ComponentProps<"div">) { return (
      & React.ComponentProps<"button">) { - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn( - "absolute end-3 top-3.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 group-data-[collapsible=icon]:hidden md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", - className, - ), - }, - props, - ), - render, - state: { - slot: "sidebar-group-action", - sidebar: "group-action", - }, - }); -} - function SidebarGroupContent({ className, ...props }: React.ComponentProps<"div">) { return (
      & - React.ComponentProps<"button"> & { - showOnHover?: boolean; - }) { - return useRender({ - defaultTagName: "button", - props: mergeProps<"button">( - { - className: cn( - "absolute end-1 top-1.5 flex aspect-square w-5 items-center justify-center rounded-md p-0 text-sidebar-foreground outline-hidden ring-sidebar-ring transition-transform after:absolute after:-inset-2 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 peer-hover/menu-button:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden peer-data-[size=default]/menu-button:top-1.5 peer-data-[size=lg]/menu-button:top-2.5 peer-data-[size=sm]/menu-button:top-1 md:after:hidden [&>svg]:size-4 [&>svg]:shrink-0", - showOnHover && - "group-focus-within/menu-item:opacity-100 group-hover/menu-item:opacity-100 aria-expanded:opacity-100 peer-data-active/menu-button:text-sidebar-accent-foreground md:opacity-0", - className, - ), - }, - props, - ), - render, - state: { - slot: "sidebar-menu-action", - sidebar: "menu-action", - }, - }); -} - -function SidebarMenuBadge({ className, ...props }: React.ComponentProps<"div">) { - return ( -
      - ); -} - -function SidebarMenuSkeleton({ - className, - showIcon = false, - ...props -}: React.ComponentProps<"div"> & { - showIcon?: boolean; -}) { - // Random width between 50 to 90%. - const [width] = React.useState(() => { - return `${Math.floor(Math.random() * 40) + 50}%`; - }); - - return ( -
      - {showIcon && } - -
      - ); -} - -function SidebarMenuSub({ className, ...props }: React.ComponentProps<"ul">) { - return ( -
        - ); -} - -function SidebarMenuSubItem({ className, ...props }: React.ComponentProps<"li">) { - return ( -
      • - ); -} - -function SidebarMenuSubButton({ - render, - size = "md", - isActive = false, - className, - ...props -}: useRender.ComponentProps<"a"> & - React.ComponentProps<"a"> & { - size?: "sm" | "md"; - isActive?: boolean; - }) { - return useRender({ - defaultTagName: "a", - props: mergeProps<"a">( - { - className: cn( - "flex h-7 min-w-0 -translate-x-px items-center gap-2 overflow-hidden rounded-md px-2 text-sidebar-foreground outline-hidden ring-sidebar-ring hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-active:bg-sidebar-accent data-[size=md]:text-sm data-[size=sm]:text-xs data-active:text-sidebar-accent-foreground group-data-[collapsible=icon]:hidden rtl:translate-x-px [&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0 [&>svg]:text-sidebar-accent-foreground", - className, - ), - }, - props, - ), - render, - state: { - slot: "sidebar-menu-sub-button", - sidebar: "menu-sub-button", - size, - active: isActive, - }, - }); -} - export { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, - SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, - SidebarInput, - SidebarInset, SidebarMenu, - SidebarMenuAction, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, - SidebarMenuSkeleton, - SidebarMenuSub, - SidebarMenuSubButton, - SidebarMenuSubItem, SidebarProvider, SidebarRail, SidebarSeparator, diff --git a/packages/utils/package.json b/packages/utils/package.json index 4cadc140f..99669ab3a 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -11,7 +11,6 @@ "./rate-limit": "./src/rate-limit.ts", "./string": "./src/string.ts", "./style": "./src/style.ts", - "./url": "./src/url.ts", "./url-security.node": "./src/url-security.node.ts" }, "scripts": { diff --git a/packages/utils/src/url.test.ts b/packages/utils/src/url.test.ts deleted file mode 100644 index 8799d66fd..000000000 --- a/packages/utils/src/url.test.ts +++ /dev/null @@ -1,40 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createUrl } from "./url"; - -describe("createUrl", () => { - it("returns empty url and label when no url provided", () => { - expect(createUrl()).toEqual({ url: "", label: "" }); - }); - - it("returns empty pair when url is empty string (falsy)", () => { - expect(createUrl("")).toEqual({ url: "", label: "" }); - }); - - it("uses url as label when no label provided", () => { - expect(createUrl("https://example.com")).toEqual({ - url: "https://example.com", - label: "https://example.com", - }); - }); - - it("preserves provided label", () => { - expect(createUrl("https://example.com", "Example")).toEqual({ - url: "https://example.com", - label: "Example", - }); - }); - - it("falls back to url when label is empty string", () => { - expect(createUrl("https://example.com", "")).toEqual({ - url: "https://example.com", - label: "https://example.com", - }); - }); - - it("does not validate the url format (caller's responsibility)", () => { - expect(createUrl("not-a-url", "Label")).toEqual({ - url: "not-a-url", - label: "Label", - }); - }); -}); diff --git a/packages/utils/src/url.ts b/packages/utils/src/url.ts deleted file mode 100644 index 6161be990..000000000 --- a/packages/utils/src/url.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * Creates a URL object with a url and label. - * Returns empty strings if no URL is provided. - */ -export function createUrl(url?: string, label?: string): { url: string; label: string } { - if (!url) return { url: "", label: "" }; - return { url, label: label || url }; -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f72be6b3f..6991db9aa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -165,7 +165,6 @@ settings: overrides: '@better-auth/infra>@better-fetch/fetch': 1.3.2 esbuild@>=0.17.0 <0.28.1: ^0.28.1 - esbuild@>=0.27.3 <0.28.1: ^0.28.1 postcss@<8.5.10: ^8.5.14 samlify@<2.13.0: ^2.13.0 uuid@<11.1.1: ^11.1.1 @@ -195,9 +194,6 @@ importers: '@reactive-resume/config': specifier: workspace:* version: link:packages/config - '@testing-library/dom': - specifier: ^10.4.2 - version: 10.4.2 '@testing-library/jest-dom': specifier: ^7.0.1 version: 7.0.1(@testing-library/dom@10.4.2)(vitest@5.0.1) @@ -655,15 +651,9 @@ importers: better-auth: specifier: 1.7.5 version: 1.7.5(drizzle-kit@1.0.0-rc.4)(drizzle-orm@1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.0)(zod@4.6.5))(next@16.3.3(@babel/core@8.0.5)(@playwright/test@1.63.0)(@types/node@26.6.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(pg@8.23.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(solid-js@1.9.15)(vitest@5.0.1) - buffer: - specifier: ^6.0.3 - version: 6.0.3 cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - drizzle-orm: - specifier: 1.0.0-rc.4 - version: 1.0.0-rc.4(@types/pg@8.23.1)(pg@8.23.0)(zod@4.6.5) es-toolkit: specifier: ^1.52.0 version: 1.52.0 @@ -682,9 +672,6 @@ importers: pdfjs-dist: specifier: 6.3.289 version: 6.3.289 - pg: - specifier: ^8.23.0 - version: 8.23.0 prettier: specifier: ^3.9.7 version: 3.9.7 @@ -770,9 +757,6 @@ importers: '@types/babel__core': specifier: ^7.20.5 version: 7.20.5 - '@types/pg': - specifier: ^8.23.1 - version: 8.23.1 '@types/react': specifier: ^19.3.0 version: 19.3.0 @@ -809,9 +793,6 @@ importers: '@reactive-resume/utils': specifier: workspace:* version: link:../utils - deepmerge-ts: - specifier: ^8.0.2 - version: 8.0.2 jsonrepair: specifier: ^3.15.0 version: 3.15.0 @@ -936,9 +917,6 @@ importers: ollama-ai-provider-v2: specifier: ^4.0.1 version: 4.0.1(ai@7.0.102(zod@4.6.5))(zod@4.6.5) - react: - specifier: ^19.3.0 - version: 19.3.0 resumable-stream: specifier: ^2.2.12 version: 2.2.12 @@ -1147,9 +1125,6 @@ importers: specifier: ^6.9.5 version: 6.9.5(react-dom@19.3.0(react@19.3.0))(react@19.3.0)(supports-color@7.2.0) devDependencies: - '@react-email/ui': - specifier: ^6.9.5 - version: 6.9.5(@babel/core@8.0.5)(@playwright/test@1.63.0)(@types/node@26.6.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) '@reactive-resume/config': specifier: workspace:* version: link:../config @@ -2454,312 +2429,156 @@ packages: '@emnapi/wasi-threads@1.2.2': resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==} - '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [aix] - '@esbuild/aix-ppc64@0.28.2': resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] - '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [android] - '@esbuild/android-arm64@0.28.2': resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==} engines: {node: '>=18'} cpu: [arm64] os: [android] - '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [android] - '@esbuild/android-arm@0.28.2': resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==} engines: {node: '>=18'} cpu: [arm] os: [android] - '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} - engines: {node: '>=18'} - cpu: [x64] - os: [android] - '@esbuild/android-x64@0.28.2': resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==} engines: {node: '>=18'} cpu: [x64] os: [android] - '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [darwin] - '@esbuild/darwin-arm64@0.28.2': resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] - '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [darwin] - '@esbuild/darwin-x64@0.28.2': resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==} engines: {node: '>=18'} cpu: [x64] os: [darwin] - '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [freebsd] - '@esbuild/freebsd-arm64@0.28.2': resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] - '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [freebsd] - '@esbuild/freebsd-x64@0.28.2': resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] - '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} - engines: {node: '>=18'} - cpu: [arm64] - os: [linux] - '@esbuild/linux-arm64@0.28.2': resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==} engines: {node: '>=18'} cpu: [arm64] os: [linux] - '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} - engines: {node: '>=18'} - cpu: [arm] - os: [linux] - '@esbuild/linux-arm@0.28.2': resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==} engines: {node: '>=18'} cpu: [arm] os: [linux] - '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} - engines: {node: '>=18'} - cpu: [ia32] - os: [linux] - '@esbuild/linux-ia32@0.28.2': resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] - '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} - engines: {node: '>=18'} - cpu: [loong64] - os: [linux] - '@esbuild/linux-loong64@0.28.2': resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==} engines: {node: '>=18'} cpu: [loong64] os: [linux] - '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} - engines: {node: '>=18'} - cpu: [mips64el] - os: [linux] - '@esbuild/linux-mips64el@0.28.2': resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] - '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} - engines: {node: '>=18'} - cpu: [ppc64] - os: [linux] - '@esbuild/linux-ppc64@0.28.2': resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] - '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} - engines: {node: '>=18'} - cpu: [riscv64] - os: [linux] - '@esbuild/linux-riscv64@0.28.2': resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] - '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} - engines: {node: '>=18'} - cpu: [s390x] - os: [linux] - '@esbuild/linux-s390x@0.28.2': resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] - '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} - engines: {node: '>=18'} - cpu: [x64] - os: [linux] - '@esbuild/linux-x64@0.28.2': resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==} engines: {node: '>=18'} cpu: [x64] os: [linux] - '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} - engines: {node: '>=18'} - cpu: [arm64] - os: [netbsd] - '@esbuild/netbsd-arm64@0.28.2': resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] - '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} - engines: {node: '>=18'} - cpu: [x64] - os: [netbsd] - '@esbuild/netbsd-x64@0.28.2': resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] - '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openbsd] - '@esbuild/openbsd-arm64@0.28.2': resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] - '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} - engines: {node: '>=18'} - cpu: [x64] - os: [openbsd] - '@esbuild/openbsd-x64@0.28.2': resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] - '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} - engines: {node: '>=18'} - cpu: [arm64] - os: [openharmony] - '@esbuild/openharmony-arm64@0.28.2': resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] - '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} - engines: {node: '>=18'} - cpu: [x64] - os: [sunos] - '@esbuild/sunos-x64@0.28.2': resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==} engines: {node: '>=18'} cpu: [x64] os: [sunos] - '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} - engines: {node: '>=18'} - cpu: [arm64] - os: [win32] - '@esbuild/win32-arm64@0.28.2': resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==} engines: {node: '>=18'} cpu: [arm64] os: [win32] - '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} - engines: {node: '>=18'} - cpu: [ia32] - os: [win32] - '@esbuild/win32-ia32@0.28.2': resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==} engines: {node: '>=18'} cpu: [ia32] os: [win32] - '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} - engines: {node: '>=18'} - cpu: [x64] - os: [win32] - '@esbuild/win32-x64@0.28.2': resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==} engines: {node: '>=18'} @@ -3980,9 +3799,6 @@ packages: react: ^18.0 || ^19.0 || ^19.0.0-rc react-dom: ^18.0 || ^19.0 || ^19.0.0-rc - '@react-email/ui@6.9.5': - resolution: {integrity: sha512-UvE0yeSQCvdBm9BoEgvZ5vBi8abk02GbRE+4RQjjtxozy66D/O1GbUgK9RdB26+7EqQdOxdwkGLq5CMCiRO96A==} - '@react-pdf/fns@3.1.3': resolution: {integrity: sha512-0I7pApDr1/RLAKbizuLy/IHTEa93LSPy/bEwYniboC3Xqnp6Od8xFJKbKEzGw2wh/5zKFFwl00g4t9RwgIMc3w==} @@ -5618,9 +5434,6 @@ packages: resolution: {integrity: sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==} engines: {node: '>=4.0'} - buffer@6.0.3: - resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} - bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -6296,11 +6109,6 @@ packages: es-toolkit@1.52.0: resolution: {integrity: sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA==} - esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} - engines: {node: '>=18'} - hasBin: true - esbuild@0.28.2: resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==} engines: {node: '>=18'} @@ -6634,9 +6442,6 @@ packages: resolution: {integrity: sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==} engines: {node: '>=0.10.0'} - ieee754@1.2.1: - resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -10220,159 +10025,81 @@ snapshots: tslib: 2.8.1 optional: true - '@esbuild/aix-ppc64@0.28.1': - optional: true - '@esbuild/aix-ppc64@0.28.2': optional: true - '@esbuild/android-arm64@0.28.1': - optional: true - '@esbuild/android-arm64@0.28.2': optional: true - '@esbuild/android-arm@0.28.1': - optional: true - '@esbuild/android-arm@0.28.2': optional: true - '@esbuild/android-x64@0.28.1': - optional: true - '@esbuild/android-x64@0.28.2': optional: true - '@esbuild/darwin-arm64@0.28.1': - optional: true - '@esbuild/darwin-arm64@0.28.2': optional: true - '@esbuild/darwin-x64@0.28.1': - optional: true - '@esbuild/darwin-x64@0.28.2': optional: true - '@esbuild/freebsd-arm64@0.28.1': - optional: true - '@esbuild/freebsd-arm64@0.28.2': optional: true - '@esbuild/freebsd-x64@0.28.1': - optional: true - '@esbuild/freebsd-x64@0.28.2': optional: true - '@esbuild/linux-arm64@0.28.1': - optional: true - '@esbuild/linux-arm64@0.28.2': optional: true - '@esbuild/linux-arm@0.28.1': - optional: true - '@esbuild/linux-arm@0.28.2': optional: true - '@esbuild/linux-ia32@0.28.1': - optional: true - '@esbuild/linux-ia32@0.28.2': optional: true - '@esbuild/linux-loong64@0.28.1': - optional: true - '@esbuild/linux-loong64@0.28.2': optional: true - '@esbuild/linux-mips64el@0.28.1': - optional: true - '@esbuild/linux-mips64el@0.28.2': optional: true - '@esbuild/linux-ppc64@0.28.1': - optional: true - '@esbuild/linux-ppc64@0.28.2': optional: true - '@esbuild/linux-riscv64@0.28.1': - optional: true - '@esbuild/linux-riscv64@0.28.2': optional: true - '@esbuild/linux-s390x@0.28.1': - optional: true - '@esbuild/linux-s390x@0.28.2': optional: true - '@esbuild/linux-x64@0.28.1': - optional: true - '@esbuild/linux-x64@0.28.2': optional: true - '@esbuild/netbsd-arm64@0.28.1': - optional: true - '@esbuild/netbsd-arm64@0.28.2': optional: true - '@esbuild/netbsd-x64@0.28.1': - optional: true - '@esbuild/netbsd-x64@0.28.2': optional: true - '@esbuild/openbsd-arm64@0.28.1': - optional: true - '@esbuild/openbsd-arm64@0.28.2': optional: true - '@esbuild/openbsd-x64@0.28.1': - optional: true - '@esbuild/openbsd-x64@0.28.2': optional: true - '@esbuild/openharmony-arm64@0.28.1': - optional: true - '@esbuild/openharmony-arm64@0.28.2': optional: true - '@esbuild/sunos-x64@0.28.1': - optional: true - '@esbuild/sunos-x64@0.28.2': optional: true - '@esbuild/win32-arm64@0.28.1': - optional: true - '@esbuild/win32-arm64@0.28.2': optional: true - '@esbuild/win32-ia32@0.28.1': - optional: true - '@esbuild/win32-ia32@0.28.2': optional: true - '@esbuild/win32-x64@0.28.1': - optional: true - '@esbuild/win32-x64@0.28.2': optional: true @@ -10836,7 +10563,8 @@ snapshots: '@neodrag/core': 3.0.0-next.11 solid-js: 1.9.15 - '@next/env@16.3.3': {} + '@next/env@16.3.3': + optional: true '@next/swc-darwin-arm64@16.3.3': optional: true @@ -11499,21 +11227,6 @@ snapshots: react: 19.3.0 react-dom: 19.3.0(react@19.3.0) - '@react-email/ui@6.9.5(@babel/core@8.0.5)(@playwright/test@1.63.0)(@types/node@26.6.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0)': - dependencies: - esbuild: 0.28.1 - next: 16.3.3(@babel/core@8.0.5)(@playwright/test@1.63.0)(@types/node@26.6.1)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - transitivePeerDependencies: - - '@babel/core' - - '@opentelemetry/api' - - '@playwright/test' - - '@types/node' - - babel-plugin-macros - - babel-plugin-react-compiler - - react - - react-dom - - sass - '@react-pdf/fns@3.1.3': {} '@react-pdf/font@4.1.2': @@ -13015,11 +12728,6 @@ snapshots: dependencies: '@types/node': 26.6.1 - buffer@6.0.3: - dependencies: - base64-js: 1.5.1 - ieee754: 1.2.1 - bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -13098,7 +12806,8 @@ snapshots: optionalDependencies: '@colors/colors': 1.5.0 - client-only@0.0.1: {} + client-only@0.0.1: + optional: true cliui@9.0.1: dependencies: @@ -13490,35 +13199,6 @@ snapshots: es-toolkit@1.52.0: {} - esbuild@0.28.1: - optionalDependencies: - '@esbuild/aix-ppc64': 0.28.1 - '@esbuild/android-arm': 0.28.1 - '@esbuild/android-arm64': 0.28.1 - '@esbuild/android-x64': 0.28.1 - '@esbuild/darwin-arm64': 0.28.1 - '@esbuild/darwin-x64': 0.28.1 - '@esbuild/freebsd-arm64': 0.28.1 - '@esbuild/freebsd-x64': 0.28.1 - '@esbuild/linux-arm': 0.28.1 - '@esbuild/linux-arm64': 0.28.1 - '@esbuild/linux-ia32': 0.28.1 - '@esbuild/linux-loong64': 0.28.1 - '@esbuild/linux-mips64el': 0.28.1 - '@esbuild/linux-ppc64': 0.28.1 - '@esbuild/linux-riscv64': 0.28.1 - '@esbuild/linux-s390x': 0.28.1 - '@esbuild/linux-x64': 0.28.1 - '@esbuild/netbsd-arm64': 0.28.1 - '@esbuild/netbsd-x64': 0.28.1 - '@esbuild/openbsd-arm64': 0.28.1 - '@esbuild/openbsd-x64': 0.28.1 - '@esbuild/openharmony-arm64': 0.28.1 - '@esbuild/sunos-x64': 0.28.1 - '@esbuild/win32-arm64': 0.28.1 - '@esbuild/win32-ia32': 0.28.1 - '@esbuild/win32-x64': 0.28.1 - esbuild@0.28.2: optionalDependencies: '@esbuild/aix-ppc64': 0.28.2 @@ -13945,8 +13625,6 @@ snapshots: dependencies: safer-buffer: 2.1.2 - ieee754@1.2.1: {} - ignore@5.3.2: {} ignore@7.0.9: {} @@ -14916,6 +14594,7 @@ snapshots: - '@babel/core' - '@types/node' - babel-plugin-macros + optional: true node-addon-api@8.9.2: {} @@ -15286,6 +14965,7 @@ snapshots: nanoid: 3.3.19 picocolors: 1.1.1 source-map-js: 1.2.1 + optional: true postcss@8.5.28: dependencies: @@ -16091,6 +15771,7 @@ snapshots: optionalDependencies: '@babel/core': 8.0.5 babel-plugin-macros: 3.1.0 + optional: true superjson@2.2.6: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index c22b2ff53..576a015d7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -2,18 +2,14 @@ packages: - apps/* - packages/* - tooling -minimumReleaseAge: 0 allowBuilds: bcrypt: true esbuild: true lefthook: true - msgpackr-extract: true - msw: true sharp: true overrides: '@better-auth/infra>@better-fetch/fetch': 1.3.2 esbuild@>=0.17.0 <0.28.1: ^0.28.1 - esbuild@>=0.27.3 <0.28.1: ^0.28.1 postcss@<8.5.10: ^8.5.14 samlify@<2.13.0: ^2.13.0 uuid@<11.1.1: ^11.1.1 diff --git a/tooling/fonts/generate.ts b/tooling/fonts/generate.ts deleted file mode 100644 index 6c09203d5..000000000 --- a/tooling/fonts/generate.ts +++ /dev/null @@ -1,261 +0,0 @@ -/** - * This script generates a JSON file containing the fonts served by Google Fonts, - * and also injects the Computer Modern font families as served by https://github.com/bitmaks/cm-web-fonts - * to ensure they appear in the application's typography options. - * - * See: - * - Google Fonts API: https://developers.google.com/fonts/docs/developer_api - * - Computer Modern Web Fonts: https://github.com/bitmaks/cm-web-fonts - */ - -import type { APIResponse, Variant, WebFont, Weight } from "./types"; -import { mkdir, readFile, writeFile } from "node:fs/promises"; - -const args = process.argv.slice(2); -const argForce = args.includes("--force"); -const argCompress = args.includes("--compress"); - -// biome-ignore lint/style/noNonNullAssertion: it's okay -const argLimit = args.includes("--limit") ? Number.parseInt(args[args.indexOf("--limit") + 1]!, 10) : 500; - -const skippedFamilies = ["Material Icons", "Material Symbols", "Noto Color Emoji"]; - -const FONTS_DIR = "./scripts/fonts"; -const RESPONSE_FILE = `${FONTS_DIR}/response.json`; -const WEBFONTLIST_FILE = `${FONTS_DIR}/webfontlist.json`; - -/** Returns JSON from Google Fonts API or (unless --force) from local cache if it exists */ -async function getGoogleFontsJSON() { - let contents: string | null = null; - - try { - contents = await readFile(RESPONSE_FILE, "utf-8"); - } catch { - // If the file doesn't exist or there's an error reading, just continue. - } - - if (!argForce && contents) return JSON.parse(contents) as APIResponse; - - const apiKey = process.env.GOOGLE_CLOUD_API_KEY; - if (!apiKey) throw new Error("GOOGLE_CLOUD_API_KEY is not set"); - - const url = `https://www.googleapis.com/webfonts/v1/webfonts?key=${apiKey}&sort=popularity`; - const response = await fetch(url); - const data = (await response.json()) as APIResponse; - - const jsonString = argCompress ? JSON.stringify(data) : JSON.stringify(data, null, 2); - await mkdir(FONTS_DIR, { recursive: true }); - await writeFile(RESPONSE_FILE, jsonString, "utf-8"); - - return data; -} - -/** Map Google Fonts API variant strings to simple weights */ -function variantToWeight(variant: Variant): Weight | null { - if (["100", "200", "300", "500", "600", "700", "800", "900"].includes(variant)) return variant as Weight; - if (variant === "regular") return "400"; - return null; -} - -/** - * Helper: Get additional Computer Modern webfonts (manually curated from https://github.com/bitmaks/cm-web-fonts) - * These do NOT come from Google Fonts but should appear in webfontlist.json output. - * Files are delivered via jsDelivr CDN. - */ -function getComputerModernWebFonts(): WebFont[] { - const CDN = "https://cdn.jsdelivr.net/gh/bitmaks/cm-web-fonts@latest/font"; - - return [ - { - type: "web", - category: "display", - family: "Computer Modern Bright", - weights: ["400", "700"], - preview: `${CDN}/Bright/cmunbmr.woff`, - files: { - "400": `${CDN}/Bright/cmunbmr.woff`, - "400italic": `${CDN}/Bright/cmunbmo.woff`, - "700": `${CDN}/Bright/cmunbbx.woff`, - "700italic": `${CDN}/Bright/cmunbxo.woff`, - }, - }, - { - type: "web", - category: "serif", - family: "Computer Modern Concrete", - weights: ["400", "700"], - preview: `${CDN}/Concrete/cmunorm.woff`, - files: { - "400": `${CDN}/Concrete/cmunorm.woff`, - "400italic": `${CDN}/Concrete/cmunobi.woff`, - "700": `${CDN}/Concrete/cmunobx.woff`, - "700italic": `${CDN}/Concrete/cmunoti.woff`, - }, - }, - { - type: "web", - category: "sans-serif", - family: "Computer Modern Sans", - weights: ["400", "700"], - preview: `${CDN}/Sans/cmunss.woff`, - files: { - "400": `${CDN}/Sans/cmunss.woff`, - "400italic": `${CDN}/Sans/cmunsi.woff`, - "700": `${CDN}/Sans/cmunsx.woff`, - "700italic": `${CDN}/Sans/cmunso.woff`, - }, - }, - { - type: "web", - category: "serif", - family: "Computer Modern Serif", - weights: ["400", "700"], - preview: `${CDN}/Serif/cmunrm.woff`, - files: { - "400": `${CDN}/Serif/cmunrm.woff`, - "400italic": `${CDN}/Serif/cmunti.woff`, - "700": `${CDN}/Serif/cmunbx.woff`, - "700italic": `${CDN}/Serif/cmunbi.woff`, - }, - }, - { - type: "web", - category: "monospace", - family: "Computer Modern Typewriter", - weights: ["400", "700"], - preview: `${CDN}/Typewriter/cmuntt.woff`, - files: { - "400": `${CDN}/Typewriter/cmuntt.woff`, - "400italic": `${CDN}/Typewriter/cmunit.woff`, - "700": `${CDN}/Typewriter/cmuntx.woff`, - "700italic": `${CDN}/Typewriter/cmuntb.woff`, - }, - }, - ]; -} - -/** - * Helper: Additional metric-compatible web fonts that aren't in the - * popularity-sorted Google Fonts slice but are needed as render targets - * for legacy local-font aliases (#2989). Carlito is the open-source - * metric-compatible equivalent of Calibri. - */ -function getMetricCompatibleFonts(): WebFont[] { - const CDN = "https://fonts.gstatic.com/s/carlito/v4"; - - return [ - { - type: "web", - category: "sans-serif", - family: "Carlito", - weights: ["400", "700"], - preview: `${CDN}/3Jn9SDPw3m-pk039DDeBSQ.ttf`, - files: { - "400": `${CDN}/3Jn9SDPw3m-pk039DDeBSQ.ttf`, - "700": `${CDN}/3Jn4SDPw3m-pk039BIykWXolVg.ttf`, - "400italic": `${CDN}/3Jn_SDPw3m-pk039DDKxTl0F.ttf`, - "700italic": `${CDN}/3Jn6SDPw3m-pk039DDK59XgVUcBD.ttf`, - }, - }, - ]; -} - -/** - * Helper: locale-coverage web fonts that aren't in the popularity-sorted - * Google Fonts slice but are stored as `typography.*.fontFamily` in imported - * resume JSON. Without a catalog entry, PDF registration silently substitutes - * IBM Plex Serif and Persian/Arabic glyphs stack or tofu (#3098). - */ -function getLocaleCoverageFonts(): WebFont[] { - const CDN = "https://fonts.gstatic.com/s/vazirmatn/v16"; - - return [ - { - type: "web", - category: "sans-serif", - family: "Vazirmatn", - weights: ["100", "200", "300", "400", "500", "600", "700", "800", "900"], - preview: `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf`, - files: { - "100": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgyCRCT60LA7Qdazg.ttf`, - "200": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklegzCRCT60LA7Qdazg.ttf`, - "300": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklTYzCRCT60LA7Qdazg.ttf`, - "400": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklWgzCRCT60LA7Qdazg.ttf`, - "500": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklVozCRCT60LA7Qdazg.ttf`, - "600": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklbY0CRCT60LA7Qdazg.ttf`, - "700": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklY80CRCT60LA7Qdazg.ttf`, - "800": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRkleg0CRCT60LA7Qdazg.ttf`, - "900": `${CDN}/Dxx78j6PP2D_kU2muijPEe1n2vVbfJRklcE0CRCT60LA7Qdazg.ttf`, - }, - }, - ]; -} - -async function generateFonts() { - const response = await getGoogleFontsJSON(); - console.log(`Found ${response.items.length} fonts in total (Google Fonts).`); - - const filteredItems = response.items.filter( - (item) => !skippedFamilies.some((family) => item.family.includes(family)), - ); - - const googleFontResults: WebFont[] = filteredItems.slice(0, argLimit).map((item) => { - // 1. weights: Only non-italic, convert "regular" to "400" - const weights: Weight[] = item.variants.map((v) => variantToWeight(v)).filter((w): w is Weight => !!w); - - // 2. files: all files, but change "regular"->"400", "italic"->"400italic" - const files: Record = {}; - - for (const [variant, url] of Object.entries(item.files)) { - let key = variant; - if (variant === "regular") key = "400"; - else if (variant === "italic") key = "400italic"; - files[key] = url; - } - - return { - type: "web", - category: item.category, - family: item.family, - weights, - preview: item.menu, - files, - } satisfies WebFont; - }); - - // Manually append Computer Modern web fonts - const computerModernFonts = getComputerModernWebFonts(); - // Manually append metric-compatible fonts not covered by the popularity slice - const metricCompatFonts = getMetricCompatibleFonts(); - const localeCoverageFonts = getLocaleCoverageFonts(); - // De-duplicate against the Google Fonts slice in case a manual entry - // later enters the popularity top-N. - const googleFontFamilies = new Set(googleFontResults.map((f) => f.family)); - const filteredMetricCompat = metricCompatFonts.filter((f) => !googleFontFamilies.has(f.family)); - const filteredLocaleCoverage = localeCoverageFonts.filter((f) => !googleFontFamilies.has(f.family)); - - const allWebFonts: WebFont[] = [ - ...computerModernFonts, - ...googleFontResults, - ...filteredMetricCompat, - ...filteredLocaleCoverage, - ]; - - const manualFontSummary = [ - `${computerModernFonts.length} Computer Modern Web Fonts`, - `${filteredMetricCompat.length} metric-compatible fonts`, - `and ${filteredLocaleCoverage.length} locale-coverage fonts`, - ].join(", "); - - console.log(`Added ${manualFontSummary}. Total output: ${allWebFonts.length} web fonts.`); - - const jsonString = argCompress ? JSON.stringify(allWebFonts) : JSON.stringify(allWebFonts, null, 2); - await mkdir(FONTS_DIR, { recursive: true }); - await writeFile(WEBFONTLIST_FILE, jsonString, "utf-8"); - - console.log(`Generated ${allWebFonts.length} fonts in the list (including Computer Modern web fonts).`); -} - -if (import.meta.main) { - await generateFonts(); -} diff --git a/tooling/fonts/types.ts b/tooling/fonts/types.ts deleted file mode 100644 index c8024bd89..000000000 --- a/tooling/fonts/types.ts +++ /dev/null @@ -1,51 +0,0 @@ -type Category = "display" | "handwriting" | "monospace" | "serif" | "sans-serif"; - -export type Variant = - | "100" - | "100italic" - | "200" - | "200italic" - | "300" - | "300italic" - | "regular" - | "italic" - | "500" - | "500italic" - | "600" - | "600italic" - | "700" - | "700italic" - | "800" - | "800italic" - | "900" - | "900italic"; - -type Item = { - kind: "webfonts#webfont"; - menu: string; - family: string; - version: string; - category: Category; - lastModified: string; - subsets: string[]; - variants: Variant[]; - colorCapabilities?: string[]; - files: Partial>; -}; - -export type APIResponse = { - kind: "webfonts#webfontList"; - items: Item[]; -}; - -export type Weight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900"; -type FileWeight = Weight | `${Weight}italic`; - -export type WebFont = { - type: "web"; - category: Category; - family: string; - weights: Weight[]; - preview: string; - files: Partial>; -}; diff --git a/tooling/package.json b/tooling/package.json index 176cdddac..538ecdf3f 100644 --- a/tooling/package.json +++ b/tooling/package.json @@ -6,7 +6,6 @@ "scripts": { "db:reset": "tsx database/reset.ts", "docs:gen": "tsx semantic-css/generate-reference.ts", - "fonts:generate": "tsx fonts/generate.ts", "test": "vitest run --passWithNoTests", "test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests", "typecheck": "tsgo --noEmit",