fix: audit code for reduction

This commit is contained in:
Amruth Pillai
2026-09-17 22:16:44 +02:00
parent a30bf371ff
commit b953435f2c
122 changed files with 1178 additions and 4861 deletions
-17
View File
@@ -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";
},
};
-1
View File
@@ -1 +0,0 @@
AGENTS.md
+20 -36
View File
@@ -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<typeof router> {
}
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);
+1 -1
View File
@@ -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 {
-4
View File
@@ -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",
@@ -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<typeof TextMaskEffect>) => render(<TextMaskEffect {...props} />);
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");
});
});
@@ -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<SVGSVGElement>(null);
const [hovered, setHovered] = useState(false);
const [maskPosition, setMaskPosition] = useState({ cx: "50%", cy: "50%" });
return (
<svg
ref={svgRef}
width="100%"
height="100%"
viewBox="0 0 300 40"
aria-hidden={ariaHidden}
aria-label="Text mask effect"
className={cn("select-none", className)}
xmlns="http://www.w3.org/2000/svg"
onMouseEnter={() => 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}%` });
}}
>
<defs>
<linearGradient id="textGradient" gradientUnits="userSpaceOnUse" cx="50%" cy="50%" r="25%">
{hovered && (
<>
<stop offset="0%" stopColor="#eab308" />
<stop offset="25%" stopColor="#ef4444" />
<stop offset="50%" stopColor="#3b82f6" />
<stop offset="75%" stopColor="#06b6d4" />
<stop offset="100%" stopColor="#8b5cf6" />
</>
)}
</linearGradient>
<m.radialGradient
r="20%"
id="revealMask"
animate={maskPosition}
gradientUnits="userSpaceOnUse"
initial={{ cx: "50%", cy: "50%" }}
transition={{ duration: 0, ease: "easeOut" }}
>
<stop offset="0%" stopColor="white" />
<stop offset="100%" stopColor="black" />
</m.radialGradient>
<mask id="textMask">
<rect x="0" y="0" width="100%" height="100%" fill="url(#revealMask)" />
</mask>
</defs>
<text
x="50%"
y="50%"
strokeWidth="0.3"
textAnchor="middle"
dominantBaseline="middle"
style={{ opacity: hovered ? 0.7 : 0 }}
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
>
{text}
</text>
<m.text
x="50%"
y="50%"
strokeWidth="0.3"
textAnchor="middle"
dominantBaseline="middle"
transition={{ duration, ease: "easeInOut" }}
initial={{ strokeDashoffset: 1000, strokeDasharray: 1000 }}
whileInView={{ strokeDashoffset: 0, strokeDasharray: 1000 }}
className={cn(textClassName, "stroke-zinc-300 dark:stroke-zinc-700")}
>
{text}
</m.text>
<text
x="50%"
y="50%"
strokeWidth="0.3"
textAnchor="middle"
mask="url(#textMask)"
dominantBaseline="middle"
stroke="url(#textGradient)"
className={cn(textClassName)}
>
{text}
</text>
</svg>
);
};
+1 -1
View File
@@ -1,4 +1,4 @@
import type { AnyDialogRendererEntry } from "../renderer-registry";
import type { AnyDialogRendererEntry } from "../schemas";
import { CreateApiKeyDialog } from "./create";
export const apiKeyDialogRenderers: readonly AnyDialogRendererEntry[] = [
@@ -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 (
+1 -1
View File
@@ -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";
-12
View File
@@ -1,12 +0,0 @@
import type { ReactNode } from "react";
import type { DialogSchema, DialogType } from "./schemas";
// ponytail: defineDialogRenderer/defineDialogRendererRegistry were identity fns; inlined
type DialogRendererEntry<T extends DialogType = DialogType> = {
type: T;
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
};
export type AnyDialogRendererEntry = {
[T in DialogType]: DialogRendererEntry<T>;
}[DialogType];
+1 -1
View File
@@ -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";
+10
View File
@@ -1,4 +1,5 @@
import type { CoverLetterItem, CustomSection, SectionItem, SummaryItem } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
type EmptyDialog<T extends string> = { [K in T]: { type: K; data?: undefined } }[T];
@@ -55,6 +56,15 @@ export type DialogSchema =
export type DialogType = DialogSchema["type"];
export type DialogRendererEntry<T extends DialogType = DialogType> = {
type: T;
render: (dialog: Extract<DialogSchema, { type: T }>) => ReactNode;
};
export type AnyDialogRendererEntry = {
[T in DialogType]: DialogRendererEntry<T>;
}[DialogType];
export type DialogData<T extends DialogType> = Extract<DialogSchema, { type: T }>["data"];
type DialogPropsData<T extends DialogType> =
@@ -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);
});
});
+1 -12
View File
@@ -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();
}
@@ -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"] });
@@ -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<CommandPaletteStore>((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: () => {
@@ -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() {
<AccordionContent className="pb-3">
<ul className="space-y-2">
{hiddenSections.map(({ sectionId }) => {
const title = getRecoverySectionTitle(data, sectionId);
const title = resolveLayoutSectionTitle(data, sectionId);
return (
<li
@@ -34,44 +34,40 @@ function walk(root: SemanticNode): SemanticNode[] {
return nodes;
}
function unique(values: readonly string[]): string[] {
return [...new Set(values)];
}
function selectorLabels(metadata: SemanticCssEditorMetadata): string[] {
const nodes = walk(metadata.semanticTree);
const attributes = unique([
"id",
"role",
...Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ attributes }) => attributes),
]);
const roles = unique(Object.values(SEMANTIC_REGISTRY_V1).flatMap(({ roles }) => roles));
return unique([
...SEMANTIC_NODE_KINDS,
"*",
...nodes.flatMap((node) => (node.id ? [`#${escapeCssIdentifier(node.id)}`] : [])),
...attributes.map((attribute) => `[${escapeCssIdentifier(attribute)}]`),
...nodes.flatMap((node) =>
Object.entries(node.attributes).map(
([name, value]) => `[${escapeCssIdentifier(name)}=${escapeCssString(value)}]`,
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),
]),
];
}
}
}
+17
View File
@@ -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 };
}
+3 -12
View File
@@ -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,
+3 -16
View File
@@ -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<RouterContext>()({
],
};
},
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() {
+4 -17
View File
@@ -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<PanelImperativeHandle | null>(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 });
-464
View File
@@ -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
}
]
}
+1 -1
View File
@@ -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"],
-3
View File
@@ -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",
-1
View File
@@ -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"
},
+11 -5
View File
@@ -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<string, unknown>, source: Record<string, unknown>): Record<string, unknown> {
const output: Record<string, unknown> = { ...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",
-1
View File
@@ -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",
@@ -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.
+8 -20
View File
@@ -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<T>(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<string>();
// 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);
}
@@ -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() })
@@ -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")
+10 -24
View File
@@ -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<T>(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,
});
+1 -10
View File
@@ -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) {
-45
View File
@@ -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",
+9 -83
View File
@@ -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<ResumeData> {
return parseAndValidateResumeJson(result.text);
}
function buildChatSystemPrompt(resumeData: ResumeData): string {
return chatSystemPromptTemplate.replace("{{RESUME_DATA}}", JSON.stringify(resumeData, null, 2));
}
type ChatInput = z.infer<typeof aiCredentialsSchema> & {
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,
+5 -16
View File
@@ -77,28 +77,17 @@ export async function* subscribeResumeUpdated({ resumeId, userId, signal }: Subs
try {
await client.query(`LISTEN ${RESUME_UPDATED_CHANNEL}`);
const waitForNextEvent = async (): Promise<ResumeUpdatedEvent | null> => {
if (done) return null;
while (!done) {
const event = queue.shift();
if (event) return event;
if (event) {
yield event;
continue;
}
await new Promise<void>((resolve) => {
wake = resolve;
});
return waitForNextEvent();
};
async function* streamEvents(): AsyncGenerator<ResumeUpdatedEvent> {
const event = await waitForNextEvent();
if (!event) return;
yield event;
yield* streamEvents();
}
yield* streamEvents();
} finally {
signal?.removeEventListener("abort", onAbort);
client.off("notification", onNotification);
@@ -24,7 +24,7 @@ export type PublicResumePdfDependencies = {
findResume(input: Pick<CreatePublicResumePdfInput, "username" | "slug">): Promise<PublicRenderResume | null>;
hasPasswordAccess(requestHeaders: Headers, resumeId: string, passwordHash: string | null): boolean | Promise<boolean>;
resolveCurrentUserId(requestHeaders: Headers): Promise<string | undefined>;
rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void };
rateLimiter: { consume(input: { trustedClient: string; resumeId: string }): void | Promise<void> };
renderPdf(input: { data: ResumeData; filename: string }): Promise<File>;
};
@@ -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 };
}
@@ -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();
});
});
@@ -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<void>;
};
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<string, Bucket>();
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);
},
};
}
@@ -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<ResumeSocialMeta | null> {
const resume = await dependencies.findResume(input);
export async function getPublicResumeSocialMeta(input: PublicResumeSocialMetaInput): Promise<ResumeSocialMeta | null> {
const resume = await findResume(input);
if (!resume) return null;
return getResumeSocialMeta(parseStoredResumeData(resume.data), resume.name);
+1 -19
View File
@@ -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)}`;
}
@@ -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,
};
@@ -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}`;
-1
View File
@@ -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),
-1
View File
@@ -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",
+1 -2
View File
@@ -4,8 +4,7 @@
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./webfontlist.json": "./src/webfontlist.json"
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
+9 -11
View File
@@ -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");
}
});
+3 -3
View File
@@ -96,9 +96,9 @@ const punctuationFallbackFonts = {
sansSerif: "Noto Sans",
} as const;
export const webFontList = webFontListJSON as WebFont[];
export const webFontMap = new Map<string, WebFont>(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<string, WebFont>(webFontList.map((font) => [font.family, font]));
const standardFontList = standardPdfFontList.filter((font) => !webFontMap.has(font.family));
const fontMap = new Map<string, FontRecord>();
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
+5 -1
View File
@@ -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,
+58 -152
View File
@@ -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)
+1
View File
@@ -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";
+25 -33
View File
@@ -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,
+20 -7
View File
@@ -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 }) => ({
+159 -233
View File
@@ -371,200 +371,140 @@ export function registerTools(server: McpServer, client: RouterClient<typeof rou
}),
);
// ── Independent Cover Letters ───────────────────────────────────
server.registerTool(
T.listCoverLetters,
TOOL_META[T.listCoverLetters],
withErrorHandling("listing cover letters", async (params) => 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<CallToolResult>,
];
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<string, unknown>) 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<string, unknown>) 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<string, unknown>) as never);
return json({ id });
},
],
[
T.updateApplication,
"updating application",
async (params) =>
json(await client.applications.update(coerceFollowUpAt(params as Record<string, unknown>) 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<Record<string, unknown>> };
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<Record<string, unknown>> };
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<typeof rou
const file = fileFromBase64({ fileName, contentType, dataBase64 });
return json(await client.applications.attachDocument({ id, kind, file }));
},
),
);
server.registerTool(
T.removeApplicationDocument,
TOOL_META[T.removeApplicationDocument],
withErrorHandling(
],
[
T.removeApplicationDocument,
"removing application document",
async ({ id, kind }: { id: string; kind: "resume" | "cover-letter" }) => {
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);
}
}
-1
View File
@@ -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"
+5 -3
View File
@@ -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"');
});
@@ -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);
});
});
+3 -21
View File
@@ -86,9 +86,6 @@ export type SemanticBindingRegistry = Readonly<
export type BindingInventory = {
bindings: Readonly<Record<string, SemanticBinding>>;
aliasTokensByNodeKey: Readonly<Record<string, readonly string[]>>;
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<string, SemanticBinding> = {};
const aliasTokensByNodeKey: Record<string, readonly string[]> = {};
const unboundNodeKeys: string[] = [];
const nodes = new Map<string, SemanticNode>();
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 };
}
@@ -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 [
-1
View File
@@ -3,7 +3,6 @@ export type {
ResolveResumePresentationInput,
} from "./resolve";
export * from "./legacy-converter";
export * from "./legacy-parity";
export {
resolveResumePresentation,
resolveResumeRuntime,
@@ -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 =
'<p>Paragraph <strong>bold</strong> <mark>mark</mark> <a href="https://example.com">link</a></p><ul><li>List item</li></ul>';
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: "<p>Built engines.</p>",
roles: [
{
id: "role-1",
position: "Senior Engineer",
period: "1843",
description: "<p>Led the engine team.</p>",
},
],
},
];
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: "<p>Studied analytical engines.</p>",
},
];
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: "<p>First programmer.</p>",
},
];
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: "<p>Advised builders.</p>",
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<LegacyParityHostNode> => {
const element = createElement(ResumeDocument, { data, template: "gengar" }) as unknown as Parameters<typeof pdf>[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<Record<string, unknown>>).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([]);
});
});
-311
View File
@@ -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<Record<string, unknown>>;
style?: unknown;
value?: string;
children?: readonly LegacyParityHostNode[];
};
type PrimitiveSnapshot = {
type: string;
text: string;
props: Readonly<Record<string, unknown>>;
style: Readonly<Record<string, unknown>>;
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<Record<string, unknown>> = { fontWeight: 400 };
const emptyTextResetStyle: Readonly<Record<string, unknown>> = {
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<string, unknown>)
.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<Record<string, unknown>> => {
const entries = Array.isArray(style) ? style : style ? [style] : [];
const merged = Object.assign({}, ...entries) as Record<string, unknown>;
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<Record<string, unknown>> = rendererDefaults,
): PrimitiveSnapshot[] => {
const localStyle = mergedStyle(node.style);
const computedStyle: Record<string, unknown> = { ...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<Record<string, unknown>>,
style: snapshotStyle,
children,
},
];
};
const waitForDocument = async (instance: ReturnType<typeof pdf>): Promise<LegacyParityHostNode> => {
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<PrimitiveSnapshot[]> => {
const element = createElement(ResumeDocument, { data, template }) as unknown as Parameters<typeof pdf>[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<string, unknown>),
...Object.keys(semantic as Record<string, unknown>),
]);
for (const key of [...keys].sort()) {
diffValues(
(legacy as Record<string, unknown>)[key],
(semantic as Record<string, unknown>)[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<LegacySemanticPresentationComparison> {
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],
};
}
+26 -1
View File
@@ -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 = <const T extends Template, const P extends readonly TemplateSemanticPart[]>(
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.
@@ -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];
+1 -183
View File
@@ -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<Record<TemplateSemanticPartOwner["kind"], string | undefined>>;
const PLACEMENTS = new Set<TemplateSemanticPlacement>(["main", "sidebar"]);
const REGION_NAMES = new Set<TemplateSemanticRegionName>(["header", "main", "sidebar", "featured"]);
const PRIMITIVES = new Set<PrimitiveBinding["primitive"]>(["Document", "Page", "View", "Text", "Link", "Image", "Svg"]);
const SECTION_TYPES = new Set<CustomSectionType>(
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 = <T>(value: T): Readonly<T> => {
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<string>();
const partNames = new Set<string>();
const partKeys = new Set<string>();
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<string>([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<Record<Template, TemplateSemanticManifest>>;
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];
}
+1 -5
View File
@@ -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";
@@ -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<TemplateStyleSlots, "page"> & {
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 (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
</View>
<SemanticContactListView style={styles.headerContactRow}>
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
<WebsiteContactItem website={basics.website} style={styles.headerContactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</SemanticContactListView>
</SemanticHeaderView>
);
};
const Header = ({ styles }: AzurillHeaderProps) => (
<TemplateHeader
styles={{
header: styles.header,
picture: styles.picture,
title: styles.headerTitle,
identity: styles.headerIdentity,
name: styles.headerName,
contactList: styles.headerContactRow,
contactItem: styles.headerContactItem,
}}
contactListOutsideTitle
/>
);
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,
]);
};
+32 -42
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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 (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
</View>
<SemanticContactListView style={styles.headerContactRow}>
<EmailContactItem email={basics.email} style={styles.headerContactItem} />
<PhoneContactItem phone={basics.phone} style={styles.headerContactItem} />
<LocationContactItem location={basics.location} style={styles.headerContactItem} />
<WebsiteContactItem website={basics.website} style={styles.headerContactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.headerContactItem} />
))}
</SemanticContactListView>
</SemanticHeaderView>
);
};
const Header = ({ styles }: BronzorHeaderProps) => (
<TemplateHeader
styles={{
header: styles.header,
picture: styles.picture,
title: styles.headerTitle,
identity: styles.headerIdentity,
name: styles.headerName,
contactList: styles.headerContactRow,
contactItem: styles.headerContactItem,
}}
contactListOutsideTitle
/>
);
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,
]);
};
@@ -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<TemplateStyleSlots, "page"> & {
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]);
};
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+16 -12
View File
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+28 -38
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
@@ -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<TemplateStyleSlots, "page"> & {
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 (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
</View>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</SemanticContactListView>
</SemanticHeaderView>
);
};
const Header = ({ styles }: GlalieHeaderProps) => (
<TemplateHeader
styles={{
header: styles.header,
picture: styles.picture,
title: styles.headerTitle,
identity: styles.headerIdentity,
name: styles.headerName,
contactList: styles.contactList,
contactItem: styles.contactItem,
}}
contactListOutsideTitle
/>
);
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,
]);
};
+1 -5
View File
@@ -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";
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+2 -12
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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 (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</SemanticContactListView>
</View>
</SemanticHeaderView>
);
};
const Header = ({ styles }: LaprasHeaderProps) => (
<TemplateHeader
styles={{
header: styles.header,
picture: styles.picture,
title: styles.headerTitle,
identity: styles.headerIdentity,
name: styles.headerName,
contactList: styles.contactList,
contactItem: styles.contactItem,
}}
/>
);
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,
]);
};
+2 -12
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+55 -65
View File
@@ -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;
+32 -60
View File
@@ -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<TemplateStyleSlots, "page"> & {
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 (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</SemanticContactListView>
</View>
</SemanticHeaderView>
);
};
const Header = ({ styles }: OnyxHeaderProps) => (
<TemplateHeader
styles={{
header: styles.header,
picture: styles.picture,
title: styles.headerTitle,
identity: styles.headerIdentity,
name: styles.headerName,
contactList: styles.contactList,
contactItem: styles.contactItem,
}}
/>
);
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,
]);
};
+2 -12
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+11 -21
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
+6 -13
View File
@@ -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;
@@ -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<TemplateStyleSlots, "page"> & {
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,
]);
};
@@ -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;
}
@@ -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: "" });
});
});
@@ -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: "" };
};
@@ -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 = (
<SemanticContactListView style={styles.contactList}>
<EmailContactItem email={basics.email} style={styles.contactItem} />
<PhoneContactItem phone={basics.phone} style={styles.contactItem} />
<LocationContactItem location={basics.location} style={styles.contactItem} />
<WebsiteContactItem website={basics.website} style={styles.contactItem} />
{basics.customFields.map((field) => (
<CustomFieldContactItem key={field.id} field={field} style={styles.contactItem} />
))}
</SemanticContactListView>
);
return (
<SemanticHeaderView style={styles.header}>
{hasPicture && <SemanticHeaderPicture src={picture.url} style={styles.picture} />}
<View style={styles.title}>
<View style={styles.identity}>
<Heading style={styles.name}>{basics.name}</Heading>
<Text>{basics.headline}</Text>
</View>
{!contactListOutsideTitle && contactList}
</View>
{contactListOutsideTitle && contactList}
</SemanticHeaderView>
);
};
+1 -3
View File
@@ -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);
}
});
+3 -159
View File
@@ -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<Record<string, PdfRuleReference>>;
+3 -12
View File
@@ -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";

Some files were not shown because too many files have changed in this diff Show More