Merge branch 'main' of github.com:amruthpillai/reactive-resume

This commit is contained in:
Amruth Pillai
2026-05-07 15:14:21 +02:00
1015 changed files with 106087 additions and 141872 deletions
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@reactive-resume/ai",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./store": "./src/store.ts",
"./types": "./src/types.ts",
"./prompts": "./src/prompts.ts",
"./tools/patch-resume": "./src/tools/patch-resume.ts",
"./resume/extraction-template": "./src/resume/extraction-template.ts",
"./resume/sanitize": "./src/resume/sanitize.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"deepmerge-ts": "^7.1.5",
"fast-json-patch": "^3.1.1",
"immer": "^11.1.7",
"jsonrepair": "^3.14.0",
"zod": "^4.4.3",
"zustand": "^5.0.13"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+4
View File
@@ -0,0 +1,4 @@
declare module "*.md?raw" {
const content: string;
export default content;
}
+17
View File
@@ -0,0 +1,17 @@
/// <reference path="./markdown-raw.d.ts" />
import analyzeResumeSystemPrompt from "./prompts/analyze-resume-system.md?raw";
import chatSystemPromptTemplate from "./prompts/chat-system.md?raw";
import docxParserSystemPrompt from "./prompts/docx-parser-system.md?raw";
import docxParserUserPrompt from "./prompts/docx-parser-user.md?raw";
import pdfParserSystemPrompt from "./prompts/pdf-parser-system.md?raw";
import pdfParserUserPrompt from "./prompts/pdf-parser-user.md?raw";
export {
analyzeResumeSystemPrompt,
chatSystemPromptTemplate,
docxParserSystemPrompt,
docxParserUserPrompt,
pdfParserSystemPrompt,
pdfParserUserPrompt,
};
@@ -0,0 +1,65 @@
You are a senior resume reviewer and ATS optimization specialist.
Your task is to analyze the resume JSON provided in context and return a structured analysis.
## Core Objectives
Evaluate the resume for:
- clarity and specificity
- impact and quantification
- ATS compatibility
- structure and completeness
- language quality and relevance
## Strict Output Contract
Return only a JSON object that matches this exact structure:
{
"overallScore": 0-100 integer,
"scorecard": [
{
"dimension": "string",
"score": 0-100 integer,
"rationale": "string"
}
],
"suggestions": [
{
"title": "string",
"impact": "high" | "medium" | "low",
"why": "string",
"exampleRewrite": "string or null",
"copyPrompt": "string"
}
],
"strengths": ["string"]
}
Do not include markdown, comments, or additional keys.
## Evaluation Rules
1. Use 0-100 scoring for each dimension and overall score.
2. Keep rationales concise, specific, and evidence-based from resume content.
3. Suggestions must be prioritized by impact and be actionable.
4. Never invent candidate achievements or facts.
5. If data is missing, call it out explicitly in rationale/suggestions.
6. Keep scorecard dimensions practical and common for resume review.
## Suggestions Requirements
Each suggestion must include:
- a clear title
- impact level (`high`, `medium`, or `low`)
- explanation of why it matters
- a copyable prompt for improving that area in another LLM
`copyPrompt` should be concrete and directly usable, for example:
"Rewrite my experience bullets to emphasize measurable outcomes and ATS keywords. Keep each bullet under 25 words and include a metric where possible. Here is my current section: "
## Tone
Professional, direct, and constructive. Focus on helping the user improve quickly.
+54
View File
@@ -0,0 +1,54 @@
You are a resume editing assistant that must modify resume data only through JSON Patch (RFC 6902) tool calls.
## Objective
- Help the user improve resume content and structure.
- Apply edits safely and minimally via the `patch_resume` tool.
## Allowed Inputs
- User instructions in conversation.
- Current resume JSON state provided below.
## Hard Constraints
1. For any data change, always call `patch_resume`. Do not output raw patch arrays directly in chat text.
2. Generate the minimal set of patch operations required for the request.
3. Preserve existing data unless the user explicitly asks to replace or remove it.
4. Ask for confirmation before destructive edits (deletions, clears, or replacing large sections).
5. Stay resume-focused; decline off-topic requests.
6. Do not fabricate factual user history. For drafted content, label it as a draft and ask for confirmation.
7. Keep all paths and operations valid for RFC 6902 and current schema.
8. New item IDs must be UUIDs in `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx` format.
9. HTML fields (such as summary/description) must use valid HTML (`<p>`, `<ul>`, `<li>`, `<strong>`, `<em>` as needed).
## Conflict Resolution Order
1. Data safety and schema validity
2. User intent from latest instruction
3. Minimal-change editing strategy
## Editing Rules
- Prefer targeted `replace` operations over broad object replacements.
- Use `add` at `/items/-` for appending list entries.
- Use `remove` only when explicitly requested or confirmed.
- Keep `website` objects shaped as `{ "url": string, "label": string }`.
- Keep `hidden` fields explicit booleans.
## Resume Shape Reference
- Top-level keys: `basics`, `summary`, `picture`, `sections`, `customSections`, `metadata`
- Section item families in `sections`: `profiles`, `experience`, `education`, `projects`, `skills`, `languages`, `interests`, `awards`, `certifications`, `publications`, `volunteer`, `references`
## Output Contract
- If a change is needed: call `patch_resume`, then provide a concise natural-language confirmation.
- If no change is needed: provide concise guidance without tool calls.
- Never include markdown code blocks for patch payloads in your chat reply.
## Current Resume Data
```json
{{RESUME_DATA}}
```
@@ -0,0 +1,54 @@
You are a strict resume extraction engine for Microsoft Word files (DOC/DOCX). Convert the attached document into a Reactive Resume JSON object.
## Objective
- Extract resume content accurately and map it into the provided JSON template.
- Prioritize source fidelity and schema correctness over completeness.
## Allowed Input
- Use only visible, intended content from the attached document.
- Ignore hidden text, comments, track changes, revision history, document metadata, and layout artifacts.
## Hard Constraints
1. Extract only explicitly stated information.
2. Never fabricate, infer, or normalize missing data.
3. Keep original wording and original language.
4. When uncertain, omit content and leave template defaults.
5. Do not use external knowledge.
## Conflict Resolution Order
1. Schema validity (must return valid JSON matching template shape)
2. Source fidelity (exactly what the document states)
3. Omit uncertain values (never guess)
## Extraction Rules
- Dates: preserve exactly as written.
- URLs: include only URLs explicitly visible in document content.
- Contact data: copy as-is; do not reformat.
- Skills: include only explicit skill mentions.
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
- Lists and tables: extract visible text faithfully; preserve relationships in section fields.
- Headers/footers: include only if they contain real resume data.
- IDs: generate unique UUIDs for all `id` fields.
- `hidden`: default to `false` unless explicitly indicated otherwise.
- `columns`: default to `1` unless clearly multi-column by content intent.
- `website`: when missing, use `{ "url": "", "label": "" }`.
## Section Mapping
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
- Map based on explicit headings first; use local context only when heading is absent.
## Fallback Rules
- If the document is malformed or partially unreadable, return best-effort extraction for readable parts only.
- Keep unknown fields empty according to the template.
## Output Contract
- Return only one raw JSON object.
- No markdown, no commentary, no extra keys.
@@ -0,0 +1,3 @@
The Microsoft Word resume file is attached as a file in this message.
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
@@ -0,0 +1,52 @@
You are a strict resume extraction engine for PDF files. Convert the attached PDF into a Reactive Resume JSON object.
## Objective
- Extract resume content accurately and map it into the provided JSON template.
- Prioritize source fidelity and schema correctness over completeness.
## Allowed Input
- Use only the visible content from the attached PDF document.
- Ignore OCR noise, watermarks, repeated headers/footers, and broken line wraps.
## Hard Constraints
1. Extract only explicitly stated information.
2. Never fabricate, infer, or normalize missing data.
3. Keep original wording and original language.
4. When uncertain, omit content and leave template defaults.
5. Do not use external knowledge.
## Conflict Resolution Order
1. Schema validity (must return valid JSON matching template shape)
2. Source fidelity (exactly what the PDF states)
3. Omit uncertain values (never guess)
## Extraction Rules
- Dates: preserve exactly as written.
- URLs: include only full URLs that are explicitly present.
- Contact data: copy as-is; do not reformat.
- Skills: include only explicit skill mentions.
- Descriptions: output HTML using `<p>`, `<ul>`, `<li>` while preserving meaning.
- IDs: generate unique UUIDs for all `id` fields.
- `hidden`: default to `false` unless explicitly indicated otherwise.
- `columns`: default to `1` unless clearly multi-column by content intent.
- `website`: when missing, use `{ "url": "", "label": "" }`.
## Section Mapping
- `basics`, `summary`, `experience`, `education`, `skills`, `projects`, `certifications`, `awards`, `languages`, `volunteer`, `publications`, `references`, `profiles`, `interests`
- Map based on explicit headings first; use local context only when heading is absent.
## Fallback Rules
- If the PDF is low quality or partially unreadable, return best-effort extraction for readable parts only.
- Keep unknown fields empty according to the template.
## Output Contract
- Return only one raw JSON object.
- No markdown, no commentary, no extra keys.
@@ -0,0 +1,3 @@
The resume PDF is attached as a file in this message.
Process the attached document and return the final JSON object only, strictly following the system rules and the provided schema template.
@@ -0,0 +1,132 @@
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
function makeEmptyItem(shape: Record<string, unknown>): Record<string, unknown> {
const item: Record<string, unknown> = {};
for (const [key, value] of Object.entries(shape)) {
if (typeof value === "string") item[key] = "";
else if (typeof value === "number") item[key] = 0;
else if (typeof value === "boolean") item[key] = false;
else if (Array.isArray(value)) item[key] = [];
else if (value !== null && typeof value === "object") {
item[key] = makeEmptyItem(value as Record<string, unknown>);
} else {
item[key] = "";
}
}
return item;
}
const SECTION_ITEM_SHAPES: Record<string, Record<string, unknown>> = {
profiles: {
id: "",
hidden: false,
icon: "",
network: "",
username: "",
website: { url: "", label: "", inlineLink: false },
},
experience: {
id: "",
hidden: false,
company: "",
position: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
education: {
id: "",
hidden: false,
school: "",
degree: "",
area: "",
grade: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
projects: {
id: "",
hidden: false,
name: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
skills: { id: "", hidden: false, icon: "", name: "", proficiency: "", level: 0, keywords: [] },
languages: { id: "", hidden: false, language: "", fluency: "", level: 0 },
interests: { id: "", hidden: false, icon: "", name: "", keywords: [] },
awards: {
id: "",
hidden: false,
title: "",
awarder: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
certifications: {
id: "",
hidden: false,
title: "",
issuer: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
publications: {
id: "",
hidden: false,
title: "",
publisher: "",
date: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
volunteer: {
id: "",
hidden: false,
organization: "",
location: "",
period: "",
website: { url: "", label: "", inlineLink: false },
description: "",
},
references: {
id: "",
hidden: false,
name: "",
position: "",
website: { url: "", label: "", inlineLink: false },
phone: "",
description: "",
},
};
export function buildAiExtractionTemplate() {
const sections: Record<string, unknown> = {};
for (const [key, shape] of Object.entries(SECTION_ITEM_SHAPES)) {
const sectionKey = key as keyof typeof defaultResumeData.sections;
sections[key] = {
...defaultResumeData.sections[sectionKey],
items: [makeEmptyItem(shape)],
};
}
return {
...defaultResumeData,
basics: {
...defaultResumeData.basics,
customFields: [{ id: "", icon: "", text: "", link: "" }],
},
sections: {
...defaultResumeData.sections,
...sections,
},
};
}
+262
View File
@@ -0,0 +1,262 @@
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";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { isObject } from "@reactive-resume/utils/sanitize";
import { buildAiExtractionTemplate } from "./extraction-template";
const aiExtractionTemplate = buildAiExtractionTemplate();
type SectionKey = keyof typeof sectionRequiredFieldMap;
type CoercionEntry = {
path: string;
fromType: string;
toType: string;
};
type DroppedSectionItemEntry = {
section: SectionKey;
index: number;
reason: string;
};
export type ResumeSanitizationDiagnostics = {
coercions: CoercionEntry[];
droppedSectionItems: DroppedSectionItemEntry[];
salvageApplied: boolean;
};
export type ResumeSanitizationResult = {
data: ResumeData;
diagnostics: ResumeSanitizationDiagnostics;
};
const mergeDefaultsDeep = deepmergeCustom({
filterValues: (values) => values.filter((value) => value !== undefined && value !== null),
mergeArrays: false,
});
const sectionRequiredFieldMap = {
profiles: "network",
experience: "company",
education: "school",
projects: "name",
skills: "name",
languages: "language",
interests: "name",
awards: "title",
certifications: "title",
publications: "title",
volunteer: "organization",
references: "name",
} as const;
function getValueType(value: unknown): string {
if (value === null) return "null";
if (Array.isArray(value)) return "array";
return typeof value;
}
function mergeDefaults<T extends Record<string, unknown>, S extends Record<string, unknown>>(
target: T,
source: S,
): T & S {
if (!isObject(target) || !isObject(source)) {
return (source !== undefined && source !== null ? source : target) as T & S;
}
return mergeDefaultsDeep(target, source) as T & S;
}
function coerceBoolean(value: unknown): boolean | undefined {
if (typeof value === "boolean") return value;
if (typeof value === "number") {
if (value === 1) return true;
if (value === 0) return false;
return;
}
if (typeof value !== "string") return;
const normalized = value.trim().toLowerCase();
if (normalized === "true" || normalized === "1") return true;
if (normalized === "false" || normalized === "0") return false;
return;
}
function coerceNumber(value: unknown): number | undefined {
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "string") return;
const normalized = value.trim();
if (normalized.length === 0) return;
const coerced = Number(normalized);
return Number.isFinite(coerced) ? coerced : undefined;
}
function coerceValueAgainstTemplate(
value: unknown,
template: unknown,
path: string,
diagnostics: ResumeSanitizationDiagnostics,
): unknown {
if (value === undefined || value === null) return value;
if (template === undefined || template === null) return value;
if (typeof template === "boolean") {
const coerced = coerceBoolean(value);
if (coerced === undefined) return value;
if (coerced === value) return value;
diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "boolean" });
return coerced;
}
if (typeof template === "number") {
const coerced = coerceNumber(value);
if (coerced === undefined) return value;
if (coerced === value) return value;
diagnostics.coercions.push({ path, fromType: getValueType(value), toType: "number" });
return coerced;
}
if (Array.isArray(template) && Array.isArray(value)) {
const itemTemplate = template[0];
if (itemTemplate === undefined) return value;
return value.map((entry, index) =>
coerceValueAgainstTemplate(entry, itemTemplate, `${path}[${index}]`, diagnostics),
);
}
if (!isObject(template) || !isObject(value)) return value;
const output: Record<string, unknown> = { ...value };
for (const key of Object.keys(template)) {
if (!(key in output)) continue;
const nextPath = path.length > 0 ? `${path}.${key}` : key;
output[key] = coerceValueAgainstTemplate(output[key], template[key], nextPath, diagnostics);
}
return output;
}
function getJsonBoundaryIndices(value: string): { first: number; last: number } {
const firstCurly = value.indexOf("{");
const firstSquare = value.indexOf("[");
const lastCurly = value.lastIndexOf("}");
const lastSquare = value.lastIndexOf("]");
let first = -1;
if (firstCurly !== -1 && firstSquare !== -1) {
first = Math.min(firstCurly, firstSquare);
} else {
first = Math.max(firstCurly, firstSquare);
}
return { first, last: Math.max(lastCurly, lastSquare) };
}
function normalizeResumeDataForSchema(data: Record<string, unknown>, diagnostics: ResumeSanitizationDiagnostics) {
if (!isObject(data)) return data;
if (!isObject(data.sections)) return data;
const normalizedSections: Record<string, unknown> = { ...data.sections };
for (const sectionKey of Object.keys(sectionRequiredFieldMap) as SectionKey[]) {
const section = normalizedSections[sectionKey];
if (!isObject(section)) continue;
if (!Array.isArray(section.items)) continue;
const itemTemplate = aiExtractionTemplate.sections[sectionKey].items[0] as Record<string, unknown>;
const requiredField = sectionRequiredFieldMap[sectionKey];
const normalizedItems = section.items
.filter((item): item is Record<string, unknown> => isObject(item))
.map((item) => {
const mergedItem = mergeDefaults(itemTemplate, item);
return coerceValueAgainstTemplate(
mergedItem,
itemTemplate,
`sections.${sectionKey}.items`,
diagnostics,
) as Record<string, unknown>;
})
.filter((item, index) => {
const requiredValue = item[requiredField];
if (typeof requiredValue !== "string" || requiredValue.trim().length === 0) {
diagnostics.salvageApplied = true;
diagnostics.droppedSectionItems.push({
section: sectionKey,
index,
reason: `missing required "${requiredField}"`,
});
return false;
}
return true;
})
.map((item) => {
const normalizedItem = { ...item };
if (typeof normalizedItem.id !== "string" || normalizedItem.id.trim().length === 0) {
diagnostics.salvageApplied = true;
normalizedItem.id = crypto.randomUUID();
}
if (typeof normalizedItem.hidden !== "boolean") {
diagnostics.salvageApplied = true;
normalizedItem.hidden = false;
}
return normalizedItem;
});
normalizedSections[sectionKey] = { ...section, items: normalizedItems };
}
return { ...data, sections: normalizedSections };
}
export function sanitizeAndParseResumeJson(resultText: string): ResumeSanitizationResult {
let jsonString = resultText;
const { first, last } = getJsonBoundaryIndices(jsonString);
if (first !== -1 && last !== -1 && last >= first) {
jsonString = jsonString.substring(first, last + 1);
}
try {
const diagnostics: ResumeSanitizationDiagnostics = {
coercions: [],
droppedSectionItems: [],
salvageApplied: false,
};
const repairedJson = jsonrepair(jsonString);
const parsedJson = JSON.parse(repairedJson);
const mergedData = mergeDefaults(defaultResumeData, parsedJson);
const coercedData = coerceValueAgainstTemplate(mergedData, defaultResumeData, "", diagnostics);
const normalizedData = normalizeResumeDataForSchema(coercedData as Record<string, unknown>, diagnostics);
const data = resumeDataSchema.parse({
...normalizedData,
customSections: [],
picture: defaultResumeData.picture,
metadata: defaultResumeData.metadata,
});
return { data, diagnostics };
} catch (error: unknown) {
if (error instanceof ZodError) {
console.error("Zod validation failed during resume parsing:", flattenError(error));
throw error;
}
console.error("Unknown error during resume data validation:", error);
throw new Error("An unknown error occurred while validating the merged resume data.");
}
}
+88
View File
@@ -0,0 +1,88 @@
import type { WritableDraft } from "immer";
import type { AIProvider } from "./types";
import { createJSONStorage, persist } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";
import { create } from "zustand/react";
type TestStatus = "unverified" | "success" | "failure";
type AIStoreState = {
enabled: boolean;
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
testStatus: TestStatus;
};
type AIStoreActions = {
canEnable: () => boolean;
setEnabled: (value: boolean) => void;
set: (fn: (draft: WritableDraft<AIStoreState>) => void) => void;
reset: () => void;
};
type AIStore = AIStoreState & AIStoreActions;
const initialState: AIStoreState = {
enabled: false,
provider: "openai",
model: "",
apiKey: "",
baseURL: "",
testStatus: "unverified",
};
export const useAIStore = create<AIStore>()(
persist(
immer((set, get) => ({
...initialState,
set: (fn) => {
set((draft) => {
const prev = {
provider: draft.provider,
model: draft.model,
apiKey: draft.apiKey,
baseURL: draft.baseURL,
};
fn(draft);
if (
draft.provider !== prev.provider ||
draft.model !== prev.model ||
draft.apiKey !== prev.apiKey ||
draft.baseURL !== prev.baseURL
) {
draft.testStatus = "unverified";
draft.enabled = false;
}
});
},
reset: () => set(() => initialState),
canEnable: () => {
const { testStatus } = get();
return testStatus === "success";
},
setEnabled: (value: boolean) => {
const canEnable = get().canEnable();
if (value && !canEnable) return;
set((draft) => {
draft.enabled = value;
});
},
})),
{
name: "ai-store",
storage: createJSONStorage(() => localStorage),
partialize: (state) => ({
enabled: state.enabled,
provider: state.provider,
model: state.model,
apiKey: state.apiKey,
baseURL: state.baseURL,
testStatus: state.testStatus,
}),
},
),
);
+22
View File
@@ -0,0 +1,22 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Operation } from "fast-json-patch";
import z from "zod";
import { applyResumePatches, jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
export const patchResumeInputSchema = z.object({
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("Array of JSON Patch (RFC 6902) operations to apply to the resume"),
});
export const patchResumeDescription = `Apply JSON Patch (RFC 6902) operations to modify the user's resume data.
Use this tool whenever the user asks to change, add, or remove content from their resume.
Always generate the minimal set of operations needed. Prefer "replace" for updates, "add" for new content, "remove" for deletions.
Use the special "-" index to append to arrays (e.g. "/sections/experience/items/-").`;
export function executePatchResume(resumeData: ResumeData, operations: Operation[]) {
// Validates operations structurally and against the schema; throws on invalid
applyResumePatches(resumeData, operations);
return { success: true as const, appliedOperations: operations };
}
+16
View File
@@ -0,0 +1,16 @@
import { z } from "zod";
const AI_PROVIDERS = ["openai", "anthropic", "gemini", "vercel-ai-gateway", "openrouter", "ollama"] as const;
export type AIProvider = (typeof AI_PROVIDERS)[number];
export const aiProviderSchema = z.enum(AI_PROVIDERS);
export const AI_PROVIDER_DEFAULT_BASE_URLS: Record<AIProvider, string> = {
openai: "https://api.openai.com/v1",
anthropic: "https://api.anthropic.com/v1",
gemini: "https://generativelanguage.googleapis.com/v1beta",
"vercel-ai-gateway": "https://ai-gateway.vercel.sh/v3/ai",
openrouter: "https://openrouter.ai/api/v1",
ollama: "https://ollama.com/api",
};
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"lib": ["ESNext", "DOM"]
}
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/ai",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+55
View File
@@ -0,0 +1,55 @@
{
"name": "@reactive-resume/api",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./context": "./src/context.ts",
"./routers": "./src/routers/index.ts",
"./services/*": "./src/services/*.ts",
"./helpers/*": "./src/helpers/*.ts",
"./middleware/rate-limit": "./src/middleware/rate-limit/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@ai-sdk/anthropic": "^3.0.75",
"@ai-sdk/google": "^3.0.68",
"@ai-sdk/openai": "^3.0.62",
"@ai-sdk/openai-compatible": "^2.0.46",
"@aws-sdk/client-s3": "^3.1044.0",
"@orpc/client": "^1.14.2",
"@orpc/experimental-ratelimit": "^1.14.2",
"@orpc/server": "^1.14.2",
"@reactive-resume/ai": "workspace:*",
"@reactive-resume/auth": "workspace:*",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"ai": "^6.0.175",
"bcrypt": "^6.0.0",
"better-auth": "1.6.9",
"drizzle-orm": "1.0.0-beta.22",
"drizzle-zod": "1.0.0-beta.14-a36c63d",
"es-toolkit": "^1.46.1",
"fast-json-patch": "^3.1.1",
"ollama-ai-provider-v2": "^3.5.0",
"react": "^19.2.6",
"sharp": "^0.34.5",
"ts-pattern": "^5.9.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+99
View File
@@ -0,0 +1,99 @@
import type { Locale } from "@reactive-resume/utils/locale";
import type { User } from "better-auth";
import { ORPCError, os } from "@orpc/server";
import { eq } from "drizzle-orm";
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
import { db } from "@reactive-resume/db/client";
import { user } from "@reactive-resume/db/schema";
interface ORPCContext {
locale: Locale;
reqHeaders?: Headers;
}
async function getUserFromBearerToken(headers: Headers): Promise<User | null> {
try {
const authHeader = headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) return null;
const payload = await verifyOAuthToken(authHeader.slice(7));
if (!payload?.sub) return null;
const [userResult] = await db.select().from(user).where(eq(user.id, payload.sub)).limit(1);
return userResult ?? null;
} catch (error) {
console.warn("Bearer token verification failed:", error);
return null;
}
}
async function getUserFromHeaders(headers: Headers): Promise<User | null> {
try {
const result = await auth.api.getSession({ headers });
if (!result?.user) return null;
return result.user;
} catch (error) {
console.warn("Session verification failed:", error);
return null;
}
}
async function getUserFromApiKey(apiKey: string): Promise<User | null> {
try {
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
if (!result.key || !result.valid) return null;
const [userResult] = await db.select().from(user).where(eq(user.id, result.key.referenceId)).limit(1);
if (!userResult) return null;
return userResult;
} catch (error) {
console.warn("API key verification failed:", error);
return null;
}
}
/**
* Resolve the authenticated user from the same headers oRPC uses (`x-api-key`,
* `Authorization: Bearer`, or session cookies). Tries each auth method in
* priority order and returns the first valid identity. Used directly by
* oRPC's `publicProcedure` and by callers outside oRPC handlers (e.g. MCP
* tools) where `context.user` is not in scope.
*/
export async function resolveUserFromRequestHeaders(headers: Headers): Promise<User | null> {
const apiKey = headers.get("x-api-key");
if (apiKey) {
const apiKeyUser = await getUserFromApiKey(apiKey);
if (apiKeyUser) return apiKeyUser;
} else {
const bearerUser = await getUserFromBearerToken(headers);
if (bearerUser) return bearerUser;
}
return getUserFromHeaders(headers);
}
const base = os.$context<ORPCContext>();
export const publicProcedure = base.use(async ({ context, next }) => {
const user = await resolveUserFromRequestHeaders(context.reqHeaders ?? new Headers());
return next({
context: {
...context,
user,
},
});
});
export const protectedProcedure = publicProcedure.use(async ({ context, next }) => {
if (!context.user) throw new ORPCError("UNAUTHORIZED");
return next({
context: {
...context,
user: context.user,
},
});
});
+104
View File
@@ -0,0 +1,104 @@
import { createSelectSchema } from "drizzle-zod";
import z from "zod";
import * as schema from "@reactive-resume/db/schema";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { jsonPatchOperationSchema } from "@reactive-resume/utils/resume/patch";
const resumeSchema = createSelectSchema(schema.resume, {
id: z.string().describe("The ID of the resume."),
name: z.string().min(1).describe("The name of the resume."),
slug: z.string().min(1).describe("The slug of the resume."),
tags: z.array(z.string()).describe("The tags of the resume."),
isPublic: z.boolean().describe("Whether the resume is public."),
isLocked: z.boolean().describe("Whether the resume is locked."),
password: z.string().min(6).nullable().describe("The password of the resume, if any."),
data: resumeDataSchema,
userId: z.string().describe("The ID of the user who owns the resume."),
createdAt: z.date().describe("The date and time the resume was created."),
updatedAt: z.date().describe("The date and time the resume was last updated."),
});
export const resumeDto = {
list: {
input: z
.object({
tags: z.array(z.string()).optional().default([]),
sort: z.enum(["lastUpdatedAt", "createdAt", "name"]).optional().default("lastUpdatedAt"),
})
.optional()
.default({ tags: [], sort: "lastUpdatedAt" }),
output: z.array(resumeSchema.omit({ data: true, password: true, userId: true })),
},
getById: {
input: resumeSchema.pick({ id: true }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
getBySlug: {
input: z.object({ username: z.string(), slug: z.string() }),
output: resumeSchema.omit({ password: true, userId: true, createdAt: true, updatedAt: true }),
},
create: {
input: resumeSchema
.pick({ name: true, slug: true, tags: true })
.extend({ withSampleData: z.boolean().default(false) }),
output: z.string().describe("The ID of the created resume."),
},
import: {
input: resumeSchema.pick({ data: true }),
output: z.string().describe("The ID of the imported resume."),
},
update: {
input: resumeSchema
.pick({ name: true, slug: true, tags: true, data: true, isPublic: true })
.partial()
.extend({ id: z.string() }),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
setLocked: {
input: resumeSchema.pick({ id: true, isLocked: true }),
output: z.void(),
},
setPassword: {
input: resumeSchema.pick({ id: true }).extend({ password: z.string().min(6).max(64) }),
output: z.void(),
},
removePassword: {
input: resumeSchema.pick({ id: true }),
output: z.void(),
},
patch: {
input: z.object({
id: z.string().describe("The ID of the resume to patch."),
operations: z
.array(jsonPatchOperationSchema)
.min(1)
.describe("An array of JSON Patch (RFC 6902) operations to apply to the resume data."),
}),
output: resumeSchema
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
.extend({ hasPassword: z.boolean() }),
},
duplicate: {
input: resumeSchema.pick({ id: true, name: true, slug: true, tags: true }),
output: z.string().describe("The ID of the duplicated resume."),
},
delete: {
input: resumeSchema.pick({ id: true }),
output: z.void(),
},
};
+36
View File
@@ -0,0 +1,36 @@
import { createHash, timingSafeEqual } from "node:crypto";
import { getCookie, setCookie } from "@tanstack/react-start/server";
import { env } from "@reactive-resume/env/server";
const RESUME_ACCESS_COOKIE_PREFIX = "resume_access";
const RESUME_ACCESS_TTL_SECONDS = 60 * 10; // 10 minutes
const getResumeAccessCookieName = (resumeId: string) => `${RESUME_ACCESS_COOKIE_PREFIX}_${resumeId}`;
const signResumeAccessToken = (resumeId: string, passwordHash: string): string =>
createHash("sha256").update(`${resumeId}:${passwordHash}`).digest("hex");
const safeEquals = (value: string, expected: string) => {
const valueBuffer = Buffer.from(value);
const expectedBuffer = Buffer.from(expected);
if (valueBuffer.length !== expectedBuffer.length) return false;
return timingSafeEqual(valueBuffer, expectedBuffer);
};
export const hasResumeAccess = (resumeId: string, passwordHash: string | null) => {
if (!passwordHash) return false;
const cookieName = getResumeAccessCookieName(resumeId);
const cookieValue = getCookie(cookieName);
if (!cookieValue) return false;
const expected = signResumeAccessToken(resumeId, passwordHash);
return safeEquals(cookieValue, expected);
};
export const grantResumeAccess = (resumeId: string, passwordHash: string) =>
setCookie(getResumeAccessCookieName(resumeId), signResumeAccessToken(resumeId, passwordHash), {
path: "/",
httpOnly: true,
sameSite: "lax",
maxAge: RESUME_ACCESS_TTL_SECONDS,
secure: env.APP_URL.startsWith("https"),
});
@@ -0,0 +1,118 @@
import { createRatelimitMiddleware } from "@orpc/experimental-ratelimit";
import { MemoryRatelimiter } from "@orpc/experimental-ratelimit/memory";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
const isRateLimitEnabled = process.env.NODE_ENV === "production";
type ContextWithHeaders = {
reqHeaders?: Headers;
user?: { id: string } | null;
};
function getTrustedIp(headers?: Headers): string | null {
if (!headers) return null;
for (const headerName of TRUSTED_IP_HEADERS) {
const raw = headers.get(headerName)?.trim();
if (!raw) continue;
// Some proxies provide a comma-delimited chain; first item is the original client.
const ip = raw.split(",")[0]?.trim();
if (!ip) continue;
return ip;
}
return null;
}
function getClientKey(headers?: Headers): string {
const trustedIp = getTrustedIp(headers);
if (trustedIp) return `ip:${trustedIp}`;
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)}`;
}
function getUserKey(context: ContextWithHeaders): string {
return context.user?.id ?? "anon";
}
function getInputKeyPart(input: unknown): string {
if (!input || typeof input !== "object") return "no-input";
const inputRecord = input as Record<string, unknown>;
const id = inputRecord.id;
if (typeof id === "string" && id.trim()) return id;
const username = inputRecord.username;
const slug = inputRecord.slug;
if (typeof username === "string" && typeof slug === "string") return `${username}:${slug}`;
return "no-id";
}
const resumePasswordLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumePassword);
const pdfLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.pdfExport);
const aiLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.aiRequest);
const jobsSearchLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsSearch);
const jobsTestConnectionLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.jobsTestConnection);
const storageUploadLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageUpload);
const storageDeleteLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.storageDelete);
const resumeMutationLimiter = new MemoryRatelimiter(rateLimitConfig.orpc.resumeMutations);
const disabledLimiter = {
limit: async () => ({
success: true,
remaining: Number.POSITIVE_INFINITY,
reset: Date.now(),
}),
};
const productionLimiter = (limiter: MemoryRatelimiter) => (isRateLimitEnabled ? limiter : disabledLimiter);
export const resumePasswordRateLimit = createRatelimitMiddleware<
ContextWithHeaders,
{ username: string; slug: string }
>({
limiter: productionLimiter(resumePasswordLimiter),
key: ({ context }, input) => `resume-password:${input.username}:${input.slug}:${getClientKey(context.reqHeaders)}`,
});
export const pdfExportRateLimit = createRatelimitMiddleware<ContextWithHeaders, { id: string }>({
limiter: productionLimiter(pdfLimiter),
key: ({ context }, input) => `pdf-export:${getUserKey(context)}:${input.id}`,
});
export const aiRequestRateLimit = createRatelimitMiddleware<ContextWithHeaders, { provider: string }>({
limiter: productionLimiter(aiLimiter),
key: ({ context }, input) => `ai-request:${getUserKey(context)}:${input.provider}`,
});
export const jobsSearchRateLimit = createRatelimitMiddleware<ContextWithHeaders, { params: { query: string } }>({
limiter: productionLimiter(jobsSearchLimiter),
key: ({ context }, input) => `jobs-search:${getUserKey(context)}:${input.params.query.trim().toLowerCase()}`,
});
export const jobsTestConnectionRateLimit = createRatelimitMiddleware<ContextWithHeaders, { apiKey: string }>({
limiter: productionLimiter(jobsTestConnectionLimiter),
key: ({ context }) => `jobs-test-connection:${getUserKey(context)}`,
});
export const storageUploadRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(storageUploadLimiter),
key: ({ context }) => `storage-upload:${getUserKey(context)}`,
});
export const storageDeleteRateLimit = createRatelimitMiddleware<ContextWithHeaders, { filename: string }>({
limiter: productionLimiter(storageDeleteLimiter),
key: ({ context }, input) => `storage-delete:${getUserKey(context)}:${input.filename}`,
});
export const resumeMutationRateLimit = createRatelimitMiddleware<ContextWithHeaders, unknown>({
limiter: productionLimiter(resumeMutationLimiter),
key: ({ context }, input) => `resume-mutation:${getUserKey(context)}:${getInputKeyPart(input)}`,
});
+218
View File
@@ -0,0 +1,218 @@
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 { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { protectedProcedure } from "../context";
import { aiRequestRateLimit } from "../middleware/rate-limit";
import { aiCredentialsSchema, aiService, fileInputSchema } from "../services/ai";
import { resumeService } from "../services/resume";
type AIProvider = z.infer<typeof aiCredentialsSchema.shape.provider>;
function isInvalidAiBaseUrlError(error: unknown): boolean {
return error instanceof Error && error.message === "INVALID_AI_BASE_URL";
}
function isAiProviderGatewayError(error: unknown): boolean {
return error instanceof AISDKError;
}
function throwAiProviderGatewayError(): never {
throw new ORPCError("BAD_GATEWAY", { message: "Could not reach the AI provider." });
}
function throwAiProviderConfigError(): never {
throw new ORPCError("BAD_REQUEST", { message: "Invalid AI provider configuration." });
}
function throwResumeStructureError(error: ZodError): never {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume data structure",
cause: flattenError(error),
});
}
export const aiRouter = {
testConnection: protectedProcedure
.route({
method: "POST",
path: "/ai/test-connection",
tags: ["AI"],
operationId: "testAiConnection",
summary: "Test AI provider connection",
description:
"Validates the connection to an AI provider by sending a simple test prompt. Requires the provider type, model name, API key, and an optional base URL. Supported providers: OpenAI, Anthropic, Google Gemini, Ollama, OpenRouter, and Vercel AI Gateway. Requires authentication.",
successDescription: "The AI provider connection was successful.",
})
.input(z.object({ ...aiCredentialsSchema.shape }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "Invalid AI provider configuration.", status: 400 },
})
.handler(async ({ input }) => {
try {
return await aiService.testConnection(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
parsePdf: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-pdf",
tags: ["AI"],
operationId: "parseResumePdf",
summary: "Parse a PDF file into resume data",
description:
"Extracts structured resume data from a PDF file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The PDF was successfully parsed into structured resume data.",
})
.input(z.object({ ...aiCredentialsSchema.shape, file: fileInputSchema }))
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }): Promise<ResumeData> => {
try {
return await aiService.parsePdf(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
parseDocx: protectedProcedure
.route({
method: "POST",
path: "/ai/parse-docx",
tags: ["AI"],
operationId: "parseResumeDocx",
summary: "Parse a DOCX file into resume data",
description:
"Extracts structured resume data from a DOCX or DOC file using the specified AI provider. The file should be sent as a base64-encoded string along with AI provider credentials and the document's media type. Returns a complete ResumeData object. Requires authentication.",
successDescription: "The DOCX was successfully parsed into structured resume data.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
file: fileInputSchema,
mediaType: z.enum([
"application/msword",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
]),
}),
)
.use(aiRequestRateLimit)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ input }) => {
try {
return await aiService.parseDocx(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) throwResumeStructureError(error);
throw error;
}
}),
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 patch_resume tool to generate JSON Patch operations that modify the resume. Requires authentication and AI provider credentials.",
})
.input(
type<{
provider: AIProvider;
model: string;
apiKey: string;
baseURL: string;
messages: UIMessage[];
resumeData: ResumeData;
}>(),
)
.use(aiRequestRateLimit)
.handler(async ({ input }) => {
try {
return await aiService.chat(input);
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
throw error;
}
}),
analyzeResume: protectedProcedure
.route({
method: "POST",
path: "/ai/analyze-resume",
tags: ["AI"],
operationId: "analyzeResume",
summary: "Analyze resume and persist latest analysis",
description:
"Uses AI to analyze the current resume and returns a structured analysis with scorecard, strengths, and improvement suggestions. The latest analysis is persisted and can be fetched later. Requires authentication and AI credentials.",
successDescription: "Structured resume analysis returned and persisted successfully.",
})
.input(
z.object({
...aiCredentialsSchema.shape,
resumeId: z.string(),
resumeData: resumeDataSchema,
}),
)
.use(aiRequestRateLimit)
.output(storedResumeAnalysisSchema)
.errors({
BAD_GATEWAY: { message: "The AI provider returned an error or is unreachable.", status: 502 },
BAD_REQUEST: { message: "The AI returned an improperly formatted structure.", status: 400 },
})
.handler(async ({ context, input }) => {
try {
const analysis = await aiService.analyzeResume({
provider: input.provider,
model: input.model,
apiKey: input.apiKey,
baseURL: input.baseURL,
resumeData: input.resumeData,
});
return await resumeService.analysis.upsert({
id: input.resumeId,
userId: context.user.id,
analysis: {
...analysis,
updatedAt: new Date(),
modelMeta: { provider: input.provider, model: input.model },
},
});
} catch (error) {
if (isInvalidAiBaseUrlError(error)) throwAiProviderConfigError();
if (isAiProviderGatewayError(error)) throwAiProviderGatewayError();
if (error instanceof ZodError) {
throw new ORPCError("BAD_REQUEST", {
message: "Invalid resume analysis structure",
cause: flattenError(error),
});
}
throw error;
}
}),
};
+37
View File
@@ -0,0 +1,37 @@
import type { ProviderList } from "../services/auth";
import { protectedProcedure, publicProcedure } from "../context";
import { authService } from "../services/auth";
export const authRouter = {
providers: {
list: publicProcedure
.route({
method: "GET",
path: "/auth/providers",
tags: ["Authentication"],
operationId: "listAuthProviders",
summary: "List authentication providers",
description:
"Returns a list of all authentication providers enabled on this Reactive Resume instance, along with their display names. Possible providers include password-based credentials, Google, GitHub, LinkedIn, and custom OAuth. No authentication required.",
successDescription: "A map of enabled authentication provider identifiers to their display names.",
})
.handler((): ProviderList => {
return authService.providers.list();
}),
},
deleteAccount: protectedProcedure
.route({
method: "DELETE",
path: "/auth/account",
tags: ["Authentication"],
operationId: "deleteAccount",
summary: "Delete user account",
description:
"Permanently deletes the authenticated user's account, including all resumes, uploaded files (profile pictures, screenshots, PDFs), and associated data. This action is irreversible. Requires authentication.",
successDescription: "The user account and all associated data have been successfully deleted.",
})
.handler(async ({ context }): Promise<void> => {
return await authService.deleteAccount({ userId: context.user.id });
}),
};
+25
View File
@@ -0,0 +1,25 @@
import type { FeatureFlags } from "../services/flags";
import z from "zod";
import { publicProcedure } from "../context";
import { flagsService } from "../services/flags";
export const flagsRouter = {
get: publicProcedure
.route({
method: "GET",
path: "/flags",
tags: ["Feature Flags"],
operationId: "getFeatureFlags",
summary: "Get feature flags",
description:
"Returns the current feature flags for this Reactive Resume instance. Feature flags control instance-wide settings such as whether new user signups or email-based authentication are disabled. No authentication required.",
successDescription: "The current feature flags for this instance.",
})
.output(
z.object({
disableSignups: z.boolean().describe("Whether new user signups are disabled on this instance."),
disableEmailAuth: z.boolean().describe("Whether email-based authentication is disabled on this instance."),
}),
)
.handler((): FeatureFlags => flagsService.getFlags()),
};
+15
View File
@@ -0,0 +1,15 @@
import { aiRouter } from "./ai";
import { authRouter } from "./auth";
import { flagsRouter } from "./flags";
import { resumeRouter } from "./resume";
import { statisticsRouter } from "./statistics";
import { storageRouter } from "./storage";
export default {
ai: aiRouter,
auth: authRouter,
flags: flagsRouter,
resume: resumeRouter,
statistics: statisticsRouter,
storage: storageRouter,
};
+399
View File
@@ -0,0 +1,399 @@
import z from "zod";
import { storedResumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { sampleResumeData } from "@reactive-resume/schema/resume/sample";
import { generateRandomName, slugify } from "@reactive-resume/utils/string";
import { protectedProcedure, publicProcedure } from "../context";
import { resumeDto } from "../dto/resume";
import { resumeMutationRateLimit, resumePasswordRateLimit } from "../middleware/rate-limit";
import { resumeService } from "../services/resume";
const tagsRouter = {
list: protectedProcedure
.route({
method: "GET",
path: "/resumes/tags",
tags: ["Resumes"],
operationId: "listResumeTags",
summary: "List all resume tags",
description:
"Returns a sorted list of all unique tags across the authenticated user's resumes. Useful for populating tag filters in the dashboard. Requires authentication.",
successDescription: "A sorted array of unique tag strings.",
})
.output(z.array(z.string()))
.handler(async ({ context }) => {
return resumeService.tags.list({ userId: context.user.id });
}),
};
const statisticsRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/statistics",
tags: ["Resume Statistics"],
operationId: "getResumeStatistics",
summary: "Get resume statistics",
description:
"Returns view and download statistics for the specified resume, including total counts and the timestamps of the last view and download. Requires authentication.",
successDescription: "The resume's view and download statistics.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(
z.object({
isPublic: z.boolean().describe("Whether the resume is currently public."),
views: z.number().describe("Total number of times the resume has been viewed."),
downloads: z.number().describe("Total number of times the resume has been downloaded."),
lastViewedAt: z.date().nullable().describe("Timestamp of the last view, or null if never viewed."),
lastDownloadedAt: z.date().nullable().describe("Timestamp of the last download, or null if never downloaded."),
}),
)
.handler(async ({ context, input }) => {
return resumeService.statistics.getById({ id: input.id, userId: context.user.id });
}),
};
const analysisRouter = {
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}/analysis",
tags: ["Resume Analysis"],
operationId: "getResumeAnalysis",
summary: "Get latest resume analysis",
description:
"Returns the latest persisted AI analysis for the specified resume, if one exists. Requires authentication.",
successDescription: "The latest persisted resume analysis, or null if no analysis has been saved yet.",
})
.input(z.object({ id: z.string().describe("The unique identifier of the resume.") }))
.output(storedResumeAnalysisSchema.nullable())
.handler(async ({ context, input }) => {
return resumeService.analysis.getById({ id: input.id, userId: context.user.id });
}),
};
export const resumeRouter = {
tags: tagsRouter,
statistics: statisticsRouter,
analysis: analysisRouter,
list: protectedProcedure
.route({
method: "GET",
path: "/resumes",
tags: ["Resumes"],
operationId: "listResumes",
summary: "List all resumes",
description:
"Returns a list of all resumes belonging to the authenticated user. Results can be filtered by tags and sorted by last updated date, creation date, or name. Resume data is not included in the response for performance; use the get endpoint to fetch full resume data. Requires authentication.",
successDescription: "A list of resumes with their metadata (without full resume data).",
})
.input(resumeDto.list.input.optional().default({ tags: [], sort: "lastUpdatedAt" }))
.output(resumeDto.list.output)
.handler(async ({ input, context }) => {
return resumeService.list({
userId: context.user.id,
tags: input.tags,
sort: input.sort,
});
}),
getById: protectedProcedure
.route({
method: "GET",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "getResume",
summary: "Get resume by ID",
description:
"Returns a single resume with its full data, identified by its unique ID. Only resumes belonging to the authenticated user can be retrieved. Requires authentication.",
successDescription: "The resume with its full data.",
})
.input(resumeDto.getById.input)
.output(resumeDto.getById.output)
.handler(async ({ context, input }) => {
return resumeService.getById({ id: input.id, userId: context.user.id });
}),
getBySlug: publicProcedure
.route({
method: "GET",
path: "/resumes/{username}/{slug}",
tags: ["Resume Sharing"],
operationId: "getResumeBySlug",
summary: "Get public resume by username and slug",
description:
"Returns a publicly shared resume identified by the owner's username and the resume's slug. If the resume is password-protected and the viewer has not yet verified the password, a 401 error with code NEED_PASSWORD is returned. No authentication required for public resumes; if authenticated as the owner, private resumes are also accessible.",
successDescription: "The public resume with its full data.",
})
.input(resumeDto.getBySlug.input)
.output(resumeDto.getBySlug.output)
.handler(async ({ input, context }) => {
return resumeService.getBySlug({
...input,
...(context.user?.id ? { currentUserId: context.user.id } : {}),
});
}),
create: protectedProcedure
.route({
method: "POST",
path: "/resumes",
tags: ["Resumes"],
operationId: "createResume",
summary: "Create a new resume",
description:
"Creates a new resume with the given name, slug, and tags. Optionally initializes the resume with sample data by setting withSampleData to true. The slug must be unique across the user's resumes. Returns the ID of the newly created resume. Requires authentication.",
successDescription: "The ID of the newly created resume.",
})
.input(resumeDto.create.input)
.use(resumeMutationRateLimit)
.output(resumeDto.create.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.create({
name: input.name,
slug: input.slug,
tags: input.tags,
locale: context.locale,
userId: context.user.id,
...(input.withSampleData ? { data: sampleResumeData } : {}),
});
}),
import: protectedProcedure
.route({
method: "POST",
path: "/resumes/import",
tags: ["Resumes"],
operationId: "importResume",
summary: "Import a resume",
description:
"Creates a new resume from an existing ResumeData object (e.g. from a previously exported JSON file). A random name and slug are generated automatically. Returns the ID of the imported resume. Requires authentication.",
successDescription: "The ID of the imported resume.",
})
.input(resumeDto.import.input)
.use(resumeMutationRateLimit)
.output(resumeDto.import.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
const name = generateRandomName();
const slug = slugify(name);
return resumeService.create({
name,
slug,
tags: [],
data: input.data,
locale: context.locale,
userId: context.user.id,
});
}),
update: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "updateResume",
summary: "Update a resume",
description:
"Updates one or more fields of a resume identified by its ID. All fields are optional; only provided fields will be updated. Locked resumes cannot be updated. Requires authentication.",
successDescription: "The updated resume with its full data.",
})
.input(resumeDto.update.input)
.use(resumeMutationRateLimit)
.output(resumeDto.update.output)
.errors({
RESUME_SLUG_ALREADY_EXISTS: {
message: "A resume with this slug already exists.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.update({
id: input.id,
userId: context.user.id,
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
});
}),
patch: protectedProcedure
.route({
method: "PATCH",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "patchResume",
summary: "Patch resume data",
description:
"Applies JSON Patch (RFC 6902) operations to partially update a resume's data. This allows small, targeted changes (e.g. updating a single field) without sending the entire resume object. Locked resumes cannot be patched. Requires authentication.",
successDescription: "The patched resume with its full data.",
})
.input(resumeDto.patch.input)
.use(resumeMutationRateLimit)
.output(resumeDto.patch.output)
.errors({
INVALID_PATCH_OPERATIONS: {
message: "The patch operations are invalid or produced an invalid resume.",
status: 400,
},
})
.handler(async ({ context, input }) => {
return resumeService.patch({
id: input.id,
userId: context.user.id,
operations: input.operations,
});
}),
setLocked: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/lock",
tags: ["Resumes"],
operationId: "setResumeLocked",
summary: "Set resume lock status",
description:
"Toggles the locked status of a resume. When locked, a resume cannot be updated, patched, or deleted. Useful for protecting finalized resumes from accidental edits. Requires authentication.",
successDescription: "The resume lock status was updated successfully.",
})
.input(resumeDto.setLocked.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setLocked.output)
.handler(async ({ context, input }) => {
return resumeService.setLocked({
id: input.id,
userId: context.user.id,
isLocked: input.isLocked,
});
}),
setPassword: protectedProcedure
.route({
method: "PUT",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "setResumePassword",
summary: "Set resume password",
description:
"Sets or updates a password on a resume. When a password is set, viewers of the public resume must enter the password before the resume data is revealed. The password must be between 6 and 64 characters. Requires authentication.",
successDescription: "The resume password was set successfully.",
})
.input(resumeDto.setPassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.setPassword.output)
.handler(async ({ context, input }) => {
return resumeService.setPassword({
id: input.id,
userId: context.user.id,
password: input.password,
});
}),
verifyPassword: publicProcedure
.route({
method: "POST",
path: "/resumes/{username}/{slug}/password/verify",
tags: ["Resume Sharing"],
operationId: "verifyResumePassword",
summary: "Verify resume password",
description:
"Verifies a password for a password-protected public resume. On success, the viewer is granted access to view the resume data for the duration of their session. No authentication required.",
successDescription: "The password was verified successfully and access has been granted.",
})
.input(
z.object({
username: z.string().min(1).describe("The username of the resume owner."),
slug: z.string().min(1).describe("The slug of the resume."),
password: z.string().min(1).describe("The password to verify."),
}),
)
.use(resumePasswordRateLimit)
.output(z.boolean())
.handler(async ({ input }): Promise<boolean> => {
return resumeService.verifyPassword({
username: input.username,
slug: input.slug,
password: input.password,
});
}),
removePassword: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}/password",
tags: ["Resume Sharing"],
operationId: "removeResumePassword",
summary: "Remove resume password",
description:
"Removes password protection from a resume. After removal, the resume (if public) can be viewed without entering a password. Requires authentication.",
successDescription: "The resume password was removed successfully.",
})
.input(resumeDto.removePassword.input)
.use(resumeMutationRateLimit)
.output(resumeDto.removePassword.output)
.handler(async ({ context, input }) => {
return resumeService.removePassword({
id: input.id,
userId: context.user.id,
});
}),
duplicate: protectedProcedure
.route({
method: "POST",
path: "/resumes/{id}/duplicate",
tags: ["Resumes"],
operationId: "duplicateResume",
summary: "Duplicate a resume",
description:
"Creates a copy of an existing resume with the same data. Optionally override the name, slug, and tags for the duplicate. If not provided, the original resume's name, slug, and tags are used. Returns the ID of the duplicated resume. Requires authentication.",
successDescription: "The ID of the duplicated resume.",
})
.input(resumeDto.duplicate.input)
.use(resumeMutationRateLimit)
.output(resumeDto.duplicate.output)
.handler(async ({ context, input }) => {
const original = await resumeService.getById({ id: input.id, userId: context.user.id });
return resumeService.create({
userId: context.user.id,
name: input.name ?? original.name,
slug: input.slug ?? original.slug,
tags: input.tags ?? original.tags,
locale: context.locale,
data: original.data,
});
}),
delete: protectedProcedure
.route({
method: "DELETE",
path: "/resumes/{id}",
tags: ["Resumes"],
operationId: "deleteResume",
summary: "Delete a resume",
description:
"Permanently deletes a resume and its associated files (screenshots, PDFs) from storage. Locked resumes cannot be deleted; unlock the resume first. Requires authentication.",
successDescription: "The resume and its associated files were deleted successfully.",
})
.input(resumeDto.delete.input)
.use(resumeMutationRateLimit)
.output(resumeDto.delete.output)
.handler(async ({ context, input }) => {
return resumeService.delete({ id: input.id, userId: context.user.id });
}),
};
+63
View File
@@ -0,0 +1,63 @@
import z from "zod";
import { publicProcedure } from "../context";
import { statisticsService } from "../services/statistics";
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(async (): Promise<number> => {
return await 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(async (): Promise<number> => {
return await statisticsService.resume.getCount();
}),
};
const githubRouter = {
getStarCount: publicProcedure
.route({
method: "GET",
path: "/statistics/github/stars",
tags: ["Platform Statistics"],
operationId: "getGitHubStarCount",
summary: "Get GitHub star count",
description:
"Returns the number of GitHub stars for the Reactive Resume repository. The count is cached for up to 6 hours and falls back to a last-known value if the GitHub API is unavailable. No authentication required.",
successDescription: "The number of GitHub stars for the Reactive Resume repository.",
})
.output(z.number().describe("The number of GitHub stars."))
.handler(async (): Promise<number> => {
return await statisticsService.github.getStarCount();
}),
};
export const statisticsRouter = {
user: userRouter,
resume: resumeRouter,
github: githubRouter,
};
+110
View File
@@ -0,0 +1,110 @@
import { ORPCError } from "@orpc/server";
import z from "zod";
import { protectedProcedure } from "../context";
import { storageDeleteRateLimit, storageUploadRateLimit } from "../middleware/rate-limit";
import { getStorageService, isImageFile, processImageForUpload, uploadFile } from "../services/storage";
const storageService = getStorageService();
const fileSchema = z.file().max(10 * 1024 * 1024, "File size must be less than 10MB");
const filenameSchema = z.object({
filename: z.string().min(1).describe("The path or filename of the file to delete."),
});
function normalizeKey(input: string): string {
return input.trim().replace(/^\/+/, "").split("/").filter(Boolean).join("/");
}
function isUnsafeStorageKey(key: string): boolean {
return key.split("/").some((segment) => segment === "." || segment === "..");
}
export const storageRouter = {
uploadFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "uploadFile",
summary: "Upload a file",
description:
"Uploads a file to storage. Images are automatically resized and converted to JPEG format. Maximum file size is 10MB. Requires authentication.",
successDescription: "The file was uploaded successfully.",
})
.input(fileSchema)
.use(storageUploadRateLimit)
.output(
z.object({
url: z.string().describe("The public URL to access the uploaded file."),
path: z.string().describe("The storage path of the uploaded file."),
contentType: z.string().describe("The MIME type of the uploaded file."),
}),
)
.handler(async ({ context, input: file }) => {
const originalMimeType = file.type;
const isImage = isImageFile(originalMimeType);
let data: Uint8Array;
let contentType: string;
if (isImage) {
const processed = await processImageForUpload(file);
data = processed.data;
contentType = processed.contentType;
} else {
const fileBuffer = await file.arrayBuffer();
data = new Uint8Array(fileBuffer);
contentType = originalMimeType;
}
const result = await uploadFile({
userId: context.user.id,
data,
contentType,
type: "picture",
});
return {
url: result.url,
path: result.key,
contentType,
};
}),
deleteFile: protectedProcedure
.route({
tags: ["Internal"],
operationId: "deleteFile",
summary: "Delete a file",
description:
"Deletes a file from storage by its filename or path. If the filename does not start with 'uploads/', the user's picture directory is assumed. Requires authentication.",
successDescription: "The file was deleted successfully.",
})
.input(filenameSchema)
.use(storageDeleteRateLimit)
.output(z.void())
.errors({
NOT_FOUND: {
message: "The specified file was not found in storage.",
status: 404,
},
FORBIDDEN: {
message: "You do not have permission to delete this file.",
status: 403,
},
})
.handler(async ({ context, input }): Promise<void> => {
const requestedKey = normalizeKey(input.filename);
const key = requestedKey.startsWith("uploads/")
? requestedKey
: normalizeKey(`uploads/${context.user.id}/pictures/${requestedKey}`);
const userPrefix = `uploads/${context.user.id}/`;
if (isUnsafeStorageKey(key) || !key.startsWith(userPrefix)) {
throw new ORPCError("FORBIDDEN");
}
const deleted = await storageService.delete(key);
if (!deleted) throw new ORPCError("NOT_FOUND");
}),
};
+273
View File
@@ -0,0 +1,273 @@
import type { AIProvider } from "@reactive-resume/ai/types";
import type { ResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ModelMessage, UIMessage } from "ai";
import { createAnthropic } from "@ai-sdk/anthropic";
import { createGoogleGenerativeAI } from "@ai-sdk/google";
import { createOpenAI } from "@ai-sdk/openai";
import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
import { streamToEventIterator } from "@orpc/server";
import { convertToModelMessages, createGateway, generateText, Output, stepCountIs, streamText, tool } from "ai";
import { createOllama } from "ollama-ai-provider-v2";
import { match } from "ts-pattern";
import { z } from "zod";
import {
analyzeResumeSystemPrompt as analyzeResumeSystemPromptTemplate,
chatSystemPromptTemplate,
docxParserSystemPrompt,
docxParserUserPrompt,
pdfParserSystemPrompt,
pdfParserUserPrompt,
} from "@reactive-resume/ai/prompts";
import { buildAiExtractionTemplate } from "@reactive-resume/ai/resume/extraction-template";
import { sanitizeAndParseResumeJson } from "@reactive-resume/ai/resume/sanitize";
import {
executePatchResume,
patchResumeDescription,
patchResumeInputSchema,
} from "@reactive-resume/ai/tools/patch-resume";
import { AI_PROVIDER_DEFAULT_BASE_URLS, aiProviderSchema } from "@reactive-resume/ai/types";
import { resumeAnalysisOutputSchema, resumeAnalysisSchema } from "@reactive-resume/schema/resume/analysis";
import { isPrivateOrLoopbackHost, parseUrl } from "@reactive-resume/utils/url-security";
const aiExtractionTemplate = buildAiExtractionTemplate();
function logAndRethrow(context: string, error: unknown): never {
if (error instanceof Error) {
console.error(`${context}:`, error);
throw error;
}
console.error(`${context}:`, error);
throw new Error(`An unknown error occurred during ${context}.`);
}
function parseAndValidateResumeJson(resultText: string): ResumeData {
const { data, diagnostics } = sanitizeAndParseResumeJson(resultText);
if (diagnostics.coercions.length === 0 && diagnostics.droppedSectionItems.length === 0) return data;
const droppedBySection = diagnostics.droppedSectionItems.reduce<Record<string, number>>((acc, item) => {
acc[item.section] = (acc[item.section] ?? 0) + 1;
return acc;
}, {});
console.info("AI resume sanitization diagnostics", {
coercions: diagnostics.coercions.length,
droppedBySection,
salvageApplied: diagnostics.salvageApplied,
});
return data;
}
type GetModelInput = {
provider: AIProvider;
model: string;
apiKey: string;
baseURL?: string;
};
const MAX_AI_FILE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AI_FILE_BASE64_CHARS = Math.ceil((MAX_AI_FILE_BYTES * 4) / 3) + 4;
function resolveBaseUrl(input: GetModelInput): string {
const baseURL = input.baseURL?.trim() || AI_PROVIDER_DEFAULT_BASE_URLS[input.provider];
if (!baseURL) throw new Error("INVALID_AI_BASE_URL");
const parsedBaseURL = parseUrl(baseURL);
if (!parsedBaseURL) throw new Error("INVALID_AI_BASE_URL");
if (parsedBaseURL.protocol !== "https:") throw new Error("INVALID_AI_BASE_URL");
if (parsedBaseURL.username || parsedBaseURL.password) throw new Error("INVALID_AI_BASE_URL");
if (isPrivateOrLoopbackHost(parsedBaseURL.hostname)) throw new Error("INVALID_AI_BASE_URL");
return parsedBaseURL.toString();
}
function getModel(input: GetModelInput) {
const { provider, model, apiKey } = input;
const baseURL = resolveBaseUrl(input);
return match(provider)
.with("openai", () => createOpenAI({ apiKey, baseURL }).chat(model))
.with("anthropic", () => createAnthropic({ apiKey, baseURL }).languageModel(model))
.with("gemini", () => createGoogleGenerativeAI({ apiKey, baseURL }).languageModel(model))
.with("vercel-ai-gateway", () => createGateway({ apiKey, baseURL }).languageModel(model))
.with("openrouter", () => createOpenAICompatible({ name: "openrouter", apiKey, baseURL }).languageModel(model))
.with("ollama", () => {
const ollama = createOllama({
name: "ollama",
baseURL,
...(apiKey ? { headers: { Authorization: `Bearer ${apiKey}` } } : {}),
});
return ollama.languageModel(model);
})
.exhaustive();
}
export const aiCredentialsSchema = z.object({
provider: aiProviderSchema,
model: z.string().trim().min(1),
apiKey: z.string().trim().min(1),
baseURL: z.string().optional().default(""),
});
export const fileInputSchema = z.object({
name: z.string(),
data: z.string().max(MAX_AI_FILE_BASE64_CHARS, "File is too large. Maximum size is 10MB."),
});
type TestConnectionInput = z.infer<typeof aiCredentialsSchema>;
async function testConnection(input: TestConnectionInput): Promise<boolean> {
const RESPONSE_OK = "1";
const result = await generateText({
model: getModel(input),
output: Output.choice({ options: [RESPONSE_OK] }),
messages: [{ role: "user", content: `Respond only with JSON Object: { "result": "${RESPONSE_OK}" }` }],
});
return result.output === RESPONSE_OK;
}
type ParsePdfInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
};
type BuildResumeParsingMessagesInput = {
systemPrompt: string;
userPrompt: string;
file: z.infer<typeof fileInputSchema>;
mediaType: string;
};
function buildResumeParsingMessages({
systemPrompt,
userPrompt,
file,
mediaType,
}: BuildResumeParsingMessagesInput): ModelMessage[] {
return [
{
role: "system",
content: `${systemPrompt}\n\nIMPORTANT: You must return ONLY raw valid JSON. Do not return markdown, do not return explanations. Just the JSON object. Use the following JSON as a template and fill in the extracted values. For arrays, you MUST use the exact key names shown in the template (e.g. use 'description' instead of 'summary', 'website' instead of 'url'):\n\n${JSON.stringify(aiExtractionTemplate, null, 2)}`,
},
{
role: "user",
content: [
{ type: "text", text: userPrompt },
{ type: "file", data: file.data, mediaType, filename: file.name },
],
},
];
}
async function parsePdf(input: ParsePdfInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: pdfParserSystemPrompt,
userPrompt: pdfParserUserPrompt,
file: input.file,
mediaType: "application/pdf",
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
return parseAndValidateResumeJson(result.text);
}
type ParseDocxInput = z.infer<typeof aiCredentialsSchema> & {
file: z.infer<typeof fileInputSchema>;
mediaType: "application/msword" | "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
};
async function parseDocx(input: ParseDocxInput): Promise<ResumeData> {
const model = getModel(input);
const result = await generateText({
model,
messages: buildResumeParsingMessages({
systemPrompt: docxParserSystemPrompt,
userPrompt: docxParserUserPrompt,
file: input.file,
mediaType: input.mediaType,
}),
}).catch((error: unknown) => logAndRethrow("Failed to generate the text with the model", error));
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;
};
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: {
patch_resume: tool({
description: patchResumeDescription,
inputSchema: patchResumeInputSchema,
execute: async ({ operations }) => executePatchResume(input.resumeData, operations),
}),
},
stopWhen: stepCountIs(3),
});
return streamToEventIterator(result.toUIMessageStream());
}
type AnalyzeResumeInput = z.infer<typeof aiCredentialsSchema> & {
resumeData: ResumeData;
};
function buildAnalyzeResumeSystemPrompt(resumeData: ResumeData): string {
return `${analyzeResumeSystemPromptTemplate}\n\n## Resume Data\n\n${JSON.stringify(resumeData, null, 2)}`;
}
async function analyzeResume(input: AnalyzeResumeInput): Promise<ResumeAnalysis> {
const model = getModel(input);
const systemPrompt = buildAnalyzeResumeSystemPrompt(input.resumeData);
const result = await generateText({
model,
output: Output.object({ schema: resumeAnalysisOutputSchema }),
messages: [
{ role: "system", content: systemPrompt },
{
role: "user",
content:
"Analyze this resume and return a structured report with scorecard, overall score, strengths, and actionable suggestions.",
},
],
});
if (result.output == null) {
throw new Error("AI returned no structured analysis output.");
}
return resumeAnalysisSchema.parse(result.output);
}
export const aiService = {
analyzeResume,
chat,
parseDocx,
parsePdf,
testConnection,
};
+48
View File
@@ -0,0 +1,48 @@
import type { AuthProvider } from "@reactive-resume/auth/types";
import { ORPCError } from "@orpc/client";
import { eq } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { env } from "@reactive-resume/env/server";
import { getStorageService } from "./storage";
export type ProviderList = Partial<Record<AuthProvider, string>>;
const providers = {
list: (): ProviderList => {
const providers: ProviderList = { credential: "Password", passkey: "Passkey" };
if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) providers.google = "Google";
if (env.GITHUB_CLIENT_ID && env.GITHUB_CLIENT_SECRET) providers.github = "GitHub";
if (env.LINKEDIN_CLIENT_ID && env.LINKEDIN_CLIENT_SECRET) providers.linkedin = "LinkedIn";
if (env.OAUTH_CLIENT_ID && env.OAUTH_CLIENT_SECRET) providers.custom = env.OAUTH_PROVIDER_NAME ?? "Custom OAuth";
return providers;
},
};
export const authService = {
providers,
deleteAccount: async (input: { userId: string }): Promise<void> => {
if (!input.userId || input.userId.length === 0) return;
const storageService = getStorageService();
// Delete all user files in one call (pictures, screenshots, pdfs)
// The storage service delete method supports recursive deletion via prefix
try {
await storageService.delete(`uploads/${input.userId}`);
} catch {
// Ignore error and proceed with deleting user
}
try {
await db.delete(schema.user).where(eq(schema.user.id, input.userId));
} catch (err) {
console.error("Failed to delete user record:", err);
throw new ORPCError("INTERNAL_SERVER_ERROR");
}
},
};
+13
View File
@@ -0,0 +1,13 @@
import { env } from "@reactive-resume/env/server";
export type FeatureFlags = {
disableSignups: boolean;
disableEmailAuth: boolean;
};
export const flagsService = {
getFlags: (): FeatureFlags => ({
disableSignups: env.FLAG_DISABLE_SIGNUPS,
disableEmailAuth: env.FLAG_DISABLE_EMAIL_AUTH,
}),
};
@@ -0,0 +1,78 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { ORPCError } from "@orpc/client";
/**
* Single source of truth for "who can view/edit a resume" and "what fields
* leak to non-owners on the public view path."
*
* Owner-only mutation methods (update / patch / delete / setPassword / …)
* intentionally do **not** call this module — their `WHERE userId = :owner`
* clauses already enforce ownership at the query level. The policy here
* documents that contract and gates the dual-role read path (`getBySlug`)
* where a non-owner viewer can legitimately read a public resume.
*/
type Resume = {
userId: string;
isPublic: boolean;
};
type Viewer = { id: string } | null;
export function isOwner(resume: Resume, viewer: Viewer): boolean {
return viewer !== null && viewer.id === resume.userId;
}
/**
* Throws `NOT_FOUND` (not `FORBIDDEN`) when the viewer is not allowed to see
* the resume — same response as a nonexistent resume so the API does not
* disclose existence of private resumes by id/slug.
*/
export function assertCanView(resume: Resume, viewer: Viewer): void {
if (isOwner(resume, viewer)) return;
if (resume.isPublic) return;
throw new ORPCError("NOT_FOUND");
}
/**
* Redact owner-only fields before serializing a resume to a non-owner viewer.
*
* Stripped on public view:
* - `resume.name` — the dashboard title chosen by the owner (often
* contains personal context like "Senior Eng @ Foo — final draft").
* - `resume.data.metadata.notes` — explicitly documented as "only visible
* to the author when editing" in the resume schema.
*
* Everything else (including `data.basics.name`, the person's name on the
* resume itself) is part of the public payload and is returned unchanged.
*
* Owner views pass through untouched.
*/
export function redactResumeForViewer<T extends { name: string; data: ResumeData }>(
resume: T,
viewerIsOwner: boolean,
): T {
if (viewerIsOwner) return resume;
return {
...resume,
name: "",
data: {
...resume.data,
metadata: {
...resume.data.metadata,
notes: "",
},
},
};
}
/**
* Owner self-views/downloads do not count toward the public statistics —
* the dashboard would otherwise inflate metrics every time the author
* previewed their own resume. Call sites that increment `views` /
* `downloads` should gate on this helper.
*/
export function shouldCountForStatistics(resume: Resume, viewer: Viewer): boolean {
return !isOwner(resume, viewer);
}
+451
View File
@@ -0,0 +1,451 @@
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Locale } from "@reactive-resume/utils/locale";
import type { Operation } from "fast-json-patch";
import { ORPCError } from "@orpc/client";
import { compare, hash } from "bcrypt";
import { and, arrayContains, asc, desc, eq, isNotNull, sql } from "drizzle-orm";
import { get } from "es-toolkit/compat";
import { match } from "ts-pattern";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { applyResumePatches, ResumePatchError } from "@reactive-resume/utils/resume/patch";
import { generateId } from "@reactive-resume/utils/string";
import { grantResumeAccess, hasResumeAccess } from "../helpers/resume-access";
import { assertCanView, isOwner, redactResumeForViewer, shouldCountForStatistics } from "./resume-access-policy";
import { getStorageService } from "./storage";
const tags = {
list: async (input: { userId: string }) => {
const result = await db
.select({ tags: schema.resume.tags })
.from(schema.resume)
.where(eq(schema.resume.userId, input.userId));
const uniqueTags = new Set(result.flatMap((tag) => tag.tags));
const sortedTags = Array.from(uniqueTags).sort((a, b) => a.localeCompare(b));
return sortedTags;
},
};
const statistics = {
getById: async (input: { id: string; userId: string }) => {
const [statistics] = await db
.select({
isPublic: schema.resume.isPublic,
views: schema.resumeStatistics.views,
downloads: schema.resumeStatistics.downloads,
lastViewedAt: schema.resumeStatistics.lastViewedAt,
lastDownloadedAt: schema.resumeStatistics.lastDownloadedAt,
})
.from(schema.resumeStatistics)
.rightJoin(schema.resume, eq(schema.resumeStatistics.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!statistics) throw new ORPCError("NOT_FOUND");
return {
isPublic: statistics.isPublic,
views: statistics.views ?? 0,
downloads: statistics.downloads ?? 0,
lastViewedAt: statistics.lastViewedAt,
lastDownloadedAt: statistics.lastDownloadedAt,
};
},
increment: async (input: { id: string; views?: boolean; downloads?: boolean }) => {
const views = input.views ? 1 : 0;
const downloads = input.downloads ? 1 : 0;
const lastViewedAt = input.views ? sql`now()` : undefined;
const lastDownloadedAt = input.downloads ? sql`now()` : undefined;
await db
.insert(schema.resumeStatistics)
.values({
resumeId: input.id,
views,
downloads,
lastViewedAt,
lastDownloadedAt,
})
.onConflictDoUpdate({
target: [schema.resumeStatistics.resumeId],
set: {
views: sql`${schema.resumeStatistics.views} + ${views}`,
downloads: sql`${schema.resumeStatistics.downloads} + ${downloads}`,
lastViewedAt,
lastDownloadedAt,
},
});
},
};
const analysis = {
getById: async (input: { id: string; userId: string }) => {
const [result] = await db
.select({ analysis: schema.resumeAnalysis.analysis })
.from(schema.resume)
.leftJoin(schema.resumeAnalysis, eq(schema.resumeAnalysis.resumeId, schema.resume.id))
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!result) throw new ORPCError("NOT_FOUND");
return result.analysis ?? null;
},
upsert: async (input: { id: string; userId: string; analysis: StoredResumeAnalysis }) => {
const [resume] = await db
.select({ id: schema.resume.id })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
await db
.insert(schema.resumeAnalysis)
.values({
resumeId: input.id,
analysis: input.analysis,
})
.onConflictDoUpdate({
target: [schema.resumeAnalysis.resumeId],
set: {
analysis: input.analysis,
},
});
return input.analysis;
},
};
function toSharedResumeResponse<TPassword extends boolean>(
resume: {
id: string;
name: string;
slug: string;
tags: string[];
data: ResumeData;
isPublic: boolean;
isLocked: boolean;
},
hasPassword: TPassword,
) {
return {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
data: resume.data,
isPublic: resume.isPublic,
isLocked: resume.isLocked,
hasPassword,
};
}
export const resumeService = {
tags,
statistics,
analysis,
list: async (input: { userId: string; tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
return await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
createdAt: schema.resume.createdAt,
updatedAt: schema.resume.updatedAt,
})
.from(schema.resume)
.where(
and(
eq(schema.resume.userId, input.userId),
match(input.tags.length)
.with(0, () => undefined)
.otherwise(() => arrayContains(schema.resume.tags, input.tags)),
),
)
.orderBy(
match(input.sort)
.with("lastUpdatedAt", () => desc(schema.resume.updatedAt))
.with("createdAt", () => asc(schema.resume.createdAt))
.with("name", () => asc(schema.resume.name))
.exhaustive(),
);
},
getById: async (input: { id: string; userId: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
getBySlug: async (input: { username: string; slug: string; currentUserId?: string }) => {
const [resume] = await db
.select({
id: schema.resume.id,
userId: schema.resume.userId,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
passwordHash: schema.resume.password,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
})
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(and(eq(schema.resume.slug, input.slug), eq(schema.user.username, input.username)));
if (!resume) throw new ORPCError("NOT_FOUND");
const viewer = input.currentUserId ? { id: input.currentUserId } : null;
assertCanView(resume, viewer);
if (resume.hasPassword && !hasResumeAccess(resume.id, resume.passwordHash)) {
throw new ORPCError("NEED_PASSWORD", {
status: 401,
data: { username: input.username, slug: input.slug },
});
}
if (shouldCountForStatistics(resume, viewer)) {
await resumeService.statistics.increment({ id: resume.id, views: true });
}
return toSharedResumeResponse(redactResumeForViewer(resume, isOwner(resume, viewer)), resume.hasPassword);
},
create: async (input: {
userId: string;
name: string;
slug: string;
tags: string[];
locale: Locale;
data?: ResumeData;
}) => {
const id = generateId();
const data = input.data ?? defaultResumeData;
data.metadata.page.locale = input.locale;
try {
await db.insert(schema.resume).values({
id,
name: input.name,
slug: input.slug,
tags: input.tags,
userId: input.userId,
data,
});
return id;
} catch (error) {
const constraint = get(error, "cause.constraint") as string | undefined;
if (constraint === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to create resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to create resume" });
}
},
update: async (input: {
id: string;
userId: string;
name?: string;
slug?: string;
tags?: string[];
data?: ResumeData;
isPublic?: boolean;
}) => {
const [resume] = await db
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (resume?.isLocked) throw new ORPCError("RESUME_LOCKED");
const updateData: Partial<typeof schema.resume.$inferSelect> = {
...(input.name !== undefined ? { name: input.name } : {}),
...(input.slug !== undefined ? { slug: input.slug } : {}),
...(input.tags !== undefined ? { tags: input.tags } : {}),
...(input.data !== undefined ? { data: input.data } : {}),
...(input.isPublic !== undefined ? { isPublic: input.isPublic } : {}),
};
try {
const [resume] = await db
.update(schema.resume)
.set(updateData)
.where(
and(
eq(schema.resume.id, input.id),
eq(schema.resume.isLocked, false),
eq(schema.resume.userId, input.userId),
),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
} catch (error) {
if (get(error, "cause.constraint") === "resume_slug_user_id_unique") {
throw new ORPCError("RESUME_SLUG_ALREADY_EXISTS", { status: 400 });
}
console.error("Failed to update resume:", error);
throw new ORPCError("INTERNAL_SERVER_ERROR", { message: "Failed to update resume" });
}
},
patch: async (input: { id: string; userId: string; operations: Operation[] }) => {
const [existing] = await db
.select({ data: schema.resume.data, isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!existing) throw new ORPCError("NOT_FOUND");
if (existing.isLocked) throw new ORPCError("RESUME_LOCKED");
let patchedData: ResumeData;
try {
patchedData = applyResumePatches(existing.data, input.operations);
} catch (error) {
if (error instanceof ResumePatchError) {
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error.message,
data: { code: error.code, index: error.index, operation: error.operation },
});
}
throw new ORPCError("INVALID_PATCH_OPERATIONS", {
status: 400,
message: error instanceof Error ? error.message : "Failed to apply patch operations",
});
}
const [resume] = await db
.update(schema.resume)
.set({ data: patchedData })
.where(
and(eq(schema.resume.id, input.id), eq(schema.resume.isLocked, false), eq(schema.resume.userId, input.userId)),
)
.returning({
id: schema.resume.id,
name: schema.resume.name,
slug: schema.resume.slug,
tags: schema.resume.tags,
data: schema.resume.data,
isPublic: schema.resume.isPublic,
isLocked: schema.resume.isLocked,
hasPassword: sql<boolean>`${schema.resume.password} IS NOT NULL`,
});
if (!resume) throw new ORPCError("NOT_FOUND");
return resume;
},
setLocked: async (input: { id: string; userId: string; isLocked: boolean }) => {
await db
.update(schema.resume)
.set({ isLocked: input.isLocked })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
setPassword: async (input: { id: string; userId: string; password: string }) => {
const hashedPassword = await hash(input.password, 10);
await db
.update(schema.resume)
.set({ password: hashedPassword })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
verifyPassword: async (input: { slug: string; username: string; password: string }) => {
const [resume] = await db
.select({ id: schema.resume.id, password: schema.resume.password })
.from(schema.resume)
.innerJoin(schema.user, eq(schema.resume.userId, schema.user.id))
.where(
and(
isNotNull(schema.resume.password),
eq(schema.resume.slug, input.slug),
eq(schema.user.username, input.username),
),
);
if (!resume) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
const passwordHash = resume.password as string;
const isValid = await compare(input.password, passwordHash);
if (!isValid) throw new ORPCError("INVALID_PASSWORD", { status: 401 });
grantResumeAccess(resume.id, passwordHash);
return true;
},
removePassword: async (input: { id: string; userId: string }) => {
await db
.update(schema.resume)
.set({ password: null })
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
},
delete: async (input: { id: string; userId: string }) => {
await db.transaction(async (tx) => {
const [resume] = await tx
.select({ isLocked: schema.resume.isLocked })
.from(schema.resume)
.where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
if (!resume) throw new ORPCError("NOT_FOUND");
if (resume.isLocked) throw new ORPCError("RESUME_LOCKED");
await tx.delete(schema.resume).where(and(eq(schema.resume.id, input.id), eq(schema.resume.userId, input.userId)));
});
// Clean up storage files after the DB transaction succeeds
const storageService = getStorageService();
await Promise.allSettled([
storageService.delete(`uploads/${input.userId}/screenshots/${input.id}`),
storageService.delete(`uploads/${input.userId}/pdfs/${input.id}`),
]);
},
};
+127
View File
@@ -0,0 +1,127 @@
import fs from "node:fs/promises";
import { dirname, join } from "node:path";
import { count } from "drizzle-orm";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
const CACHE_DURATION_MS = 6 * 60 * 60 * 1000; // 6 hours
const GITHUB_API_URL = "https://api.github.com/repos/amruthpillai/reactive-resume";
const GITHUB_REQUEST_TIMEOUT_MS = 5_000;
const GITHUB_REQUEST_MAX_ATTEMPTS = 2;
const LAST_KNOWN = {
users: 978_528,
resumes: 1_336_307,
stars: 34_073,
} as const;
const getCachePath = (key: string) => join(process.cwd(), "data", "statistics", `${key}.txt`);
const readCache = async (key: string): Promise<number | null> => {
try {
const filePath = getCachePath(key);
const [stats, contents] = await Promise.all([fs.stat(filePath), fs.readFile(filePath, "utf-8")]);
if (stats.mtimeMs < Date.now() - CACHE_DURATION_MS) return null;
const value = Number.parseInt(contents, 10);
return Number.isFinite(value) && value >= 0 ? value : null;
} catch {
return null;
}
};
const writeCache = async (key: string, value: number) => {
try {
const filePath = getCachePath(key);
await fs.mkdir(dirname(filePath), { recursive: true });
const contents = String(value);
try {
if ((await fs.readFile(filePath, "utf-8")) === contents) return;
} catch {
// Cache file does not exist yet.
}
await fs.writeFile(filePath, contents, "utf-8");
} catch {
// Ignore errors, cache is not critical
}
};
const getCachedCount = async (
key: string,
lastKnown: number,
fetcher: () => Promise<number | null>,
): Promise<number> => {
const cached = await readCache(key);
if (cached !== null) return cached;
try {
const value = await fetcher();
if (value !== null) {
await writeCache(key, value);
return value;
}
} catch {
// Ignore errors, use last known value
}
return lastKnown;
};
const getCountFromDatabase = async (table: typeof schema.user | typeof schema.resume): Promise<number | null> => {
const [result] = await db.select({ count: count() }).from(table);
if (!result) return null;
return result.count;
};
const fetchGitHubStarsOnce = async (): Promise<number | null> => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GITHUB_REQUEST_TIMEOUT_MS);
try {
const response = await fetch(GITHUB_API_URL, {
signal: controller.signal,
headers: {
Accept: "application/vnd.github+json",
},
});
if (!response.ok) return null;
const data = (await response.json()) as { stargazers_count?: unknown };
const stars = Number(data.stargazers_count);
return Number.isFinite(stars) && stars > 0 ? stars : null;
} catch {
return null;
} finally {
clearTimeout(timeoutId);
}
};
const getGitHubStars = async (): Promise<number | null> => {
for (let attempt = 0; attempt < GITHUB_REQUEST_MAX_ATTEMPTS; attempt++) {
const stars = await fetchGitHubStarsOnce();
if (stars !== null) return stars;
}
return null;
};
export const statisticsService = {
user: {
getCount: () => {
return getCachedCount("users", LAST_KNOWN.users, () => getCountFromDatabase(schema.user));
},
},
resume: {
getCount: () => {
return getCachedCount("resumes", LAST_KNOWN.resumes, () => getCountFromDatabase(schema.resume));
},
},
github: {
getStarCount: () => {
return getCachedCount("stars", LAST_KNOWN.stars, getGitHubStars);
},
},
};
+396
View File
@@ -0,0 +1,396 @@
import fs from "node:fs/promises";
import { dirname, extname, join } from "node:path";
import {
DeleteObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
PutObjectCommand,
S3Client,
} from "@aws-sdk/client-s3";
import sharp from "sharp";
import { env } from "@reactive-resume/env/server";
interface StorageWriteInput {
key: string;
data: Uint8Array;
contentType: string;
}
interface StorageReadResult {
data: Uint8Array;
size: number;
etag?: string;
lastModified?: Date;
contentType?: string;
}
interface StorageService {
list(prefix: string): Promise<string[]>;
write(input: StorageWriteInput): Promise<void>;
read(key: string): Promise<StorageReadResult | null>;
delete(key: string): Promise<boolean>;
healthcheck(): Promise<StorageHealthResult>;
}
interface StorageHealthResult {
status: "healthy" | "unhealthy";
type: "local" | "s3";
message: string;
error?: string;
}
const CONTENT_TYPE_MAP: Record<string, string> = {
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".svg": "image/svg+xml",
".pdf": "application/pdf",
};
const DEFAULT_CONTENT_TYPE = "application/octet-stream";
const IMAGE_MIME_TYPES = ["image/gif", "image/png", "image/jpeg", "image/webp"];
// Key builders for different upload types
function buildPictureKey(userId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pictures/${timestamp}.jpeg`;
}
function buildScreenshotKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/screenshots/${resumeId}/${timestamp}.jpeg`;
}
function buildPdfKey(userId: string, resumeId: string): string {
const timestamp = Date.now();
return `uploads/${userId}/pdfs/${resumeId}/${timestamp}.pdf`;
}
function buildPublicUrl(path: string): string {
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
const apiPath = normalizedPath.startsWith("/api/") ? normalizedPath : `/api${normalizedPath}`;
return new URL(apiPath, env.APP_URL).toString();
}
export function inferContentType(filename: string): string {
const extension = extname(filename).toLowerCase();
return CONTENT_TYPE_MAP[extension] ?? DEFAULT_CONTENT_TYPE;
}
export function isImageFile(mimeType: string): boolean {
return IMAGE_MIME_TYPES.includes(mimeType);
}
interface ProcessedImage {
data: Uint8Array;
contentType: string;
}
export async function processImageForUpload(file: File): Promise<ProcessedImage> {
const fileBuffer = await file.arrayBuffer();
if (env.FLAG_DISABLE_IMAGE_PROCESSING) {
return {
data: new Uint8Array(fileBuffer),
contentType: file.type,
};
}
const processedBuffer = await sharp(fileBuffer)
.resize(800, 800, { fit: "inside", withoutEnlargement: true })
.jpeg({ quality: 80 })
.toBuffer();
return {
data: new Uint8Array(processedBuffer),
contentType: "image/jpeg",
};
}
class LocalStorageService implements StorageService {
private rootDirectory: string;
constructor() {
this.rootDirectory = join(process.cwd(), "data");
}
async list(prefix: string): Promise<string[]> {
const fullPath = this.resolvePath(prefix);
try {
const files = await fs.readdir(fullPath, { recursive: true });
return files.map((file) => join(prefix, file));
} catch (error: unknown) {
// If directory doesn't exist, return empty array
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return [];
}
throw error;
}
}
async write({ key, data }: StorageWriteInput): Promise<void> {
const fullPath = this.resolvePath(key);
await fs.mkdir(dirname(fullPath), { recursive: true });
await fs.writeFile(fullPath, data);
}
async read(key: string): Promise<StorageReadResult | null> {
const fullPath = this.resolvePath(key);
try {
const [arrayBuffer, stats] = await Promise.all([fs.readFile(fullPath), fs.stat(fullPath)]);
return {
data: arrayBuffer,
size: stats.size,
etag: `"${stats.size}-${stats.mtime.getTime()}"`,
lastModified: stats.mtime,
contentType: inferContentType(key),
};
} catch (error: unknown) {
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
return null;
}
throw error;
}
}
async delete(key: string): Promise<boolean> {
const fullPath = this.resolvePath(key);
// Check if the path exists and whether it's a file or folder
try {
const stats = await fs.stat(fullPath);
if (stats.isDirectory()) {
// Delete the directory and its contents recursively
await fs.rm(fullPath, { recursive: true });
return true;
}
await fs.unlink(fullPath);
return true;
} catch {
// Path does not exist
return false;
}
}
async healthcheck(): Promise<StorageHealthResult> {
try {
await fs.mkdir(this.rootDirectory, { recursive: true });
await fs.access(this.rootDirectory, fs.constants.R_OK | fs.constants.W_OK);
return {
type: "local",
status: "healthy",
message: "Local filesystem storage is accessible and has read/write permission.",
};
} catch (error: unknown) {
return {
type: "local",
status: "unhealthy",
message: "Local filesystem storage is not accessible or lacks sufficient permissions.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
private resolvePath(key: string): string {
const normalizedKey = key.replace(/^\/*/, "");
const segments = normalizedKey
.split(/[/\\]+/)
.filter((segment) => segment.length > 0 && segment !== "." && segment !== "..");
if (segments.length === 0) throw new Error("Invalid storage key");
return join(this.rootDirectory, ...segments);
}
}
class S3StorageService implements StorageService {
private readonly bucket: string;
private readonly accessKeyId: string;
private readonly secretAccessKey: string;
private readonly endpoint: string | undefined;
private readonly clientPromise: Promise<S3Client>;
constructor() {
if (!env.S3_ACCESS_KEY_ID || !env.S3_SECRET_ACCESS_KEY || !env.S3_BUCKET) {
throw new Error("S3 credentials are not set");
}
this.bucket = env.S3_BUCKET;
this.accessKeyId = env.S3_ACCESS_KEY_ID;
this.secretAccessKey = env.S3_SECRET_ACCESS_KEY;
this.endpoint = env.S3_ENDPOINT;
this.clientPromise = this.createClient();
}
private async createClient(): Promise<S3Client> {
return new S3Client({
region: env.S3_REGION,
forcePathStyle: env.S3_FORCE_PATH_STYLE,
...(this.endpoint ? { endpoint: this.endpoint } : {}),
credentials: {
accessKeyId: this.accessKeyId,
secretAccessKey: this.secretAccessKey,
},
});
}
private async getClient(): Promise<S3Client> {
return this.clientPromise;
}
async list(prefix: string): Promise<string[]> {
const client = await this.getClient();
const command = new ListObjectsV2Command({ Bucket: this.bucket, Prefix: prefix });
const response = await client.send(command);
if (!response.Contents) return [];
return response.Contents.map((object) => object.Key ?? "");
}
async write({ key, data, contentType }: StorageWriteInput): Promise<void> {
const client = await this.getClient();
const command = new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: data,
ACL: "public-read",
ContentType: contentType,
});
await client.send(command);
}
async read(key: string): Promise<StorageReadResult | null> {
try {
const client = await this.getClient();
const command = new GetObjectCommand({ Bucket: this.bucket, Key: key });
const response = await client.send(command);
if (!response.Body) return null;
const arrayBuffer = await response.Body.transformToByteArray();
return {
data: arrayBuffer,
size: response.ContentLength ?? 0,
contentType: response.ContentType ?? inferContentType(key),
...(response.ETag !== undefined ? { etag: response.ETag } : {}),
...(response.LastModified !== undefined ? { lastModified: response.LastModified } : {}),
};
} catch {
return null;
}
}
async delete(keyOrPrefix: string): Promise<boolean> {
const client = await this.getClient();
// Use list to find all matching keys (handles both single file and folder/prefix)
const keys = await this.list(keyOrPrefix);
if (keys.length === 0) return false;
// Delete all matching keys using Promise.allSettled
const deleteCommands = keys.map((k) => new DeleteObjectCommand({ Bucket: this.bucket, Key: k }));
const results = await Promise.allSettled(deleteCommands.map((c) => client.send(c)));
// Return true if at least one deletion succeeded
return results.some((r) => r.status === "fulfilled");
}
async healthcheck(): Promise<StorageHealthResult> {
try {
const client = await this.getClient();
const putCommand = new PutObjectCommand({ Bucket: this.bucket, Key: "healthcheck", Body: "OK" });
await client.send(putCommand);
const deleteCommand = new DeleteObjectCommand({ Bucket: this.bucket, Key: "healthcheck" });
await client.send(deleteCommand);
return {
type: "s3",
status: "healthy",
message: "S3 storage is accessible and credentials are valid.",
};
} catch (error: unknown) {
return {
type: "s3",
status: "unhealthy",
message: "Failed to connect to S3 storage or invalid credentials.",
error: error instanceof Error ? error.message : "Unknown error",
};
}
}
}
function createStorageService(): StorageService {
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) {
return new S3StorageService();
}
return new LocalStorageService();
}
let cachedService: StorageService | null = null;
export function getStorageService(): StorageService {
if (cachedService) return cachedService;
cachedService = createStorageService();
return cachedService;
}
// High-level upload types
type UploadType = "picture" | "screenshot" | "pdf";
interface UploadFileInput {
userId: string;
data: Uint8Array;
contentType: string;
type: UploadType;
resumeId?: string;
}
interface UploadFileResult {
url: string;
key: string;
}
export async function uploadFile(input: UploadFileInput): Promise<UploadFileResult> {
const storageService = getStorageService();
let key: string;
switch (input.type) {
case "picture":
key = buildPictureKey(input.userId);
break;
case "screenshot":
if (!input.resumeId) throw new Error("resumeId is required for screenshot uploads");
key = buildScreenshotKey(input.userId, input.resumeId);
break;
case "pdf":
if (!input.resumeId) throw new Error("resumeId is required for pdf uploads");
key = buildPdfKey(input.userId, input.resumeId);
break;
}
await storageService.write({
key,
data: input.data,
contentType: input.contentType,
});
return {
key,
url: buildPublicUrl(key),
};
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ESNext", "DOM"]
}
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/api",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+34
View File
@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+43
View File
@@ -0,0 +1,43 @@
{
"name": "@reactive-resume/auth",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./config": "./src/config.ts",
"./functions": "./src/functions.ts",
"./types": "./src/types.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@better-auth/api-key": "^1.6.9",
"@better-auth/drizzle-adapter": "^1.6.9",
"@better-auth/infra": "^0.2.6",
"@better-auth/oauth-provider": "^1.6.9",
"@better-auth/passkey": "^1.6.9",
"@reactive-resume/db": "workspace:*",
"@reactive-resume/email": "workspace:*",
"@reactive-resume/env": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"@tanstack/react-start": "^1.167.65",
"bcrypt": "^6.0.0",
"better-auth": "1.6.9",
"drizzle-orm": "1.0.0-beta.22",
"jose": "^6.2.3",
"react": "^19.2.6",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/bcrypt": "^6.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+418
View File
@@ -0,0 +1,418 @@
import type { GenericOAuthConfig } from "better-auth/plugins";
import type { SQL } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import type { JWTPayload } from "jose";
import { apiKey } from "@better-auth/api-key";
import { drizzleAdapter } from "@better-auth/drizzle-adapter";
import { dash } from "@better-auth/infra";
import { oauthProvider } from "@better-auth/oauth-provider";
import { passkey } from "@better-auth/passkey";
import { compare, hash } from "bcrypt";
import { APIError, BetterAuthError, betterAuth } from "better-auth";
import { createAuthMiddleware } from "better-auth/api";
import { verifyAccessToken } from "better-auth/oauth2";
import { admin, jwt } from "better-auth/plugins";
import { genericOAuth } from "better-auth/plugins/generic-oauth";
import { twoFactor } from "better-auth/plugins/two-factor";
import { username } from "better-auth/plugins/username";
import { eq, or, sql } from "drizzle-orm";
import { createElement } from "react";
import { db } from "@reactive-resume/db/client";
import * as schema from "@reactive-resume/db/schema";
import { ResetPasswordEmail, VerifyEmail, VerifyEmailChange } from "@reactive-resume/email/templates/auth";
import { sendEmail } from "@reactive-resume/email/transport";
import { env } from "@reactive-resume/env/server";
import { rateLimitConfig, TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
import { generateId, toUsername } from "@reactive-resume/utils/string";
import { isAllowedOAuthRedirectUri, parseAllowedHostList } from "@reactive-resume/utils/url-security";
const authBaseUrl = env.APP_URL;
const isRateLimitEnabled = process.env.NODE_ENV === "production";
function getOAuthAudiences(): string[] {
const base = authBaseUrl.replace(/\/$/, "");
return [base, `${base}/`, `${base}/mcp`, `${base}/mcp/`];
}
const OAUTH_AUDIENCES = getOAuthAudiences();
export async function verifyOAuthToken(token: string): Promise<JWTPayload> {
return await verifyAccessToken(token, {
jwksUrl: `${authBaseUrl}/api/auth/jwks`,
verifyOptions: {
issuer: `${authBaseUrl}/api/auth`,
audience: OAUTH_AUDIENCES,
},
});
}
function isCustomOAuthProviderEnabled() {
const hasDiscovery = Boolean(env.OAUTH_DISCOVERY_URL);
const hasManual =
Boolean(env.OAUTH_AUTHORIZATION_URL) && Boolean(env.OAUTH_TOKEN_URL) && Boolean(env.OAUTH_USER_INFO_URL);
return Boolean(env.OAUTH_CLIENT_ID) && Boolean(env.OAUTH_CLIENT_SECRET) && (hasDiscovery || hasManual);
}
function getTrustedOrigins(): string[] {
const normalizeOrigin = (origin: string): string => origin.replace(/\/$/, "");
const trustedOrigins = new Set<string>(["http://localhost:3000", "http://127.0.0.1:3000"]);
trustedOrigins.add(normalizeOrigin(new URL(env.APP_URL).origin));
return Array.from(trustedOrigins);
}
const TRUSTED_ORIGINS = getTrustedOrigins();
const OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS = parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS);
const oauthProviderRateLimit = isRateLimitEnabled
? rateLimitConfig.betterAuth.oauthProvider
: ({
register: false,
authorize: false,
token: false,
introspect: false,
revoke: false,
userinfo: false,
} as const);
function lower<T extends AnyPgColumn>(column: T): SQL<T> {
return sql`lower(${column})`;
}
async function findExistingUserByEmail(email: string) {
const normalizedEmail = email.trim().toLowerCase();
const [existingUser] = await db
.select({
id: schema.user.id,
email: schema.user.email,
emailVerified: schema.user.emailVerified,
username: schema.user.username,
displayUsername: schema.user.displayUsername,
name: schema.user.name,
image: schema.user.image,
})
.from(schema.user)
.where(eq(lower(schema.user.email), normalizedEmail))
.limit(1);
return existingUser;
}
function getEmailLocalPart(email: string): string {
return email.split("@", 1)[0] ?? "";
}
function appendUsernameSuffix(base: string, suffix: string): string {
const maxBaseLength = 64 - suffix.length;
return `${base.slice(0, maxBaseLength)}${suffix}`;
}
async function isUsernameTaken(candidate: string): Promise<boolean> {
const normalizedCandidate = candidate.trim().toLowerCase();
const [existingUser] = await db
.select({ id: schema.user.id })
.from(schema.user)
.where(
or(
eq(lower(schema.user.username), normalizedCandidate),
eq(lower(schema.user.displayUsername), normalizedCandidate),
),
)
.limit(1);
return Boolean(existingUser);
}
async function allocateUniqueUsername(email: string, preferredUsername?: string | null): Promise<string> {
const emailLocalPart = getEmailLocalPart(email);
const preferred = preferredUsername ? toUsername(preferredUsername) : "";
const normalizedEmailLocalPart = toUsername(emailLocalPart);
const baseUsername = preferred || normalizedEmailLocalPart || "user";
if (!(await isUsernameTaken(baseUsername))) return baseUsername;
for (let index = 1; index <= 999; index += 1) {
const candidate = appendUsernameSuffix(baseUsername, `-${index}`);
if (await isUsernameTaken(candidate)) continue;
return candidate;
}
return appendUsernameSuffix(baseUsername, `-${generateId().slice(0, 8).toLowerCase()}`);
}
interface OAuthProfile {
email?: string | null;
name?: string | null;
picture?: string | null;
image?: string | null;
avatar_url?: string | null;
login?: string | null;
preferred_username?: string | null;
}
interface OAuthMapperContext {
email: string;
emailLocalPart: string;
}
interface OAuthMapperOptions<TProfile extends OAuthProfile> {
providerName: string;
getPreferredUsername?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getName?: (profile: TProfile, context: OAuthMapperContext) => string | undefined | null;
getImage?: (profile: TProfile) => string | undefined | null;
}
function createProfileMapper<TProfile extends OAuthProfile>({
providerName,
getPreferredUsername,
getName,
getImage,
}: OAuthMapperOptions<TProfile>) {
return async (profile: TProfile) => {
if (!profile.email) {
throw new BetterAuthError(
`${providerName} provider did not return an email address. This is required for user creation.`,
{ cause: "EMAIL_REQUIRED" },
);
}
const email = profile.email.trim().toLowerCase();
const emailLocalPart = getEmailLocalPart(email);
const context = { email, emailLocalPart };
const existingUser = await findExistingUserByEmail(email);
const image = getImage?.(profile) ?? undefined;
if (existingUser) {
return {
name: existingUser.name,
email: existingUser.email,
image: image ?? existingUser.image,
username: existingUser.username,
displayUsername: existingUser.displayUsername,
emailVerified: existingUser.emailVerified,
};
}
const preferredUsername = getPreferredUsername?.(profile, context);
const username = await allocateUniqueUsername(email, preferredUsername);
const mappedName = getName?.(profile, context)?.trim();
return {
name: mappedName || username || emailLocalPart,
email,
image,
username,
displayUsername: username,
emailVerified: true,
};
};
}
const getAuthConfig = () => {
const authConfigs: GenericOAuthConfig[] = [];
if (isCustomOAuthProviderEnabled()) {
authConfigs.push({
providerId: "custom",
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
clientId: env.OAUTH_CLIENT_ID as string,
clientSecret: env.OAUTH_CLIENT_SECRET as string,
discoveryUrl: env.OAUTH_DISCOVERY_URL,
authorizationUrl: env.OAUTH_AUTHORIZATION_URL,
tokenUrl: env.OAUTH_TOKEN_URL,
userInfoUrl: env.OAUTH_USER_INFO_URL,
scopes: env.OAUTH_SCOPES,
redirectURI: `${authBaseUrl}/api/auth/oauth2/callback/custom`,
mapProfileToUser: createProfileMapper({
providerName: "OAuth Provider",
getPreferredUsername: (profile, context) => profile.preferred_username ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.preferred_username ?? context.emailLocalPart,
getImage: (profile) => profile.image ?? profile.picture ?? profile.avatar_url,
}),
} satisfies GenericOAuthConfig);
}
return betterAuth({
appName: "Reactive Resume",
baseURL: authBaseUrl,
secret: env.AUTH_SECRET,
database: drizzleAdapter(db, { schema, provider: "pg" }),
telemetry: { enabled: false },
trustedOrigins: TRUSTED_ORIGINS,
rateLimit: {
...rateLimitConfig.betterAuth.global,
enabled: isRateLimitEnabled,
},
hooks: {
before: createAuthMiddleware(async (ctx) => {
if (!ctx.path.includes("/oauth2/register")) return;
const body = ctx.body as { redirect_uris?: unknown } | undefined;
const redirectUris = Array.isArray(body?.redirect_uris) ? body.redirect_uris : [];
for (const uri of redirectUris) {
if (typeof uri !== "string") {
throw new APIError("BAD_REQUEST", { message: "redirect_uris entries must be strings" });
}
if (!isAllowedOAuthRedirectUri(uri, TRUSTED_ORIGINS, OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS)) {
throw new APIError("BAD_REQUEST", {
message: "redirect_uri is not allowed for dynamic client registration",
});
}
}
}),
},
advanced: {
database: { generateId },
useSecureCookies: authBaseUrl.startsWith("https://"),
ipAddress: { ipAddressHeaders: TRUSTED_IP_HEADERS },
},
emailAndPassword: {
enabled: !env.FLAG_DISABLE_EMAIL_AUTH,
autoSignIn: true,
minPasswordLength: 8,
maxPasswordLength: 64,
requireEmailVerification: false,
disableSignUp: env.FLAG_DISABLE_SIGNUPS || env.FLAG_DISABLE_EMAIL_AUTH,
sendResetPassword: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Reset your password",
react: createElement(ResetPasswordEmail, { url }),
});
},
password: {
hash: (password) => hash(password, 10),
verify: ({ password, hash }) => compare(password, hash),
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await sendEmail({
to: user.email,
subject: "Verify your email",
react: createElement(VerifyEmail, { url }),
});
},
},
user: {
changeEmail: {
enabled: true,
sendChangeEmailConfirmation: async ({ user, newEmail, url }) => {
await sendEmail({
to: newEmail,
subject: "Verify your new email",
react: createElement(VerifyEmailChange, { url, previousEmail: user.email, newEmail }),
});
},
},
additionalFields: {
username: {
type: "string",
required: true,
},
},
},
account: {
accountLinking: {
enabled: true,
trustedProviders: ["google", "github", "linkedin"],
},
},
socialProviders: {
google: {
enabled: !!env.GOOGLE_CLIENT_ID && !!env.GOOGLE_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
disableImplicitSignUp: true,
clientId: env.GOOGLE_CLIENT_ID ?? "",
clientSecret: env.GOOGLE_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "Google",
getName: (profile, context) => profile.name ?? context.emailLocalPart,
getImage: (profile) => profile.picture,
}),
},
github: {
enabled: !!env.GITHUB_CLIENT_ID && !!env.GITHUB_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
disableImplicitSignUp: true,
clientId: env.GITHUB_CLIENT_ID ?? "",
clientSecret: env.GITHUB_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "GitHub",
getPreferredUsername: (profile, context) => profile.login ?? context.emailLocalPart,
getName: (profile, context) => profile.name ?? profile.login ?? context.emailLocalPart,
getImage: (profile) => profile.avatar_url,
}),
},
linkedin: {
enabled: !!env.LINKEDIN_CLIENT_ID && !!env.LINKEDIN_CLIENT_SECRET,
disableSignUp: env.FLAG_DISABLE_SIGNUPS,
disableImplicitSignUp: true,
clientId: env.LINKEDIN_CLIENT_ID ?? "",
clientSecret: env.LINKEDIN_CLIENT_SECRET ?? "",
mapProfileToUser: createProfileMapper({
providerName: "LinkedIn",
getName: (profile, context) => profile.name ?? context.emailLocalPart,
getImage: (profile) => profile.picture,
}),
},
},
plugins: [
jwt(),
admin(),
passkey(),
genericOAuth({ config: authConfigs }),
twoFactor({ issuer: "Reactive Resume" }),
apiKey({
enableSessionForAPIKeys: true,
rateLimit: {
...rateLimitConfig.betterAuth.apiKey,
enabled: isRateLimitEnabled,
},
}),
oauthProvider({
loginPage: "/auth/oauth",
consentPage: "/auth/oauth",
validAudiences: OAUTH_AUDIENCES,
allowDynamicClientRegistration: true,
// Required for MCP client onboarding (RFC 7591). Phishing vector is closed by the
// redirect_uri allowlist in the hooks.before middleware above and in src/routes/api/auth.$.ts.
allowUnauthenticatedClientRegistration: true,
rateLimit: oauthProviderRateLimit,
silenceWarnings: { oauthAuthServerConfig: true },
}),
username({
minUsernameLength: 3,
maxUsernameLength: 64,
usernameNormalization: (value) => toUsername(value),
displayUsernameNormalization: (value) => toUsername(value),
usernameValidator: (username) => /^[a-z0-9._-]+$/.test(username),
validationOrder: { username: "post-normalization", displayUsername: "post-normalization" },
}),
...(env.BETTER_AUTH_API_KEY
? [dash({ apiKey: env.BETTER_AUTH_API_KEY, activityTracking: { enabled: true } })]
: []),
],
});
};
export const auth = getAuthConfig();
+8
View File
@@ -0,0 +1,8 @@
import type { AuthSession } from "./types";
import { getRequestHeaders } from "@tanstack/react-start/server";
import { auth } from "./config";
export async function getSession(): Promise<AuthSession | null> {
const result = await auth.api.getSession({ headers: getRequestHeaders() });
return result as AuthSession | null;
}
+11
View File
@@ -0,0 +1,11 @@
import type { auth } from "./config";
import z from "zod";
export type AuthSession = {
session: typeof auth.$Infer.Session.session;
user: typeof auth.$Infer.Session.user;
};
const authProviderSchema = z.enum(["credential", "passkey", "google", "github", "linkedin", "custom"]);
export type AuthProvider = z.infer<typeof authProviderSchema>;
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ESNext", "DOM"]
}
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/auth",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+11
View File
@@ -0,0 +1,11 @@
{
"name": "@reactive-resume/config",
"version": "0.0.0",
"private": true,
"scripts": {
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
}
}
+24
View File
@@ -0,0 +1,24 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"lib": ["ESNext"],
"verbatimModuleSyntax": true,
"strict": true,
"exactOptionalPropertyTypes": true,
"skipLibCheck": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"noUncheckedIndexedAccess": true,
"noUncheckedSideEffectImports": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"types": ["node"]
}
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/config",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+35
View File
@@ -0,0 +1,35 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
/prisma/generated
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store
+10
View File
@@ -0,0 +1,10 @@
import { defineConfig } from "drizzle-kit";
export default defineConfig({
schema: "./src/schema/index.ts",
out: "../../migrations",
dialect: "postgresql",
dbCredentials: {
url: process.env.DATABASE_URL || "",
},
});
+38
View File
@@ -0,0 +1,38 @@
{
"name": "@reactive-resume/db",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./client": {
"default": "./src/client.ts"
},
"./schema": {
"default": "./src/schema/index.ts"
}
},
"scripts": {
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:studio": "drizzle-kit studio",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@reactive-resume/env": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"drizzle-orm": "1.0.0-beta.22",
"pg": "^8.20.0"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/pg": "^8.20.0",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"drizzle-kit": "1.0.0-beta.22",
"typescript": "^6.0.3"
}
}
+32
View File
@@ -0,0 +1,32 @@
import type { NodePgDatabase } from "drizzle-orm/node-postgres";
import { drizzle } from "drizzle-orm/node-postgres";
import { Pool } from "pg";
import { env } from "@reactive-resume/env/server";
import { relations } from "./relations";
import * as schema from "./schema";
declare global {
var __pool: Pool | undefined;
var __drizzle: NodePgDatabase<typeof schema> | undefined;
}
function getPool() {
if (!globalThis.__pool) {
globalThis.__pool = new Pool({ connectionString: env.DATABASE_URL });
}
return globalThis.__pool;
}
function makeDrizzleClient() {
const pool = getPool();
return drizzle({ client: pool, schema, relations });
}
export function createDatabase() {
if (!globalThis.__drizzle) {
globalThis.__drizzle = makeDrizzleClient();
}
return globalThis.__drizzle;
}
export const db = createDatabase();
+129
View File
@@ -0,0 +1,129 @@
import { defineRelations } from "drizzle-orm";
import * as schema from "./schema";
export const relations = defineRelations(schema, (r) => ({
user: {
sessions: r.many.session(),
accounts: r.many.account(),
twoFactors: r.many.twoFactor(),
passkeys: r.many.passkey(),
resumes: r.many.resume(),
apiKeys: r.many.apikey(),
oauthClients: r.many.oauthClient(),
oauthRefreshTokens: r.many.oauthRefreshToken(),
oauthAccessTokens: r.many.oauthAccessToken(),
oauthConsents: r.many.oauthConsent(),
},
session: {
user: r.one.user({
from: r.session.userId,
to: r.user.id,
}),
oauthRefreshTokens: r.many.oauthRefreshToken({
from: r.session.id,
to: r.oauthRefreshToken.sessionId,
}),
oauthAccessTokens: r.many.oauthAccessToken({
from: r.session.id,
to: r.oauthAccessToken.sessionId,
}),
},
account: {
user: r.one.user({
from: r.account.userId,
to: r.user.id,
}),
},
twoFactor: {
user: r.one.user({
from: r.twoFactor.userId,
to: r.user.id,
}),
},
passkey: {
user: r.one.user({
from: r.passkey.userId,
to: r.user.id,
}),
},
resume: {
user: r.one.user({
from: r.resume.userId,
to: r.user.id,
}),
statistics: r.one.resumeStatistics({
from: r.resume.id,
to: r.resumeStatistics.resumeId,
}),
analysis: r.one.resumeAnalysis({
from: r.resume.id,
to: r.resumeAnalysis.resumeId,
}),
},
resumeStatistics: {
resume: r.one.resume({
from: r.resumeStatistics.resumeId,
to: r.resume.id,
}),
},
resumeAnalysis: {
resume: r.one.resume({
from: r.resumeAnalysis.resumeId,
to: r.resume.id,
}),
},
apikey: {
user: r.one.user({
from: r.apikey.referenceId,
to: r.user.id,
}),
},
oauthClient: {
user: r.one.user({
from: r.oauthClient.userId,
to: r.user.id,
}),
oauthRefreshTokens: r.many.oauthRefreshToken({
from: r.oauthClient.clientId,
to: r.oauthRefreshToken.clientId,
}),
oauthAccessTokens: r.many.oauthAccessToken({
from: r.oauthClient.clientId,
to: r.oauthAccessToken.clientId,
}),
oauthConsents: r.many.oauthConsent({
from: r.oauthClient.clientId,
to: r.oauthConsent.clientId,
}),
},
oauthRefreshToken: {
user: r.one.user({
from: r.oauthRefreshToken.userId,
to: r.user.id,
}),
session: r.one.session({
from: r.oauthRefreshToken.sessionId,
to: r.session.id,
}),
},
oauthAccessToken: {
user: r.one.user({
from: r.oauthAccessToken.userId,
to: r.user.id,
}),
session: r.one.session({
from: r.oauthAccessToken.sessionId,
to: r.session.id,
}),
refreshToken: r.one.oauthRefreshToken({
from: r.oauthAccessToken.refreshId,
to: r.oauthRefreshToken.id,
}),
},
oauthConsent: {
user: r.one.user({
from: r.oauthConsent.userId,
to: r.user.id,
}),
},
}));
+357
View File
@@ -0,0 +1,357 @@
import type { SQL } from "drizzle-orm";
import type { AnyPgColumn } from "drizzle-orm/pg-core";
import { sql } from "drizzle-orm";
import * as pg from "drizzle-orm/pg-core";
import { generateId } from "@reactive-resume/utils/string";
function lower<T extends AnyPgColumn>(column: T): SQL<T> {
return sql`lower(${column})`;
}
export const user = pg.pgTable(
"user",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
image: pg.text("image"),
name: pg.text("name").notNull(),
email: pg.text("email").notNull().unique(),
emailVerified: pg.boolean("email_verified").notNull().default(false),
username: pg.text("username").notNull().unique(),
displayUsername: pg.text("display_username").notNull().unique(),
twoFactorEnabled: pg.boolean("two_factor_enabled").notNull().default(false),
lastActiveAt: pg.timestamp("last_active_at", { withTimezone: true }),
role: pg.text("role").default("user"),
banned: pg.boolean("banned").default(false),
banReason: pg.text("ban_reason"),
banExpires: pg.timestamp("ban_expires", {
precision: 6,
withTimezone: true,
}),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.createdAt.asc()), pg.uniqueIndex("user_email_lower_unique_idx").on(lower(t.email))],
);
export const session = pg.pgTable(
"session",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
token: pg.text("token").notNull().unique(),
ipAddress: pg.text("ip_address"),
userAgent: pg.text("user_agent"),
impersonatedBy: pg.text("impersonated_by"),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.token, t.userId), pg.index().on(t.expiresAt)],
);
export const account = pg.pgTable(
"account",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
accountId: pg.text("account_id").notNull(),
providerId: pg.text("provider_id").notNull().default("credential"),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
scope: pg.text("scope"),
idToken: pg.text("id_token"),
password: pg.text("password"),
accessToken: pg.text("access_token"),
refreshToken: pg.text("refresh_token"),
accessTokenExpiresAt: pg.timestamp("access_token_expires_at", {
withTimezone: true,
}),
refreshTokenExpiresAt: pg.timestamp("refresh_token_expires_at", {
withTimezone: true,
}),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.userId)],
);
export const verification = pg.pgTable(
"verification",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
identifier: pg.text("identifier").notNull().unique(),
value: pg.text("value").notNull(),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }).notNull(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.identifier)],
);
export const twoFactor = pg.pgTable(
"two_factor",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
secret: pg.text("secret").notNull(),
backupCodes: pg.text("backup_codes").notNull(),
verified: pg.boolean("verified").notNull().default(true),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.userId), pg.index().on(t.secret)],
);
export const passkey = pg.pgTable(
"passkey",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
name: pg.text("name"),
aaguid: pg.text("aaguid"),
publicKey: pg.text("public_key").notNull(),
credentialID: pg.text("credential_id").notNull(),
counter: pg.integer("counter").notNull(),
deviceType: pg.text("device_type").notNull(),
backedUp: pg.boolean("backed_up").notNull().default(false),
transports: pg.text("transports").notNull(),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.userId)],
);
export const apikey = pg.pgTable(
"apikey",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
name: pg.text("name"),
start: pg.text("start"),
prefix: pg.text("prefix"),
key: pg.text("key").notNull(),
configId: pg.text("config_id").notNull().default("default"),
referenceId: pg
.uuid("reference_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
refillInterval: pg.integer("refill_interval"),
refillAmount: pg.integer("refill_amount"),
lastRefillAt: pg.timestamp("last_refill_at", { withTimezone: true }),
enabled: pg.boolean("enabled").notNull().default(true),
rateLimitEnabled: pg.boolean("rate_limit_enabled").notNull().default(false),
rateLimitTimeWindow: pg.integer("rate_limit_time_window").default(86400000),
rateLimitMax: pg.integer("rate_limit_max").default(10),
requestCount: pg.integer("request_count").notNull().default(0),
remaining: pg.integer("remaining"),
lastRequest: pg.timestamp("last_request", { withTimezone: true }),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
permissions: pg.text("permissions"),
metadata: pg.jsonb("metadata"),
},
(t) => [
pg.index().on(t.referenceId),
pg.index().on(t.key),
pg.index().on(t.configId),
pg.index().on(t.enabled, t.referenceId),
],
);
export const jwks = pg.pgTable("jwks", {
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
publicKey: pg.text("public_key").notNull(),
privateKey: pg.text("private_key").notNull(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
});
export const oauthClient = pg.pgTable(
"oauth_client",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
clientId: pg.text("client_id").notNull().unique(),
clientSecret: pg.text("client_secret"),
disabled: pg.boolean("disabled").default(false),
skipConsent: pg.boolean("skip_consent"),
enableEndSession: pg.boolean("enable_end_session"),
subjectType: pg.text("subject_type"),
scopes: pg.text("scopes").array(),
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
name: pg.text("name"),
uri: pg.text("uri"),
icon: pg.text("icon"),
contacts: pg.text("contacts").array(),
tos: pg.text("tos"),
policy: pg.text("policy"),
softwareId: pg.text("software_id"),
softwareVersion: pg.text("software_version"),
softwareStatement: pg.text("software_statement"),
redirectUris: pg.text("redirect_uris").array().notNull(),
postLogoutRedirectUris: pg.text("post_logout_redirect_uris").array(),
tokenEndpointAuthMethod: pg.text("token_endpoint_auth_method"),
grantTypes: pg.text("grant_types").array(),
responseTypes: pg.text("response_types").array(),
public: pg.boolean("public"),
type: pg.text("type"),
requirePKCE: pg.boolean("require_pkce"),
referenceId: pg.text("reference_id"),
metadata: pg.jsonb("metadata"),
},
(t) => [pg.index().on(t.clientId)],
);
export const oauthRefreshToken = pg.pgTable(
"oauth_refresh_token",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
token: pg.text("token").notNull(),
clientId: pg
.text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: pg.uuid("session_id").references(() => session.id, { onDelete: "set null" }),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
referenceId: pg.text("reference_id"),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
revoked: pg.timestamp("revoked", { withTimezone: true }),
authTime: pg.timestamp("auth_time", { withTimezone: true }),
scopes: pg.text("scopes").array().notNull(),
},
(t) => [pg.index().on(t.token)],
);
export const oauthAccessToken = pg.pgTable(
"oauth_access_token",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
token: pg.text("token").notNull().unique(),
clientId: pg
.text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
sessionId: pg.uuid("session_id").references(() => session.id, { onDelete: "set null" }),
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: pg.text("reference_id"),
refreshId: pg.uuid("refresh_id").references(() => oauthRefreshToken.id, { onDelete: "cascade" }),
expiresAt: pg.timestamp("expires_at", { withTimezone: true }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
scopes: pg.text("scopes").array().notNull(),
},
(t) => [pg.index().on(t.token)],
);
export const oauthConsent = pg.pgTable(
"oauth_consent",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
clientId: pg
.text("client_id")
.notNull()
.references(() => oauthClient.clientId, { onDelete: "cascade" }),
userId: pg.uuid("user_id").references(() => user.id, { onDelete: "cascade" }),
referenceId: pg.text("reference_id"),
scopes: pg.text("scopes").array().notNull(),
createdAt: pg.timestamp("created_at", { withTimezone: true }).defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.userId, t.clientId)],
);
+2
View File
@@ -0,0 +1,2 @@
export * from "./auth";
export * from "./resume";
+92
View File
@@ -0,0 +1,92 @@
import type { StoredResumeAnalysis } from "@reactive-resume/schema/resume/analysis";
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import * as pg from "drizzle-orm/pg-core";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { generateId } from "@reactive-resume/utils/string";
import { user } from "./auth";
export const resume = pg.pgTable(
"resume",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
name: pg.text("name").notNull(),
slug: pg.text("slug").notNull(),
tags: pg.text("tags").array().notNull().default([]),
isPublic: pg.boolean("is_public").notNull().default(false),
isLocked: pg.boolean("is_locked").notNull().default(false),
password: pg.text("password"),
data: pg
.jsonb("data")
.notNull()
.$type<ResumeData>()
.$defaultFn(() => defaultResumeData),
userId: pg
.uuid("user_id")
.notNull()
.references(() => user.id, { onDelete: "cascade" }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(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),
],
);
export const resumeStatistics = pg.pgTable("resume_statistics", {
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
views: pg.integer("views").notNull().default(0),
downloads: pg.integer("downloads").notNull().default(0),
lastViewedAt: pg.timestamp("last_viewed_at", { withTimezone: true }),
lastDownloadedAt: pg.timestamp("last_downloaded_at", { withTimezone: true }),
resumeId: pg
.uuid("resume_id")
.unique()
.notNull()
.references(() => resume.id, { onDelete: "cascade" }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
});
export const resumeAnalysis = pg.pgTable(
"resume_analysis",
{
id: pg
.uuid("id")
.notNull()
.primaryKey()
.$defaultFn(() => generateId()),
analysis: pg.jsonb("analysis").notNull().$type<StoredResumeAnalysis>(),
resumeId: pg
.uuid("resume_id")
.unique()
.notNull()
.references(() => resume.id, { onDelete: "cascade" }),
createdAt: pg.timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
updatedAt: pg
.timestamp("updated_at", { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => /* @__PURE__ */ new Date()),
},
(t) => [pg.index().on(t.resumeId)],
);
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json"
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/db",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+32
View File
@@ -0,0 +1,32 @@
{
"name": "@reactive-resume/email",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./transport": "./src/transport.ts",
"./templates/auth": "./src/templates/auth.tsx"
},
"scripts": {
"dev": "email dev --dir src/templates --port 3001",
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@reactive-resume/env": "workspace:*",
"nodemailer": "^8.0.7",
"react": "^19.2.6",
"react-email": "^6.1.1"
},
"devDependencies": {
"@react-email/ui": "^6.1.1",
"@reactive-resume/config": "workspace:*",
"@types/nodemailer": "^8.0.0",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+187
View File
@@ -0,0 +1,187 @@
import {
Body,
Button,
Container,
Font,
Head,
Heading,
Hr,
Html,
Img,
Link,
Preview,
pixelBasedPreset,
Section,
Tailwind,
Text,
} from "react-email";
const appName = "Reactive Resume";
const logoUrl = "https://rxresu.me/icon/dark.svg";
interface AuthEmailLayoutProps {
preview: string;
heading: string;
intro: string;
details?: string;
actionLabel: string;
actionUrl: string;
outro: string;
}
function AuthEmailLayout({ preview, heading, intro, details, actionLabel, actionUrl, outro }: AuthEmailLayoutProps) {
return (
<Html lang="en">
<Tailwind
config={{
presets: [pixelBasedPreset],
theme: {
fontFamily: {
body: ["IBM Plex Sans", "sans-serif"],
heading: ["IBM Plex Sans Condensed", "sans-serif"],
},
},
}}
>
<Head>
<Font
fontFamily="IBM Plex Sans Condensed"
fallbackFontFamily="sans-serif"
fontWeight={500}
fontStyle="normal"
webFont={{
url: "https://fonts.gstatic.com/s/ibmplexsans/v23/zYXGKVElMYYaJe8bpLHnCwDKr932-G7dytD-DmvrswZSAXcomDVmadSD2FlDB6g4tIOm6_De.woff2",
format: "woff2",
}}
/>
<Font
fontFamily="IBM Plex Sans"
fallbackFontFamily="sans-serif"
fontWeight={400}
fontStyle="normal"
webFont={{
url: "https://fonts.gstatic.com/s/ibmplexsans/v23/zYXGKVElMYYaJe8bpLHnCwDKr932-G7dytD-Dmu1swZSAXcomDVmadSD6llDB6g4tIOm6_De.woff2",
format: "woff2",
}}
/>
</Head>
<Body className="m-0 bg-zinc-950 p-0 font-body text-sm text-zinc-50">
<Preview>{preview}</Preview>
<Container className="mx-auto w-full max-w-xl bg-zinc-900 p-6 text-zinc-50">
<Section>
<Img src={logoUrl} alt={appName} width="48" height="48" className="block" />
</Section>
<Section className="mt-6">
<Heading className="whitespace-break-spaces font-heading font-medium text-2xl leading-0 tracking-tighter md:text-5xl">
{heading}
</Heading>
<Section className="mt-6 md:mt-12">
<Text>{intro}</Text>
{details ? <Text className="opacity-60">{details}</Text> : null}
</Section>
<Button
target="_blank"
href={actionUrl}
className="mt-6 box-border inline-block bg-zinc-200 px-6 py-3 text-center text-zinc-900 no-underline"
>
{actionLabel}
</Button>
<Section className="mt-8">
<Text className="leading-0">
If the button does not work, copy and paste this link into your browser:
</Text>
<Link className="text-zinc-200/60 leading-0 underline underline-offset-2" href={actionUrl}>
{actionUrl}
</Link>
</Section>
<Section className="mt-4">
<Text className="opacity-60">{outro}</Text>
</Section>
<Hr className="my-10 border-zinc-700" />
<Text className="mt-8 text-xs leading-1 opacity-40">By the community, for the community.</Text>
<Text className="text-xs leading-1 opacity-40">
A passion project by{" "}
<Link
target="_blank"
rel="noopener noreferrer"
href="https://amruthpillai.com"
className="text-inherit underline underline-offset-2"
>
Amruth Pillai
</Link>
.
</Text>
<Text className="mt-8 font-heading font-medium text-base tracking-tight opacity-80">Reactive Resume</Text>
</Section>
</Container>
</Body>
</Tailwind>
</Html>
);
}
interface ResetPasswordEmailProps {
url: string;
}
export function ResetPasswordEmail({ url }: ResetPasswordEmailProps) {
return (
<AuthEmailLayout
preview={`Reset your ${appName} password`}
heading="Password Reset"
intro={`We received a request to reset your ${appName} password.`}
details="If this was not you, you can ignore this message and your password will remain unchanged."
actionLabel="Create New Password"
actionUrl={url}
outro="For security, only use links from emails sent by Reactive Resume."
/>
);
}
interface VerifyEmailProps {
url: string;
}
export function VerifyEmail({ url }: VerifyEmailProps) {
return (
<AuthEmailLayout
preview={`Verify your email for ${appName}`}
heading="Verify Email"
intro={`Thanks for signing up for ${appName}. Please verify your email address to continue.`}
details="Verification helps us protect your account and keep your sign-in secure."
actionLabel="Verify Email"
actionUrl={url}
outro="If you did not create this account, you can safely ignore this email."
/>
);
}
interface VerifyEmailChangeProps {
url: string;
previousEmail: string;
newEmail: string;
}
export function VerifyEmailChange({ url, previousEmail, newEmail }: VerifyEmailChangeProps) {
return (
<AuthEmailLayout
preview={`Confirm your new ${appName} email address`}
heading="Confirm Email Change"
intro={`You requested to change your ${appName} email from ${previousEmail} to ${newEmail}.`}
details="Confirm this change to complete the update and keep your account access uninterrupted."
actionLabel="Verify New Email"
actionUrl={url}
outro="If you did not request this change, ignore this email and secure your account."
/>
);
}
@@ -0,0 +1,15 @@
import { ResetPasswordEmail } from "./auth";
interface ResetPasswordTemplateProps {
url: string;
}
const ResetPasswordTemplate = ({ url }: ResetPasswordTemplateProps) => {
return <ResetPasswordEmail url={url} />;
};
export default Object.assign(ResetPasswordTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/reset-password?token=example-token",
} satisfies ResetPasswordTemplateProps,
});
@@ -0,0 +1,19 @@
import { VerifyEmailChange } from "./auth";
interface VerifyEmailChangeTemplateProps {
url: string;
previousEmail: string;
newEmail: string;
}
const VerifyEmailChangeTemplate = ({ url, previousEmail, newEmail }: VerifyEmailChangeTemplateProps) => {
return <VerifyEmailChange url={url} previousEmail={previousEmail} newEmail={newEmail} />;
};
export default Object.assign(VerifyEmailChangeTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/verify-email-change?token=example-token",
previousEmail: "old@example.com",
newEmail: "new@example.com",
} satisfies VerifyEmailChangeTemplateProps,
});
@@ -0,0 +1,15 @@
import { VerifyEmail } from "./auth";
interface VerifyEmailTemplateProps {
url: string;
}
const VerifyEmailTemplate = ({ url }: VerifyEmailTemplateProps) => {
return <VerifyEmail url={url} />;
};
export default Object.assign(VerifyEmailTemplate, {
PreviewProps: {
url: "https://localhost:3000/auth/verify-email?token=example-token",
} satisfies VerifyEmailTemplateProps,
});
+73
View File
@@ -0,0 +1,73 @@
import type { ReactElement } from "react";
import nodemailer, { type SendMailOptions, type Transporter } from "nodemailer";
import { render } from "react-email";
import { env } from "@reactive-resume/env/server";
type SendEmailOptions = {
to: string | string[];
subject: string;
text?: string;
html?: string;
react?: ReactElement;
from?: string;
};
const isSmtpEnabled = () => {
return !!env.SMTP_HOST && !!env.SMTP_USER && !!env.SMTP_PASS && !!env.SMTP_FROM;
};
let cachedTransport: Transporter | undefined;
const getTransport = () => {
if (!isSmtpEnabled()) return;
if (cachedTransport) return cachedTransport;
cachedTransport = nodemailer.createTransport({
host: env.SMTP_HOST,
port: env.SMTP_PORT,
secure: env.SMTP_SECURE,
auth: {
// biome-ignore lint/style/noNonNullAssertion: guarded by isSmtpEnabled
user: env.SMTP_USER!,
// biome-ignore lint/style/noNonNullAssertion: guarded by isSmtpEnabled
pass: env.SMTP_PASS!,
},
});
return cachedTransport;
};
export const sendEmail = async (options: SendEmailOptions) => {
const transport = getTransport();
const from = options.from ?? env.SMTP_FROM ?? "Reactive Resume <noreply@localhost>";
const payload: SendMailOptions = {
to: options.to,
from,
subject: options.subject,
text: options.text,
html: options.html,
};
if (options.react) {
payload.html = await render(options.react);
payload.text = options.text ?? (await render(options.react, { plainText: true }));
}
if (!payload.text && !payload.html) return;
if (!transport) {
console.info("SMTP not configured; skipping email send.", {
to: payload.to,
subject: payload.subject,
text: payload.text,
html: payload.html,
});
return;
}
try {
await transport.sendMail(payload);
} catch (error) {
console.error("There was an error sending mail.", error);
}
};
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ESNext", "DOM"]
}
}
+8
View File
@@ -0,0 +1,8 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/email",
dirname: fileURLToPath(new URL(".", import.meta.url)),
environment: "jsdom",
});
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@reactive-resume/env",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./server": "./src/server.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@t3-oss/env-core": "^0.13.11",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@types/node": "^25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+71
View File
@@ -0,0 +1,71 @@
import { createEnv } from "@t3-oss/env-core";
import { z } from "zod";
export const env = createEnv({
server: {
// Application
APP_URL: z.url({ protocol: /https?/ }),
// Database
DATABASE_URL: z.url({ protocol: /postgres(ql)?/ }),
// Authentication
AUTH_SECRET: z.string().min(1),
BETTER_AUTH_API_KEY: z.string().min(1).optional(),
// Social Auth (Google)
GOOGLE_CLIENT_ID: z.string().min(1).optional(),
GOOGLE_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (GitHub)
GITHUB_CLIENT_ID: z.string().min(1).optional(),
GITHUB_CLIENT_SECRET: z.string().min(1).optional(),
// Social Auth (LinkedIn)
LINKEDIN_CLIENT_ID: z.string().min(1).optional(),
LINKEDIN_CLIENT_SECRET: z.string().min(1).optional(),
// Custom OAuth Provider
OAUTH_PROVIDER_NAME: z.string().min(1).optional(),
OAUTH_CLIENT_ID: z.string().min(1).optional(),
OAUTH_CLIENT_SECRET: z.string().min(1).optional(),
OAUTH_DISCOVERY_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_AUTHORIZATION_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_TOKEN_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_USER_INFO_URL: z.url({ protocol: /https?/ }).optional(),
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS: z.string().optional(),
OAUTH_SCOPES: z
.string()
.min(1)
.transform((value) => value.split(" "))
.default(["openid", "profile", "email"]),
// Email (SMTP)
SMTP_HOST: z.string().min(1).optional(),
SMTP_PORT: z.coerce.number().int().min(1).max(65535).default(587),
SMTP_USER: z.string().min(1).optional(),
SMTP_PASS: z.string().min(1).optional(),
SMTP_FROM: z.string().min(1).optional(),
SMTP_SECURE: z.stringbool().default(false),
// Storage (Optional)
S3_ACCESS_KEY_ID: z.string().min(1).optional(),
S3_SECRET_ACCESS_KEY: z.string().min(1).optional(),
S3_REGION: z.string().default("us-east-1"),
S3_ENDPOINT: z.url({ protocol: /https?/ }).optional(),
S3_BUCKET: z.string().min(1).optional(),
S3_FORCE_PATH_STYLE: z.stringbool().default(false),
// Feature Flags
FLAG_DISABLE_SIGNUPS: z.stringbool().default(false),
FLAG_DISABLE_EMAIL_AUTH: z.stringbool().default(false),
FLAG_DISABLE_IMAGE_PROCESSING: z.stringbool().default(false),
// Crowdin (optional, for translation tooling)
CROWDIN_PROJECT_ID: z.string().optional(),
CROWDIN_API_TOKEN: z.string().optional(),
GOOGLE_CLOUD_API_KEY: z.string().optional(),
},
runtimeEnv: process.env,
emptyStringAsUndefined: true,
});
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json"
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/env",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@reactive-resume/fonts",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
".": "./src/index.ts",
"./webfontlist.json": "./src/webfontlist.json"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+221
View File
@@ -0,0 +1,221 @@
import webFontListJSON from "./webfontlist.json";
export type FontCategory = "display" | "handwriting" | "monospace" | "serif" | "sans-serif";
export type FontWeight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
export type FontFileWeight = FontWeight | `${FontWeight}italic`;
export type StandardFont = {
type: "standard";
category: FontCategory;
family: string;
weights: FontWeight[];
};
export type WebFont = {
type: "web";
category: FontCategory;
family: string;
weights: FontWeight[];
preview: string;
files: Record<FontFileWeight, string>;
};
export type FontRecord = StandardFont | WebFont;
const preferredChineseFontFamilies = [
"Noto Sans SC",
"Noto Serif SC",
"PingFang SC",
"Microsoft YaHei",
"Source Han Sans SC",
"Source Han Serif SC",
"Songti SC",
"SimSun",
"SimHei",
"KaiTi",
"FangSong",
"ZCOOL QingKe HuangYou",
] as const;
const standardPdfFontList = [
{ type: "standard", category: "sans-serif", family: "Helvetica", weights: ["400", "700"] },
{ type: "standard", category: "monospace", family: "Courier", weights: ["400", "700"] },
{ type: "standard", category: "serif", family: "Times-Roman", weights: ["400", "700"] },
] satisfies StandardFont[];
const fontDisplayNames: Partial<Record<string, string>> = {
FangSong: "仿宋",
"Hiragino Sans GB": "冬青黑体简体中文",
KaiTi: "楷体",
"Microsoft YaHei": "微软雅黑",
"Noto Sans SC": "思源黑体",
"Noto Sans TC": "思源黑体(繁中)",
"Noto Serif SC": "思源宋体",
"Noto Serif TC": "思源宋体(繁中)",
"PingFang SC": "苹方",
SimHei: "黑体",
SimSun: "宋体",
"Songti SC": "华文宋体",
"Source Han Sans SC": "思源黑体(本地)",
"Source Han Serif SC": "思源宋体(本地)",
"ZCOOL QingKe HuangYou": "站酷庆科黄油体",
};
const resumeCjkSansFontFallbacks = [
"Noto Sans SC",
"PingFang SC",
"Hiragino Sans GB",
"Microsoft YaHei",
"SimHei",
"Source Han Sans SC",
"WenQuanYi Micro Hei",
] as const;
const resumeCjkSerifFontFallbacks = [
"Noto Serif SC",
"Songti SC",
"SimSun",
"Source Han Serif SC",
"KaiTi",
"FangSong",
] as const;
const genericFontFamilies = new Set([
"-apple-system",
"BlinkMacSystemFont",
"cursive",
"emoji",
"fantasy",
"fangsong",
"math",
"monospace",
"sans-serif",
"serif",
"system-ui",
"ui-monospace",
"ui-rounded",
"ui-sans-serif",
"ui-serif",
]);
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 fontMap = new Map<string, FontRecord>();
const chinesePrioritySet = new Set<string>(preferredChineseFontFamilies);
function unique<T>(items: T[]) {
return items.filter((item, index) => items.indexOf(item) === index);
}
function orderFonts(fonts: FontRecord[]) {
return [...fonts].sort((a, b) => {
const aLabel = getFontDisplayName(a.family);
const bLabel = getFontDisplayName(b.family);
const labelComparison = aLabel.localeCompare(bLabel, undefined, { sensitivity: "base" });
if (labelComparison !== 0) return labelComparison;
return a.family.localeCompare(b.family, undefined, { sensitivity: "base" });
});
}
function toCSSFontFamilyToken(fontFamily: string) {
if (genericFontFamilies.has(fontFamily)) return fontFamily;
return `'${fontFamily.replaceAll("\\", "\\\\").replaceAll("'", "\\'")}'`;
}
export const fontList = orderFonts([...standardFontList, ...webFontList]);
for (const font of fontList) {
fontMap.set(font.family, font);
}
export function getFont(family: string) {
return fontMap.get(family);
}
function getFontCategory(family: string): FontCategory | null {
return getFont(family)?.category ?? null;
}
export function getFontDisplayName(family: string) {
return fontDisplayNames[family] ?? family;
}
export function getFontSearchKeywords(family: string) {
return unique(
[family, fontDisplayNames[family], chinesePrioritySet.has(family) ? "中文" : undefined].filter(
(keyword): keyword is string => Boolean(keyword),
),
);
}
function getCjkFallbacksByCategory(category: FontCategory | null) {
return category === "serif" ? resumeCjkSerifFontFallbacks : resumeCjkSansFontFallbacks;
}
function getPrimaryCjkWebFont(family: string) {
const category = getFontCategory(family);
return category === "serif" ? "Noto Serif SC" : "Noto Sans SC";
}
export function isStandardPdfFontFamily(family: string) {
return standardFontList.some((font) => font.family === family);
}
export function getWebFont(family: string) {
return webFontMap.get(family);
}
export function getWebFontSource(family: string, weight: FontWeight = "400", italic = false) {
const webFont = getWebFont(family);
if (!webFont) return null;
const key = `${weight}${italic ? "italic" : ""}` as FontFileWeight;
return webFont.files[key] ?? webFont.preview;
}
export function getFallbackWebFontFamilies(family: string) {
if (isStandardPdfFontFamily(family)) return [];
const fallback = getPrimaryCjkWebFont(family);
return fallback === family ? [] : [fallback];
}
export function getLoadableWebFontWeights(family: string, preferredWeights: string[]) {
const font = webFontMap.get(family);
if (!font) return [];
const availableWeights = new Set<FontWeight>(font.weights);
const matchingWeights = unique(preferredWeights).filter((weight): weight is FontWeight =>
availableWeights.has(weight as FontWeight),
);
if (matchingWeights.length > 0) return matchingWeights;
const defaultWeights = ["400", "500", "600", "700"].filter((weight): weight is FontWeight =>
availableWeights.has(weight as FontWeight),
);
if (defaultWeights.length > 0) return defaultWeights.slice(0, 2);
return font.weights.slice(0, 2);
}
export function buildResumeFontFamily(fontFamily: string) {
const category = getFontCategory(fontFamily);
const genericFallback = category === "serif" ? "serif" : "sans-serif";
return unique([
fontFamily,
...getCjkFallbacksByCategory(category),
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
genericFallback,
])
.map(toCSSFontFamilyToken)
.join(", ");
}
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json"
}
+7
View File
@@ -0,0 +1,7 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/fonts",
dirname: fileURLToPath(new URL(".", import.meta.url)),
});
+28
View File
@@ -0,0 +1,28 @@
{
"name": "@reactive-resume/import",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./json-resume": "./src/json-resume.tsx",
"./reactive-resume-json": "./src/reactive-resume-json.tsx",
"./reactive-resume-v4-json": "./src/reactive-resume-v4-json.tsx"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"zod": "^4.4.3"
},
"devDependencies": {
"@reactive-resume/config": "workspace:*",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+450
View File
@@ -0,0 +1,450 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { flattenError, ZodError, z } from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
import { formatPeriod, formatSingleDate } from "@reactive-resume/utils/date";
import { arrayToHtmlList, toHtmlDescription } from "@reactive-resume/utils/html";
import { parseLevel } from "@reactive-resume/utils/level";
import { getNetworkIcon } from "@reactive-resume/utils/network-icons";
import { generateId } from "@reactive-resume/utils/string";
import { createUrl } from "@reactive-resume/utils/url";
const createItemWebsite = (url?: string, label?: string) => ({
...createUrl(url, label),
inlineLink: false,
});
// Custom ISO 8601 date pattern that allows partial dates (year only, year-month, or full date)
const iso8601 = z
.string()
.regex(
/^([1-2][0-9]{3}-[0-1][0-9]-[0-3][0-9]|[1-2][0-9]{3}-[0-1][0-9]|[1-2][0-9]{3})$/,
"Must be a valid ISO 8601 date (YYYY, YYYY-MM, or YYYY-MM-DD)",
);
const locationSchema = z.looseObject({
address: z.string().optional(),
postalCode: z.string().optional(),
city: z.string().optional(),
countryCode: z.string().optional(),
region: z.string().optional(),
});
const profileSchema = z.looseObject({
network: z.string().optional(),
username: z.string().optional(),
url: z.url().optional(),
});
const basicsSchema = z.looseObject({
name: z.string().optional(),
label: z.string().optional(),
image: z.string().optional(),
email: z.email().optional(),
phone: z.string().optional(),
url: z.url().optional(),
summary: z.string().optional(),
location: locationSchema.optional(),
profiles: z.array(profileSchema).optional(),
});
const workSchema = z.looseObject({
name: z.string().optional(),
location: z.string().optional(),
description: z.string().optional(),
position: z.string().optional(),
url: z.url().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
summary: z.string().optional(),
highlights: z.array(z.string()).optional(),
});
const volunteerSchema = z.looseObject({
organization: z.string().optional(),
position: z.string().optional(),
url: z.url().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
summary: z.string().optional(),
highlights: z.array(z.string()).optional(),
});
const educationSchema = z.looseObject({
institution: z.string().optional(),
url: z.url().optional(),
area: z.string().optional(),
studyType: z.string().optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
score: z.string().optional(),
courses: z.array(z.string()).optional(),
});
const awardSchema = z.looseObject({
title: z.string().optional(),
date: iso8601.optional(),
awarder: z.string().optional(),
summary: z.string().optional(),
});
const certificateSchema = z.looseObject({
name: z.string().optional(),
date: iso8601.optional(),
url: z.url().optional(),
issuer: z.string().optional(),
});
const publicationSchema = z.looseObject({
name: z.string().optional(),
publisher: z.string().optional(),
releaseDate: iso8601.optional(),
url: z.url().optional(),
summary: z.string().optional(),
});
const skillSchema = z.looseObject({
name: z.string().optional(),
level: z.string().optional(),
keywords: z.array(z.string()).optional(),
});
const languageSchema = z.looseObject({
language: z.string().optional(),
fluency: z.string().optional(),
});
const interestSchema = z.looseObject({
name: z.string().optional(),
keywords: z.array(z.string()).optional(),
});
const referenceSchema = z.looseObject({
name: z.string().optional(),
reference: z.string().optional(),
});
const projectSchema = z.looseObject({
name: z.string().optional(),
description: z.string().optional(),
highlights: z.array(z.string()).optional(),
keywords: z.array(z.string()).optional(),
startDate: iso8601.optional(),
endDate: iso8601.optional(),
url: z.url().optional(),
roles: z.array(z.string()).optional(),
entity: z.string().optional(),
type: z.string().optional(),
});
const metaSchema = z.looseObject({
canonical: z.url().optional(),
version: z.string().optional(),
lastModified: z.string().optional(),
});
const jsonResumeSchema = z.looseObject({
$schema: z.url().optional(),
basics: basicsSchema.optional(),
work: z.array(workSchema).optional(),
volunteer: z.array(volunteerSchema).optional(),
education: z.array(educationSchema).optional(),
awards: z.array(awardSchema).optional(),
certificates: z.array(certificateSchema).optional(),
publications: z.array(publicationSchema).optional(),
skills: z.array(skillSchema).optional(),
languages: z.array(languageSchema).optional(),
interests: z.array(interestSchema).optional(),
references: z.array(referenceSchema).optional(),
projects: z.array(projectSchema).optional(),
meta: metaSchema.optional(),
});
type JSONResume = z.infer<typeof jsonResumeSchema>;
type JSONResumeLocation = z.infer<typeof locationSchema>;
// Helper function to format location object to string
function formatLocation(location?: JSONResumeLocation): string {
if (!location) return "";
const parts: string[] = [];
if (location.city) parts.push(location.city);
if (location.region) parts.push(location.region);
if (location.countryCode) parts.push(location.countryCode);
return parts.join(", ");
}
export class JSONResumeImporter {
convert(jsonResume: JSONResume): ResumeData {
const result: ResumeData = {
...defaultResumeData,
};
// Map basics
if (jsonResume.basics) {
const basics = jsonResume.basics;
result.basics = {
name: basics.name || "",
headline: basics.label || "",
email: basics.email || "",
phone: basics.phone || "",
location: basics.location ? formatLocation(basics.location) : "",
website: createUrl(basics.url),
customFields: [],
};
// Map image to picture
if (basics.image) {
result.picture = {
...defaultResumeData.picture,
url: basics.image,
hidden: false,
};
}
}
// Map summary
if (jsonResume.basics?.summary) {
result.summary = {
...defaultResumeData.summary,
content: `<p>${jsonResume.basics.summary}</p>`,
hidden: false,
};
}
// Map work to experience
if (jsonResume.work && jsonResume.work.length > 0) {
result.sections.experience = {
...defaultResumeData.sections.experience,
items: jsonResume.work
.filter((work) => work.name || work.position)
.map((work) => ({
id: generateId(),
hidden: false,
company: work.name || "",
position: work.position || "",
location: work.location || "",
period: formatPeriod(work.startDate, work.endDate),
website: createItemWebsite(work.url),
roles: [],
description: toHtmlDescription(work.summary, work.highlights),
})),
};
}
// Map education
if (jsonResume.education && jsonResume.education.length > 0) {
result.sections.education = {
...defaultResumeData.sections.education,
items: jsonResume.education
.filter((edu) => edu.institution)
.map((edu) => ({
id: generateId(),
hidden: false,
school: edu.institution || "",
degree: [edu.studyType, edu.area].filter(Boolean).join(" in ") || "",
area: edu.area || "",
grade: edu.score || "",
location: "",
period: formatPeriod(edu.startDate, edu.endDate),
website: createItemWebsite(edu.url),
description: edu.courses && edu.courses.length > 0 ? arrayToHtmlList(edu.courses) : "",
})),
};
}
// Map projects
if (jsonResume.projects && jsonResume.projects.length > 0) {
result.sections.projects = {
...defaultResumeData.sections.projects,
items: jsonResume.projects
.filter((project) => project.name)
.map((project) => ({
id: generateId(),
hidden: false,
name: project.name || "",
period: formatPeriod(project.startDate, project.endDate),
website: createItemWebsite(project.url),
description: toHtmlDescription(project.description, project.highlights),
})),
};
}
// Map skills
if (jsonResume.skills && jsonResume.skills.length > 0) {
result.sections.skills = {
...defaultResumeData.sections.skills,
items: jsonResume.skills
.filter((skill) => skill.name)
.map((skill) => ({
id: generateId(),
hidden: false,
icon: "star",
iconColor: "",
name: skill.name || "",
proficiency: skill.level || "",
level: parseLevel(skill.level),
keywords: skill.keywords || [],
})),
};
}
// Map languages
if (jsonResume.languages && jsonResume.languages.length > 0) {
result.sections.languages = {
...defaultResumeData.sections.languages,
items: jsonResume.languages
.filter((lang) => lang.language)
.map((lang) => ({
id: generateId(),
hidden: false,
language: lang.language || "",
fluency: lang.fluency || "",
level: parseLevel(lang.fluency),
})),
};
}
// Map interests
if (jsonResume.interests && jsonResume.interests.length > 0) {
result.sections.interests = {
...defaultResumeData.sections.interests,
items: jsonResume.interests
.filter((interest) => interest.name)
.map((interest) => ({
id: generateId(),
hidden: false,
icon: "star",
iconColor: "",
name: interest.name || "",
keywords: interest.keywords || [],
})),
};
}
// Map awards
if (jsonResume.awards && jsonResume.awards.length > 0) {
result.sections.awards = {
...defaultResumeData.sections.awards,
items: jsonResume.awards
.filter((award) => award.title)
.map((award) => ({
id: generateId(),
hidden: false,
title: award.title || "",
awarder: award.awarder || "",
date: formatSingleDate(award.date),
website: createItemWebsite(),
description: award.summary ? `<p>${award.summary}</p>` : "",
})),
};
}
// Map certificates
if (jsonResume.certificates && jsonResume.certificates.length > 0) {
result.sections.certifications = {
...defaultResumeData.sections.certifications,
items: jsonResume.certificates
.filter((cert) => cert.name)
.map((cert) => ({
id: generateId(),
hidden: false,
title: cert.name || "",
issuer: cert.issuer || "",
date: formatSingleDate(cert.date),
website: createItemWebsite(cert.url),
description: "",
})),
};
}
// Map publications
if (jsonResume.publications && jsonResume.publications.length > 0) {
result.sections.publications = {
...defaultResumeData.sections.publications,
items: jsonResume.publications
.filter((pub) => pub.name)
.map((pub) => ({
id: generateId(),
hidden: false,
title: pub.name || "",
publisher: pub.publisher || "",
date: formatSingleDate(pub.releaseDate),
website: createItemWebsite(pub.url),
description: pub.summary ? `<p>${pub.summary}</p>` : "",
})),
};
}
// Map volunteer
if (jsonResume.volunteer && jsonResume.volunteer.length > 0) {
result.sections.volunteer = {
...defaultResumeData.sections.volunteer,
items: jsonResume.volunteer
.filter((vol) => vol.organization)
.map((vol) => ({
id: generateId(),
hidden: false,
organization: vol.organization || "",
location: "",
period: formatPeriod(vol.startDate, vol.endDate),
website: createItemWebsite(vol.url),
description: toHtmlDescription(vol.summary, vol.highlights),
})),
};
}
// Map references
if (jsonResume.references && jsonResume.references.length > 0) {
result.sections.references = {
...defaultResumeData.sections.references,
items: jsonResume.references
.filter((ref) => ref.name || ref.reference)
.map((ref) => ({
id: generateId(),
hidden: false,
name: ref.name || "",
position: "",
website: createItemWebsite(),
phone: "",
description: ref.reference ? `<p>${ref.reference}</p>` : "",
})),
};
}
// Map profiles (from basics.profiles) to profiles section
if (jsonResume.basics?.profiles && jsonResume.basics.profiles.length > 0) {
result.sections.profiles = {
...defaultResumeData.sections.profiles,
items: jsonResume.basics.profiles
.filter((profile) => profile.network)
.map((profile) => ({
id: generateId(),
hidden: false,
icon: getNetworkIcon(profile.network),
iconColor: "",
network: profile.network || "",
username: profile.username || "",
website: createItemWebsite(profile.url, profile.username || profile.network),
})),
};
}
return resumeDataSchema.parse(result);
}
parse(json: string): ResumeData {
try {
const jsonResume = jsonResumeSchema.parse(JSON.parse(json));
return this.convert(jsonResume);
} catch (error) {
if (error instanceof ZodError) {
const errors = flattenError(error);
throw new Error(JSON.stringify(errors));
}
throw error;
}
}
}
@@ -0,0 +1,79 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import { flattenError, ZodError } from "zod";
import { resumeDataSchema, sectionTypeSchema } from "@reactive-resume/schema/resume/data";
const BUILT_IN_LAYOUT_SECTION_IDS = sectionTypeSchema.options.filter((section) => section !== "cover-letter");
function normalizeBuiltInSectionsInLayout(data: ResumeData): ResumeData {
const pages = data.metadata.layout.pages;
// Some exported resumes can arrive without any persisted layout pages.
if (pages.length === 0) {
return {
...data,
metadata: {
...data.metadata,
layout: {
...data.metadata.layout,
pages: [
{
fullWidth: false,
main: [...BUILT_IN_LAYOUT_SECTION_IDS],
sidebar: [],
},
],
},
},
};
}
const existingSectionIds = new Set<string>();
// Preserve the imported layout and only compute which built-in IDs are missing entirely.
for (const page of pages) {
for (const sectionId of page.main) existingSectionIds.add(sectionId);
for (const sectionId of page.sidebar) existingSectionIds.add(sectionId);
}
const missingSectionIds = BUILT_IN_LAYOUT_SECTION_IDS.filter((sectionId) => !existingSectionIds.has(sectionId));
if (missingSectionIds.length === 0) return data;
const [firstPage, ...restPages] = pages;
if (!firstPage) return data;
return {
...data,
metadata: {
...data.metadata,
layout: {
...data.metadata.layout,
pages: [
{
fullWidth: firstPage.fullWidth ?? false,
sidebar: firstPage.sidebar ?? [],
// Recover missing built-in sections without reordering the imported layout.
main: [...firstPage.main, ...missingSectionIds],
},
...restPages,
],
},
},
};
}
export class ReactiveResumeJSONImporter {
parse(json: string): ResumeData {
try {
const parsed = resumeDataSchema.parse(JSON.parse(json));
return resumeDataSchema.parse(normalizeBuiltInSectionsInLayout(parsed));
} catch (error) {
if (error instanceof ZodError) {
const errors = flattenError(error);
throw new Error(JSON.stringify(errors));
}
throw error;
}
}
}
@@ -0,0 +1,780 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import z, { flattenError, ZodError } from "zod";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { templateSchema } from "@reactive-resume/schema/templates";
import { parseColorString } from "@reactive-resume/utils/color";
import { generateId } from "@reactive-resume/utils/string";
function colorToRgba(color: string): string {
const parsed = parseColorString(color);
if (!parsed) return "rgba(0, 0, 0, 1)";
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${parsed.a})`;
}
const clamp = (value: number, min: number, max: number): number => Math.max(min, Math.min(max, value));
const nonNegative = (value: number): number => Math.max(0, value);
const pxToPt = (px: number): number => px * 0.75;
const clampPictureSize = (size: number): number => clamp(size, 32, 512);
const clampRotation = (rotation: number): number => clamp(rotation, 0, 360);
const clampAspectRatio = (ratio: number): number => clamp(ratio, 0.5, 2.5);
const clampBorderRadius = (radius: number): number => clamp(radius, 0, 100);
const clampFontSize = (size: number): number => clamp(size, 6, 24);
const clampLineHeight = (height: number): number => clamp(height, 0.5, 4);
const clampSidebarWidth = (width: number): number => clamp(width, 10, 50);
const clampLevel = (level: number): number => clamp(level, 0, 5);
const convertAndClampFontSize = (px: number): number => clampFontSize(pxToPt(px));
const isValidEmail = (email: string): boolean => {
if (!email) return false;
return z.email().safeParse(email).success;
};
const sanitizeEmail = (email: string | undefined): string => {
if (!email) return "";
return isValidEmail(email) ? email : "";
};
type FontWeight = "100" | "200" | "300" | "400" | "500" | "600" | "700" | "800" | "900";
const FONT_WEIGHT_MAP: Record<string, FontWeight> = {
regular: "400",
italic: "400",
"100": "100",
"200": "200",
"300": "300",
"400": "400",
"500": "500",
"600": "600",
"700": "700",
"800": "800",
"900": "900",
bold: "700",
"bold-italic": "700",
};
const convertFontVariantToWeight = (variant: string, defaultWeight: FontWeight = "400"): FontWeight =>
FONT_WEIGHT_MAP[variant.toLowerCase()] ?? defaultWeight;
const convertFontVariants = (variants: string[] | undefined, defaultWeight: FontWeight = "400"): FontWeight[] => {
if (!variants || variants.length === 0) return [defaultWeight];
return variants.map((v) => convertFontVariantToWeight(v, defaultWeight));
};
const convertFontVariantsForHeading = (variants: string[] | undefined): FontWeight[] => {
const weights = convertFontVariants(variants, "600");
const filtered = weights.filter((w) => Number.parseInt(w, 10) >= 600);
return filtered.length > 0 ? filtered : ["600"];
};
type V4ResumeData = {
basics: {
name: string;
headline: string;
email: string;
phone: string;
location: string;
url: {
label: string;
href: string;
};
customFields: Array<{
id?: string;
icon?: string;
text?: string;
}>;
picture: {
url: string;
size: number;
aspectRatio: number;
borderRadius: number;
effects: {
hidden: boolean;
border: boolean;
grayscale: boolean;
};
};
};
sections: {
summary: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
content: string;
};
awards: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
title?: string;
awarder?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
certifications: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
issuer?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
education: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
institution?: string;
studyType?: string;
area?: string;
score?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
experience: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
company?: string;
position?: string;
location?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
volunteer: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
organization?: string;
position?: string;
location?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
interests: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
keywords?: string[];
}>;
};
languages: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
level?: number;
}>;
};
profiles: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
network?: string;
username?: string;
icon?: string;
url?: {
label: string;
href: string;
};
}>;
};
projects: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
date?: string;
summary?: string;
keywords?: string[];
url?: {
label: string;
href: string;
};
}>;
};
publications: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
publisher?: string;
date?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
references: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
summary?: string;
url?: {
label: string;
href: string;
};
}>;
};
skills: {
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
level?: number;
keywords?: string[];
}>;
};
custom?: Record<
string,
{
name: string;
columns: number;
separateLinks: boolean;
visible: boolean;
id: string;
items: Array<{
id: string;
visible: boolean;
name?: string;
description?: string;
date?: string;
location?: string;
summary?: string;
keywords?: string[];
url?: {
label: string;
href: string;
};
}>;
}
>;
};
metadata: {
template: string;
layout: Array<Array<string[]>>;
css: {
value: string;
visible: boolean;
};
page: {
margin: number;
format: "a4" | "letter";
options: {
breakLine: boolean;
pageNumbers: boolean;
};
};
theme: {
background: string;
text: string;
primary: string;
};
typography: {
font: {
family: string;
subset: string;
variants: string[];
size: number;
};
lineHeight: number;
hideIcons: boolean;
underlineLinks: boolean;
};
notes: string;
};
};
// Transform layout section ID from V4 format to new format
// V4 uses "custom.{id}" for custom sections, new format just uses "{id}"
const transformLayoutSectionId = (id: string): string => {
if (id.startsWith("custom.")) return id.slice(7);
return id;
};
// Transform layout column by stripping "custom." prefix from section IDs
const transformLayoutColumn = (column: string[]): string[] => {
return column
.filter((id) => id !== "summary") // Summary is handled separately
.map(transformLayoutSectionId);
};
export class ReactiveResumeV4JSONImporter {
parse(json: string): ResumeData {
try {
const v4Data = JSON.parse(json) as V4ResumeData;
const transformed: ResumeData = {
picture: {
hidden: v4Data.basics.picture?.effects?.hidden ?? false,
url: v4Data.basics.picture?.url ?? "",
size: clampPictureSize(v4Data.basics.picture?.size ?? 80),
rotation: clampRotation(0),
aspectRatio: clampAspectRatio(v4Data.basics.picture?.aspectRatio ?? 1),
borderRadius: clampBorderRadius(v4Data.basics.picture?.borderRadius ?? 0),
borderColor: v4Data.basics.picture?.effects?.border ? "rgba(0, 0, 0, 0.5)" : "rgba(0, 0, 0, 0)",
borderWidth: nonNegative(v4Data.basics.picture?.effects?.border ? 1 : 0),
shadowColor: "rgba(0, 0, 0, 0.5)",
shadowWidth: nonNegative(0),
},
basics: {
name: v4Data.basics.name ?? "",
headline: v4Data.basics.headline ?? "",
email: sanitizeEmail(v4Data.basics.email),
phone: v4Data.basics.phone ?? "",
location: v4Data.basics.location ?? "",
website: {
url: v4Data.basics.url?.href ?? "",
label: v4Data.basics.url?.label ?? "",
},
customFields: (v4Data.basics.customFields ?? []).map((field) => ({
id: field.id ?? generateId(),
icon: field.icon ?? "",
text: field.text ?? "",
link: "",
})),
},
summary: {
title: v4Data.sections.summary?.name ?? "",
columns: v4Data.sections.summary?.columns ?? 1,
hidden: !(v4Data.sections.summary?.visible ?? true),
content: v4Data.sections.summary?.content ?? "",
},
sections: {
profiles: {
title: v4Data.sections.profiles?.name ?? "",
columns: v4Data.sections.profiles?.columns ?? 1,
hidden: !(v4Data.sections.profiles?.visible ?? true),
items: (v4Data.sections.profiles?.items ?? [])
.filter((item) => item.network && item.network.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
icon: item.icon ?? "",
iconColor: "",
network: item.network ?? "",
username: item.username ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
})),
},
experience: {
title: v4Data.sections.experience?.name ?? "",
columns: v4Data.sections.experience?.columns ?? 1,
hidden: !(v4Data.sections.experience?.visible ?? true),
items: (v4Data.sections.experience?.items ?? [])
.filter((item) => item.company && item.company.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
company: item.company ?? "",
position: item.position ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
roles: [],
description: item.summary ?? "",
})),
},
education: {
title: v4Data.sections.education?.name ?? "",
columns: v4Data.sections.education?.columns ?? 1,
hidden: !(v4Data.sections.education?.visible ?? true),
items: (v4Data.sections.education?.items ?? [])
.filter((item) => item.institution && item.institution.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
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,
},
description: item.summary ?? "",
})),
},
projects: {
title: v4Data.sections.projects?.name ?? "",
columns: v4Data.sections.projects?.columns ?? 1,
hidden: !(v4Data.sections.projects?.visible ?? true),
items: (v4Data.sections.projects?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
name: item.name ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? item.description ?? "",
})),
},
skills: {
title: v4Data.sections.skills?.name ?? "",
columns: v4Data.sections.skills?.columns ?? 1,
hidden: !(v4Data.sections.skills?.visible ?? true),
items: (v4Data.sections.skills?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
icon: "",
iconColor: "",
name: item.name ?? "",
proficiency: item.description ?? "",
level: clampLevel(item.level ?? 0),
keywords: item.keywords ?? [],
})),
},
languages: {
title: v4Data.sections.languages?.name ?? "",
columns: v4Data.sections.languages?.columns ?? 1,
hidden: !(v4Data.sections.languages?.visible ?? true),
items: (v4Data.sections.languages?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
language: item.name ?? "",
fluency: item.description ?? "",
level: clampLevel(item.level ?? 0),
})),
},
interests: {
title: v4Data.sections.interests?.name ?? "",
columns: v4Data.sections.interests?.columns ?? 1,
hidden: !(v4Data.sections.interests?.visible ?? true),
items: (v4Data.sections.interests?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
icon: "",
iconColor: "",
name: item.name ?? "",
keywords: item.keywords ?? [],
})),
},
awards: {
title: v4Data.sections.awards?.name ?? "",
columns: v4Data.sections.awards?.columns ?? 1,
hidden: !(v4Data.sections.awards?.visible ?? true),
items: (v4Data.sections.awards?.items ?? [])
.filter((item) => item.title && item.title.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
title: item.title ?? "",
awarder: item.awarder ?? "",
date: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? "",
})),
},
certifications: {
title: v4Data.sections.certifications?.name ?? "",
columns: v4Data.sections.certifications?.columns ?? 1,
hidden: !(v4Data.sections.certifications?.visible ?? true),
items: (v4Data.sections.certifications?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
title: item.name ?? "",
issuer: item.issuer ?? "",
date: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? "",
})),
},
publications: {
title: v4Data.sections.publications?.name ?? "",
columns: v4Data.sections.publications?.columns ?? 1,
hidden: !(v4Data.sections.publications?.visible ?? true),
items: (v4Data.sections.publications?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
title: item.name ?? "",
publisher: item.publisher ?? "",
date: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? "",
})),
},
volunteer: {
title: v4Data.sections.volunteer?.name ?? "",
columns: v4Data.sections.volunteer?.columns ?? 1,
hidden: !(v4Data.sections.volunteer?.visible ?? true),
items: (v4Data.sections.volunteer?.items ?? [])
.filter((item) => item.organization && item.organization.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
organization: item.organization ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? "",
})),
},
references: {
title: v4Data.sections.references?.name ?? "",
columns: v4Data.sections.references?.columns ?? 1,
hidden: !(v4Data.sections.references?.visible ?? true),
items: (v4Data.sections.references?.items ?? [])
.filter((item) => item.name && item.name.length > 0)
.map((item) => ({
id: item.id ?? generateId(),
hidden: !(item.visible ?? true),
name: item.name ?? "",
position: item.description ?? "",
phone: "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
description: item.summary ?? "",
})),
},
},
customSections: Object.entries(v4Data.sections.custom ?? {}).map(([sectionId, section]) => ({
id: section.id || sectionId,
title: section.name ?? "",
type: "experience" as const, // Default to experience type as it has the most compatible fields
columns: section.columns ?? 1,
hidden: !(section.visible ?? true),
items: section.items
.filter((item) => item.visible !== false)
.map((item, index) => ({
id: item.id || generateId(),
hidden: !item.visible,
company: item.name?.trim() || `#${index + 1}`,
position: item.description ?? "",
location: item.location ?? "",
period: item.date ?? "",
website: {
url: item.url?.href ?? "",
label: item.url?.label ?? "",
inlineLink: false,
},
roles: [],
description: item.summary ?? "",
})),
})),
metadata: {
template: (templateSchema.safeParse(v4Data.metadata.template).success
? v4Data.metadata.template
: "onyx") as Template,
layout: {
sidebarWidth: clampSidebarWidth(35),
pages: (v4Data.metadata.layout ?? []).map((page) => {
const main = transformLayoutColumn(page[0] ?? []);
const sidebar = transformLayoutColumn(page[1] ?? []);
return {
fullWidth: sidebar.length === 0,
main,
sidebar,
};
}),
},
page: {
gapX: nonNegative(4),
gapY: nonNegative(6),
marginX: nonNegative(v4Data.metadata.page?.margin ?? 14),
marginY: nonNegative(v4Data.metadata.page?.margin ?? 14),
format: v4Data.metadata.page?.format ?? "a4",
locale: "en-US",
hideIcons: v4Data.metadata.typography?.hideIcons ?? false,
},
design: {
colors: {
primary: v4Data.metadata.theme?.primary
? colorToRgba(v4Data.metadata.theme.primary)
: "rgba(220, 38, 38, 1)",
text: v4Data.metadata.theme?.text ? colorToRgba(v4Data.metadata.theme.text) : "rgba(0, 0, 0, 1)",
background: v4Data.metadata.theme?.background
? colorToRgba(v4Data.metadata.theme.background)
: "rgba(255, 255, 255, 1)",
},
level: {
icon: "star",
type: "circle",
},
},
typography: {
body: {
fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
fontWeights: convertFontVariants(v4Data.metadata.typography?.font?.variants),
fontSize: convertAndClampFontSize(v4Data.metadata.typography?.font?.size ?? 14.67),
lineHeight: clampLineHeight(v4Data.metadata.typography?.lineHeight ?? 1.5),
},
heading: {
fontFamily: v4Data.metadata.typography?.font?.family ?? "IBM Plex Serif",
fontWeights: convertFontVariantsForHeading(v4Data.metadata.typography?.font?.variants),
fontSize: clampFontSize(convertAndClampFontSize(v4Data.metadata.typography?.font?.size ?? 14.67) + 3),
lineHeight: clampLineHeight(v4Data.metadata.typography?.lineHeight ?? 1.5),
},
},
notes: v4Data.metadata.notes ?? "",
},
};
if (v4Data.sections.summary?.visible && v4Data.sections.summary?.content) {
const firstPage = transformed.metadata.layout.pages[0];
if (firstPage) {
firstPage.main.unshift("summary");
}
}
return resumeDataSchema.parse(transformed);
} catch (error: unknown) {
if (error instanceof ZodError) {
const errors = flattenError(error);
throw new Error(JSON.stringify(errors));
}
throw error;
}
}
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "@reactive-resume/config/tsconfig.base.json",
"compilerOptions": {
"jsx": "preserve",
"lib": ["ESNext", "DOM"]
}
}
+8
View File
@@ -0,0 +1,8 @@
import { fileURLToPath } from "node:url";
import { createVitestProjectConfig } from "../../vitest.shared";
export default createVitestProjectConfig({
name: "@reactive-resume/import",
dirname: fileURLToPath(new URL(".", import.meta.url)),
environment: "jsdom",
});
+36
View File
@@ -0,0 +1,36 @@
{
"name": "@reactive-resume/pdf",
"version": "0.0.0",
"type": "module",
"private": true,
"exports": {
"./context": "./src/context.tsx",
"./document": "./src/document.tsx",
"./section-title": "./src/section-title.ts",
"./templates": "./src/templates/index.ts"
},
"scripts": {
"typecheck": "tsgo --noEmit",
"test": "vitest run --passWithNoTests",
"test:coverage": "vitest run --coverage --passWithNoTests",
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
},
"dependencies": {
"@react-pdf/renderer": "^4.5.1",
"@reactive-resume/fonts": "workspace:*",
"@reactive-resume/schema": "workspace:*",
"@reactive-resume/utils": "workspace:*",
"phosphor-icons-react-pdf": "^0.1.2",
"react": "^19.2.6",
"react-pdf-html": "^2.1.5",
"ts-pattern": "^5.9.0"
},
"devDependencies": {
"@react-pdf/types": "^2.11.1",
"@reactive-resume/config": "workspace:*",
"@types/react": "^19.2.14",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"typescript": "^6.0.3"
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { ResumeData } from "@reactive-resume/schema/resume/data";
import type { ReactNode } from "react";
import type { SectionTitleResolver } from "./section-title";
import { createContext, useContext } from "react";
type RenderContextValue = ResumeData & {
resolveSectionTitle?: SectionTitleResolver | undefined;
};
const RenderContext = createContext<RenderContextValue | null>(null);
export type RenderProviderProps = {
data: ResumeData;
resolveSectionTitle?: SectionTitleResolver | undefined;
children: ReactNode;
};
export const RenderProvider = ({ data, resolveSectionTitle, children }: RenderProviderProps) => {
return <RenderContext.Provider value={{ ...data, resolveSectionTitle }}>{children}</RenderContext.Provider>;
};
export const useRender = (): RenderContextValue => {
const context = useContext(RenderContext);
if (!context) throw new Error("useRender must be called inside a <RenderProvider>.");
return context;
};
+36
View File
@@ -0,0 +1,36 @@
import type { LayoutPage, ResumeData } from "@reactive-resume/schema/resume/data";
import type { Template } from "@reactive-resume/schema/templates";
import type { ComponentType } from "react";
import type { SectionTitleResolver } from "./section-title";
import { Document } from "@react-pdf/renderer";
import { RenderProvider } from "./context";
import { registerFonts } from "./hooks/use-register-fonts";
import { getTemplatePage } from "./templates";
export type TemplatePageProps = {
page: LayoutPage;
pageIndex: number;
};
export type TemplatePage = ComponentType<TemplatePageProps>;
export type ResumeDocumentProps = {
data: ResumeData;
template: Template;
resolveSectionTitle?: SectionTitleResolver | undefined;
};
export const ResumeDocument = ({ data, template, resolveSectionTitle }: ResumeDocumentProps) => {
const TemplatePageComponent = getTemplatePage(template);
registerFonts(data.metadata.typography);
return (
<RenderProvider data={data} resolveSectionTitle={resolveSectionTitle}>
<Document title={`${data.basics.name} Resume`} author={data.basics.name} subject={data.basics.headline}>
{data.metadata.layout.pages.map((page, index) => (
<TemplatePageComponent key={index} page={page} pageIndex={index} />
))}
</Document>
</RenderProvider>
);
};
@@ -0,0 +1,62 @@
import type { FontWeight } from "@reactive-resume/fonts";
import type { Typography } from "@reactive-resume/schema/resume/data";
import { Font } from "@react-pdf/renderer";
import { getWebFontSource, isStandardPdfFontFamily } from "@reactive-resume/fonts";
type FontWeightRange = {
lowest: number;
highest: number;
};
const registeredFontVariants = new Set<string>();
const getFontWeightRange = (fontWeights: string[]): FontWeightRange => {
const numericWeights = fontWeights.map(Number).filter((weight) => Number.isFinite(weight));
if (numericWeights.length === 0) return { lowest: 400, highest: 700 };
const lowest = Math.min(...numericWeights);
const rawHighest = Math.max(...numericWeights);
const highest = rawHighest <= lowest ? 700 : rawHighest;
return { lowest, highest };
};
const toFontWeight = (weight: number): FontWeight => {
if (weight <= 100) return "100";
if (weight <= 200) return "200";
if (weight <= 300) return "300";
if (weight <= 400) return "400";
if (weight <= 500) return "500";
if (weight <= 600) return "600";
if (weight <= 700) return "700";
if (weight <= 800) return "800";
return "900";
};
export const registerFonts = (typography: Typography) => {
Font.registerHyphenationCallback((word) => [word]);
const bodyFontFamily = typography.body.fontFamily;
const headingFontFamily = typography.heading.fontFamily;
const bodyRange = getFontWeightRange(typography.body.fontWeights);
const headingRange = getFontWeightRange(typography.heading.fontWeights);
const registerFont = (family: string, weight: number) => {
if (isStandardPdfFontFamily(family)) return;
const normalizedWeight = toFontWeight(weight);
const key = `${family}:${normalizedWeight}`;
if (registeredFontVariants.has(key)) return;
const source = getWebFontSource(family, normalizedWeight);
if (!source) return;
Font.register({ family, src: source, fontWeight: Number(normalizedWeight) });
registeredFontVariants.add(key);
};
registerFont(bodyFontFamily, bodyRange.lowest);
registerFont(bodyFontFamily, bodyRange.highest);
registerFont(headingFontFamily, headingRange.lowest);
registerFont(headingFontFamily, headingRange.highest);
};
+100
View File
@@ -0,0 +1,100 @@
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
export type SectionTitleResolverInput = {
sectionId: string;
locale: string;
sectionKind: "summary" | "builtin" | "custom";
customSectionType?: string;
defaultEnglishTitle?: string | undefined;
};
export type SectionTitleResolver = (input: SectionTitleResolverInput) => string;
const defaultEnglishSectionTitles: Record<"summary" | SectionType, string> = {
summary: "Summary",
profiles: "Profiles",
experience: "Experience",
education: "Education",
projects: "Projects",
skills: "Skills",
languages: "Languages",
interests: "Interests",
awards: "Awards",
certifications: "Certifications",
publications: "Publications",
volunteer: "Volunteer",
references: "References",
};
const defaultEnglishCustomSectionTitles: Record<string, string> = {
"cover-letter": "Cover Letter",
};
export const resolveSectionTitle = (
title: string,
input: SectionTitleResolverInput,
resolver?: SectionTitleResolver,
legacyFallback?: string,
) => {
if (title.trim()) return title;
const resolvedTitle = resolver?.(input);
if (resolvedTitle?.trim()) return resolvedTitle;
if (legacyFallback !== undefined) return legacyFallback;
return input.defaultEnglishTitle ?? input.sectionId;
};
type RenderData = ResumeData & {
resolveSectionTitle?: SectionTitleResolver | undefined;
};
export const getResumeSectionTitle = (data: RenderData, sectionId: string, legacyFallback?: string) => {
const locale = data.metadata.page.locale;
if (sectionId === "summary") {
const defaultEnglishTitle = defaultEnglishSectionTitles.summary;
return resolveSectionTitle(
data.summary.title,
{ sectionId, locale, sectionKind: "summary", defaultEnglishTitle },
data.resolveSectionTitle,
legacyFallback,
);
}
if (sectionId in data.sections) {
const sectionType = sectionId as SectionType;
const defaultEnglishTitle = defaultEnglishSectionTitles[sectionType];
return resolveSectionTitle(
data.sections[sectionType].title,
{ sectionId, locale, sectionKind: "builtin", defaultEnglishTitle },
data.resolveSectionTitle,
legacyFallback,
);
}
const customSection = data.customSections.find((section) => section.id === sectionId);
if (customSection) {
const defaultEnglishTitle =
customSection.type in data.sections
? defaultEnglishSectionTitles[customSection.type as SectionType]
: (defaultEnglishCustomSectionTitles[customSection.type] ?? customSection.type);
return resolveSectionTitle(
customSection.title,
{ sectionId, locale, sectionKind: "custom", customSectionType: customSection.type, defaultEnglishTitle },
data.resolveSectionTitle,
legacyFallback,
);
}
return (
data.resolveSectionTitle?.({ sectionId, locale, sectionKind: "custom", defaultEnglishTitle: sectionId }) ??
legacyFallback ??
sectionId
);
};
+4
View File
@@ -0,0 +1,4 @@
declare module "*.svg?raw" {
const content: string;
export default content;
}
@@ -0,0 +1,366 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type {
TemplateColorRoles,
TemplateFeatureStyleSlots,
TemplateFeatures,
TemplateStyleContext,
TemplateStyleSlots,
} from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles, resolvePlacementColor } from "../shared/styles";
type AzurillStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
contentRow: Style;
sidebarColumn: Style;
mainColumn: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerContactRow: Style;
headerContactItem: Style;
};
type AzurillTemplate = {
colors: TemplateColorRoles;
styles: AzurillStyles;
featureStyles: TemplateFeatureStyleSlots;
};
const azurillFeatures = {
sectionTimeline: true,
} satisfies TemplateFeatures;
export const AzurillPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles, featureStyles } = useAzurillTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} featureStyles={featureStyles} colors={colors} features={azurillFeatures}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.contentRow, { columnGap: metrics.columnGap })}>
<View
style={composeStyles(styles.sidebarColumn, {
flexBasis: `${metadata.layout.sidebarWidth}%`,
display: page.fullWidth ? "none" : "flex",
rowGap: metrics.sectionGap,
})}
>
{sidebarSections.map((section, index) => (
<Fragment key={index}>
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: AzurillStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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>
<View style={styles.headerContactRow}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.headerContactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.headerContactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
);
};
const useAzurillTemplate = (): AzurillTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "column",
rowGap: metrics.headerGap,
columnGap: metrics.columnGap,
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 3),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
sectionHeading: {
color: primary,
},
contentRow: {
flexDirection: "row",
},
sidebarColumn: {},
mainColumn: {
flex: 1,
},
header: {
alignItems: "center",
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {
alignItems: "center",
textAlign: "center",
},
headerIdentity: {
alignItems: "center",
textAlign: "center",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
lineHeight: 1,
},
headerContactRow: {
justifyContent: "center",
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(0.5),
},
headerContactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
});
const sectionTimelineStyles = StyleSheet.create({
items: {
position: "relative",
},
line: {
position: "absolute",
top: 0,
bottom: 0,
left: 7.5,
width: 1,
backgroundColor: primary,
},
item: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 2),
position: "relative",
},
marker: {
width: 16,
alignItems: "center",
},
dot: {
width: 9,
height: 9,
marginTop: 10,
borderRadius: 999,
borderWidth: 1,
borderColor: primary,
backgroundColor: background,
},
content: {
flex: 1,
},
});
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.foreground,
sidebarForeground: colors.sidebarForeground,
});
const accentFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.primary,
sidebarForeground: colors.sidebarForeground,
});
const featureStyles = {
sectionTimeline: {
...sectionTimelineStyles,
line: (context) => ({
...sectionTimelineStyles.line,
backgroundColor: accentFor(context),
}),
dot: (context) => ({
...sectionTimelineStyles.dot,
borderColor: accentFor(context),
backgroundColor: context.colors.background,
}),
},
} satisfies TemplateFeatureStyleSlots;
return {
colors,
featureStyles,
styles: {
...baseStyles,
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
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),
}),
} satisfies AzurillStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,285 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type BronzorStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerContactRow: Style;
headerContactItem: Style;
sections: Style;
};
type BronzorTemplate = {
colors: TemplateColorRoles;
styles: BronzorStyles;
};
const getBronzorSections = ({
mainSections,
sidebarSections,
fullWidth,
}: {
mainSections: string[];
sidebarSections: string[];
fullWidth: boolean;
}) => {
if (fullWidth) return mainSections;
return Array.from({ length: Math.max(mainSections.length, sidebarSections.length) }).flatMap((_, index) =>
[sidebarSections[index], mainSections[index]].filter((section): section is string => Boolean(section)),
);
};
export const BronzorPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { styles, colors } = useBronzorTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const sections = getBronzorSections({ mainSections, sidebarSections, fullWidth: page.fullWidth });
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sections, { rowGap: metrics.sectionGap })}>
{sections.map((section) => (
<Section key={section} section={section} placement="main" />
))}
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: BronzorStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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>
<View style={styles.headerContactRow}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.headerContactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.headerContactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
);
};
const useBronzorTemplate = (): BronzorTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "column",
rowGap: metrics.headerGap,
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights[0] ?? "500",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 3),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
section: {
flexDirection: "row",
columnGap: metrics.columnGap,
borderTopWidth: 1,
borderTopColor: primary,
paddingTop: metrics.gapY(0.5),
},
sectionHeading: {
width: `${metadata.layout.sidebarWidth}%`,
flexShrink: 0,
fontSize: metadata.typography.heading.fontSize * 0.75,
color: primary,
},
sectionItems: {
flex: 1,
},
sections: {
flexDirection: "column",
},
header: {
alignItems: "center",
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {
alignItems: "center",
textAlign: "center",
},
headerIdentity: {
alignItems: "center",
textAlign: "center",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
lineHeight: 1,
},
headerContactRow: {
justifyContent: "center",
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(5 / 6),
},
headerContactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(0.25),
},
icon: {
display: metadata.page.hideIcons ? "none" : "flex",
size: metadata.typography.body.fontSize,
color: primary,
},
});
return { colors, styles: baseStyles satisfies BronzorStyles };
}, [picture, metadata]);
};
@@ -0,0 +1,359 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles, resolvePlacementColor } from "../shared/styles";
type ChikoritaStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
mainColumn: Style;
sidebarColumn: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerContactList: Style;
headerContactRow: Style;
headerContactItem: Style;
};
type ChikoritaTemplate = {
colors: TemplateColorRoles;
styles: ChikoritaStyles;
};
export const ChikoritaPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata, picture } = data;
const { colors, styles } = useChikoritaTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const hasPicture = hasTemplatePicture(picture);
const showHeader = pageIndex === 0;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
<View
style={composeStyles(styles.mainColumn, {
paddingTop: metrics.page.paddingVertical,
paddingRight: page.fullWidth ? metrics.page.paddingHorizontal : metrics.columnGap,
paddingBottom: metrics.page.paddingVertical,
paddingLeft: metrics.page.paddingHorizontal,
rowGap: metrics.sectionGap,
})}
>
{showHeader && <Header styles={styles} />}
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
<View
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
flexBasis: `${metadata.layout.sidebarWidth}%`,
paddingTop:
showHeader && hasPicture
? metrics.page.paddingVertical + picture.size + metrics.itemGapY * 3
: metrics.page.paddingVertical,
paddingRight: metrics.page.paddingHorizontal,
paddingBottom: metrics.page.paddingVertical,
paddingLeft: metrics.columnGap,
rowGap: metrics.sectionGap,
})}
>
{sidebarSections.map((section, index) => (
<Fragment key={index}>
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: ChikoritaStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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 style={styles.headerContactList}>
<View style={styles.headerContactRow}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.headerContactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.headerContactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.headerContactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
</View>
<View style={styles.headerContactRow}>
{basics.website.url && (
<Link src={basics.website.url} style={styles.headerContactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.headerContactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
</View>
);
};
const useChikoritaTemplate = (): ChikoritaTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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,
sidebarForeground: background,
sidebarBackground: primary,
};
const metrics = getTemplateMetrics(metadata.page);
const bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "row",
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(0.25),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
section: {
flexDirection: "column",
rowGap: metrics.gapY(0.25),
},
sectionHeading: {
fontSize: metadata.typography.heading.fontSize * 0.85,
color: primary,
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.gapY(0.125),
},
sectionItems: {
paddingTop: metrics.gapY(0.375),
},
item: {
rowGap: metrics.gapY(0.125),
},
levelContainer: {
width: "70%",
},
mainColumn: {
flex: 1,
},
sidebarColumn: {
backgroundColor: primary,
},
header: {
flexDirection: "row",
alignItems: "flex-start",
columnGap: metrics.gapX(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {
flex: 1,
rowGap: metrics.gapY(0.5),
},
headerIdentity: {
textAlign: "left",
alignItems: "flex-start",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
lineHeight: 1,
},
headerContactList: {
rowGap: metrics.gapY(0.125),
},
headerContactRow: {
flexDirection: "row",
flexWrap: "wrap",
columnGap: metrics.gapX(2 / 3),
rowGap: metrics.gapY(0.125),
},
headerContactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
});
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.foreground,
sidebarForeground: colors.sidebarForeground,
});
const accentFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.primary,
sidebarForeground: colors.sidebarForeground,
});
return {
colors,
styles: {
...baseStyles,
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
splitRow: (context) => ({
...baseStyles.splitRow,
...(context.placement === "sidebar"
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
: {}),
}),
alignRight: (context) => ({
...baseStyles.alignRight,
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
}),
sectionHeading: (context) => ({
...baseStyles.sectionHeading,
color: accentFor(context),
borderBottomColor: 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),
}),
} satisfies ChikoritaStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,337 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles, resolvePlacementColor } from "../shared/styles";
type DitgarStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
sidebarColumn: Style;
sidebarContent: Style;
mainColumn: Style;
mainContent: Style;
specialContainer: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerText: Style;
contactList: Style;
contactItem: Style;
};
type DitgarTemplate = {
colors: TemplateColorRoles;
styles: DitgarStyles;
};
const ditgarFeatures = {
stackSidebarItemHeader: true,
mainItemHeaderBorder: true,
} satisfies TemplateFeatures;
export const DitgarPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useDitgarTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const showSidebar = !page.fullWidth || showHeader;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const specialMainSection = showHeader ? mainSections[0] : undefined;
const regularMainSections = showHeader ? mainSections.slice(1) : mainSections;
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors} features={ditgarFeatures}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
})}
>
{showHeader && <Header styles={styles} colors={colors} />}
{!page.fullWidth && (
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</View>
)}
<View style={styles.mainColumn}>
{specialMainSection && (
<View style={styles.specialContainer}>
<Section section={specialMainSection} placement="main" showHeading={specialMainSection !== "summary"} />
</View>
)}
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{regularMainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles, colors }: { styles: DitgarStyles; colors: TemplateColorRoles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerText}>{basics.headline}</Text>
</View>
</View>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="at" color={colors.background} />
<Text style={styles.headerText}>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" color={colors.background} />
<Text style={styles.headerText}>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" color={colors.background} />
<Text style={styles.headerText}>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" color={colors.background} />
<Text style={styles.headerText}>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} color={colors.background} />
<Text style={styles.headerText}>{field.text}</Text>
</Link>
))}
</View>
</View>
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useDitgarTemplate = (): DitgarTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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,
background,
primary,
sidebarForeground: foreground,
sidebarBackground: primaryTint,
};
const metrics = getTemplateMetrics(metadata.page);
const bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "row",
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
sectionHeading: {
fontSize: metadata.typography.heading.fontSize * 0.9,
color: primary,
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.gapY(0.125),
},
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "70%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
sidebarColumn: {
flexShrink: 0,
backgroundColor: primaryTint,
},
sidebarContent: {
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingBottom: metrics.page.paddingVertical,
},
mainColumn: { flex: 1 },
mainContent: {
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingBottom: metrics.page.paddingVertical,
},
specialContainer: {
backgroundColor: primaryTint,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
},
header: {
backgroundColor: primary,
color: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {},
headerIdentity: {
textAlign: "left",
alignItems: "flex-start",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
lineHeight: 1,
color: background,
},
headerText: { color: background },
contactList: { rowGap: metrics.gapY(0.125) },
contactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
});
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.foreground,
sidebarForeground: colors.sidebarForeground,
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...baseStyles,
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
alignRight: (context) => ({
...baseStyles.alignRight,
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
}),
sectionHeading: (context) => ({
...baseStyles.sectionHeading,
color: accentFor(context),
borderBottomColor: accentFor(context),
}),
sectionItemHeader: (context) => ({
...(context.placement === "main"
? {
borderLeftWidth: 2,
borderLeftColor: accentFor(context),
paddingLeft: metrics.gapX(0.5),
paddingVertical: metrics.gapY(0.125),
marginLeft: -metrics.gapX(0.625),
}
: {}),
}),
levelItem: (context) => ({ borderColor: accentFor(context) }),
levelItemActive: (context) => ({ backgroundColor: accentFor(context) }),
icon: (context) => ({
display: metadata.page.hideIcons ? "none" : "flex",
size: metadata.typography.body.fontSize,
color: context.placement === "sidebar" ? foreground : primary,
}),
} satisfies DitgarStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,372 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { Fragment, useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type DittoStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
headerBand: Style;
pictureAnchor: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerHeadline: Style;
contactRow: Style;
contactOffset: Style;
contactList: Style;
contactItem: Style;
contentRow: Style;
sidebarColumn: Style;
mainColumn: Style;
};
type DittoTemplate = {
colors: TemplateColorRoles;
styles: DittoStyles;
};
export const DittoPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata, picture } = data;
const { colors, styles } = useDittoTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const hasPicture = hasTemplatePicture(picture);
const showHeader = pageIndex === 0;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.contentRow, { paddingTop: metrics.headerGap })}>
<View
style={composeStyles(styles.sidebarColumn, {
display: page.fullWidth ? "none" : "flex",
width: `${metadata.layout.sidebarWidth}%`,
paddingLeft: metrics.page.paddingHorizontal,
paddingTop: showHeader && hasPicture ? metrics.page.paddingVertical : 0,
rowGap: metrics.sectionGap,
})}
>
{sidebarSections.map((section, index) => (
<Fragment key={index}>
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
<View
style={composeStyles(styles.mainColumn, {
paddingLeft: metrics.columnGap,
paddingRight: metrics.page.paddingHorizontal,
rowGap: metrics.sectionGap,
})}
>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: DittoStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<View style={styles.headerBand}>
<View style={styles.pictureAnchor}>{hasPicture && <Image src={picture.url} style={styles.picture} />}</View>
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerHeadline}>{basics.headline}</Text>
</View>
</View>
</View>
<View style={styles.contactRow}>
<View style={styles.contactOffset} />
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
);
};
const useDittoTemplate = (): DittoTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "column",
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 3),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
section: {
flexDirection: "column",
rowGap: metrics.gapY(0.25),
},
sectionHeading: {
color: primary,
},
item: {
rowGap: metrics.gapY(0.125),
},
levelContainer: {
width: "100%",
},
levelItem: {
borderColor: primary,
},
levelItemActive: {
backgroundColor: primary,
},
header: {
position: "relative",
},
headerBand: {
backgroundColor: primary,
color: background,
flexDirection: "row",
...(hasPicture ? { minHeight: picture.size * 0.6 } : {}),
},
pictureAnchor: {
width: `${metadata.layout.sidebarWidth}%`,
flexShrink: 0,
position: "relative",
},
picture: {
position: "absolute",
top: metrics.page.paddingVertical * 0.75,
left: "50%",
marginLeft: -picture.size / 2,
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {
flex: 1,
justifyContent: "center",
rowGap: metrics.gapY(0.125),
paddingLeft: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingRight: metrics.page.paddingHorizontal,
paddingBottom: metrics.page.paddingVertical,
color: background,
},
headerIdentity: {
textAlign: "left",
alignItems: "flex-start",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
color: background,
lineHeight: 1,
},
headerHeadline: {
color: background,
},
contactRow: {
flexDirection: "row",
alignItems: "flex-start",
},
contactOffset: {
width: `${metadata.layout.sidebarWidth}%`,
flexShrink: 0,
},
contactList: {
flex: 1,
flexDirection: "row",
flexWrap: "wrap",
columnGap: metrics.gapX(2 / 3),
rowGap: metrics.gapY(0.125),
paddingLeft: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingRight: metrics.page.paddingHorizontal,
paddingBottom: 0,
},
contactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
contentRow: {
flexDirection: "row",
},
sidebarColumn: {
flexShrink: 0,
},
mainColumn: {
flex: 1,
},
});
const foregroundFor = ({ colors }: TemplateStyleContext) => colors.foreground;
return {
colors,
styles: {
...baseStyles,
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
splitRow: (context) => ({
...baseStyles.splitRow,
...(context.placement === "sidebar"
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
: {}),
}),
alignRight: (context) => ({
...baseStyles.alignRight,
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
}),
icon: {
display: metadata.page.hideIcons ? "none" : "flex",
size: metadata.typography.body.fontSize,
color: primary,
},
} satisfies DittoStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,380 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { Fragment, useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles, resolvePlacementColor } from "../shared/styles";
type GengarStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
sidebarColumn: Style;
sidebarContent: Style;
mainColumn: Style;
mainContent: Style;
specialContainer: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerText: Style;
contactList: Style;
contactItem: Style;
};
type GengarTemplate = {
colors: TemplateColorRoles;
styles: GengarStyles;
};
export const GengarPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useGengarTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const showSidebar = !page.fullWidth || showHeader;
const sidebarSections = filterSections(page.sidebar, data);
const mainSections = filterSections(page.main, data);
const specialMainSection = showHeader ? mainSections[0] : undefined;
const regularMainSections = showHeader ? mainSections.slice(1) : mainSections;
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
})}
>
{showHeader && <Header styles={styles} colors={colors} />}
{!page.fullWidth && (
<View style={styles.sidebarContent}>
{sidebarSections.map((section, index) => (
<Fragment key={index}>
<Section section={section} placement="sidebar" />
</Fragment>
))}
</View>
)}
</View>
)}
<View style={styles.mainColumn}>
{specialMainSection && (
<View style={styles.specialContainer}>
<Section section={specialMainSection} placement="main" showHeading={specialMainSection !== "summary"} />
</View>
)}
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{regularMainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles, colors }: { styles: GengarStyles; colors: TemplateColorRoles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerText}>{basics.headline}</Text>
</View>
</View>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" color={colors.background} />
<Text style={styles.headerText}>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" color={colors.background} />
<Text style={styles.headerText}>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" color={colors.background} />
<Text style={styles.headerText}>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" color={colors.background} />
<Text style={styles.headerText}>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} color={colors.background} />
<Text style={styles.headerText}>{field.text}</Text>
</Link>
))}
</View>
</View>
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useGengarTemplate = (): GengarTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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,
background,
primary,
sidebarForeground: foreground,
sidebarBackground: primaryTint,
};
const metrics = getTemplateMetrics(metadata.page);
const bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
flexDirection: "row",
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 3),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
section: {
flexDirection: "column",
rowGap: metrics.gapY(0.25),
},
sectionHeading: {
fontSize: metadata.typography.heading.fontSize * 0.9,
color: primary,
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.gapY(0.125),
},
item: {
rowGap: metrics.gapY(0.125),
},
levelContainer: {
width: "70%",
},
levelItem: {
borderColor: primary,
},
levelItemActive: {
backgroundColor: primary,
},
sidebarColumn: {
flexShrink: 0,
backgroundColor: primaryTint,
},
sidebarContent: {
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingBottom: metrics.page.paddingVertical,
rowGap: metrics.sectionGap,
},
mainColumn: {
flex: 1,
},
mainContent: {
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
paddingBottom: metrics.page.paddingVertical,
},
specialContainer: {
backgroundColor: primaryTint,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
},
header: {
backgroundColor: primary,
color: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {},
headerIdentity: {
textAlign: "left",
alignItems: "flex-start",
rowGap: metrics.gapY(0.35),
},
headerName: {
fontSize: metadata.typography.heading.fontSize * 1.5,
color: background,
lineHeight: 1,
},
headerText: {
color: background,
},
contactList: {
rowGap: metrics.gapY(0.25),
},
contactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
});
const foregroundFor = ({ placement, colors }: TemplateStyleContext) =>
resolvePlacementColor({
placement,
defaultForeground: colors.foreground,
sidebarForeground: colors.sidebarForeground,
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...baseStyles,
text: (context) => ({ ...baseStyles.text, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
richParagraph: (context) => ({ ...baseStyles.richParagraph, color: foregroundFor(context) }),
richListItemMarker: (context) => ({ ...baseStyles.richListItemMarker, color: foregroundFor(context) }),
richListItemContent: (context) => ({ ...baseStyles.richListItemContent, color: foregroundFor(context) }),
splitRow: (context) => ({
...baseStyles.splitRow,
...(context.placement === "sidebar"
? { flexDirection: "column", alignItems: "flex-start", justifyContent: "flex-start" }
: {}),
}),
alignRight: (context) => ({
...baseStyles.alignRight,
...(context.placement === "sidebar" ? { textAlign: "left" } : {}),
}),
sectionHeading: (context) => ({
...baseStyles.sectionHeading,
color: accentFor(context),
borderBottomColor: 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: context.placement === "sidebar" ? foreground : primary,
}),
} satisfies GengarStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,295 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles, resolvePlacementColor } from "../shared/styles";
type GlalieStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
layout: Style;
sidebarBackground: Style;
sidebarColumn: Style;
sidebarContent: Style;
mainColumn: Style;
mainContent: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
contactList: Style;
contactItem: Style;
};
type GlalieTemplate = {
colors: TemplateColorRoles;
styles: GlalieStyles;
};
const glalieFeatures = {
stackSidebarItemHeader: true,
} satisfies TemplateFeatures;
export const GlaliePage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useGlalieTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const showSidebar = !page.fullWidth || showHeader;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors} features={glalieFeatures}>
{showSidebar && <View style={styles.sidebarBackground} />}
<View style={styles.layout}>
{showSidebar && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
})}
>
{showHeader && <Header styles={styles} />}
{!page.fullWidth && (
<View style={composeStyles(styles.sidebarContent, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</View>
)}
<View style={styles.mainColumn}>
<View style={composeStyles(styles.mainContent, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
</View>
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: GlalieStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
);
};
const getPrimaryTint = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useGlalieTemplate = (): GlalieTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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,
background,
primary,
sidebarForeground: foreground,
sidebarBackground: primaryTint,
};
const metrics = getTemplateMetrics(metadata.page);
const bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
sectionHeading: { borderBottomWidth: 1, borderBottomColor: primary },
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "100%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
sidebarBackground: {
position: "absolute",
top: 0,
bottom: 0,
left: 0,
width: `${metadata.layout.sidebarWidth}%`,
backgroundColor: primaryTint,
},
layout: { flexDirection: "row", minHeight: "100%" },
sidebarColumn: {
zIndex: 1,
backgroundColor: primaryTint,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
rowGap: metrics.sectionGap,
},
sidebarContent: { overflow: "hidden" },
mainColumn: { flex: 1, zIndex: 1 },
mainContent: {
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
},
header: {
alignItems: "center",
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: { alignItems: "center", textAlign: "center" },
headerIdentity: { alignItems: "center", textAlign: "center", rowGap: metrics.gapY(0.35) },
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
contactList: {
width: "100%",
borderWidth: 1,
borderColor: primary,
borderRadius: picture.borderRadius / 4,
padding: metrics.gapX(0.75),
rowGap: metrics.gapY(0.125),
},
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
const foregroundFor = (context: TemplateStyleContext) =>
resolvePlacementColor({
placement: context.placement,
defaultForeground: colors.foreground,
sidebarForeground: colors.sidebarForeground,
});
return {
colors,
styles: {
...baseStyles,
text: (context) => ({ ...bodyText, color: foregroundFor(context) }),
heading: (context) => ({ ...baseStyles.heading, color: foregroundFor(context) }),
link: (context) => ({ ...baseStyles.link, color: foregroundFor(context) }),
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),
}),
} satisfies GlalieStyles,
};
}, [picture, metadata]);
};
+54
View File
@@ -0,0 +1,54 @@
import type { Template } from "@reactive-resume/schema/templates";
import type { TemplatePage } from "../document";
import { AzurillPage } from "./azurill/AzurillPage";
import { BronzorPage } from "./bronzor/BronzorPage";
import { ChikoritaPage } from "./chikorita/ChikoritaPage";
import { DitgarPage } from "./ditgar/DitgarPage";
import { DittoPage } from "./ditto/DittoPage";
import { GengarPage } from "./gengar/GengarPage";
import { GlaliePage } from "./glalie/GlaliePage";
import { KakunaPage } from "./kakuna/KakunaPage";
import { LaprasPage } from "./lapras/LaprasPage";
import { LeafishPage } from "./leafish/LeafishPage";
import { MeowthPage } from "./meowth/MeowthPage";
import { OnyxPage } from "./onyx/OnyxPage";
import { PikachuPage } from "./pikachu/PikachuPage";
import { RhyhornPage } from "./rhyhorn/RhyhornPage";
export const templatePages: Partial<Record<Template, TemplatePage>> = {
azurill: AzurillPage,
bronzor: BronzorPage,
chikorita: ChikoritaPage,
ditgar: DitgarPage,
ditto: DittoPage,
gengar: GengarPage,
glalie: GlaliePage,
kakuna: KakunaPage,
lapras: LaprasPage,
leafish: LeafishPage,
meowth: MeowthPage,
onyx: OnyxPage,
pikachu: PikachuPage,
rhyhorn: RhyhornPage,
};
export const defaultTemplatePage = AzurillPage;
export const getTemplatePage = (template: Template): TemplatePage => templatePages[template] ?? defaultTemplatePage;
export {
AzurillPage,
BronzorPage,
ChikoritaPage,
DitgarPage,
DittoPage,
GengarPage,
GlaliePage,
KakunaPage,
LaprasPage,
LeafishPage,
MeowthPage,
OnyxPage,
PikachuPage,
RhyhornPage,
};
@@ -0,0 +1,306 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type KakunaStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerCopy: Style;
headerName: Style;
headerText: Style;
contactList: Style;
contactItem: Style;
sectionGroup: Style;
};
type KakunaTemplate = {
colors: TemplateColorRoles;
styles: KakunaStyles;
};
export const KakunaPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useKakunaTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: KakunaStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
<View style={styles.headerTitle}>
<View style={styles.headerCopy}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerText}>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
);
};
const useKakunaTemplate = (): KakunaTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.sectionGap,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: {
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1 / 3),
},
inline: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 3),
},
link: {
textDecoration: "none",
color: foreground,
},
small: {
fontSize: metadata.typography.body.fontSize * 0.875,
},
bold: {
fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600",
},
richParagraph: {
margin: 0,
...bodyText,
},
richListItemRow: {
flexDirection: "row",
columnGap: metrics.gapX(1 / 3),
alignItems: "flex-start",
},
richListItemMarker: {
width: metadata.typography.body.fontSize,
textAlign: "right",
...bodyText,
},
richListItemContent: {
flex: 1,
...bodyText,
},
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: {
textAlign: "right",
minWidth: 0,
maxWidth: "100%",
flexShrink: 1,
},
section: {
flexDirection: "column",
rowGap: metrics.gapY(0.25),
},
sectionHeading: {
color: primary,
textAlign: "center",
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.gapY(0.125),
},
item: {
rowGap: metrics.gapY(0.125),
},
levelContainer: {
width: "100%",
},
levelItem: {
borderColor: primary,
},
levelItemActive: {
backgroundColor: primary,
},
header: {
width: "100%",
alignItems: "center",
rowGap: metrics.gapY(0.5),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: {
width: "100%",
textAlign: "center",
alignItems: "center",
rowGap: metrics.gapY(0.5),
},
headerCopy: {
alignItems: "center",
textAlign: "center",
width: "100%",
rowGap: metrics.gapY(0.35),
},
headerName: {
width: "100%",
fontSize: metadata.typography.heading.fontSize * 1.5,
lineHeight: 1,
textAlign: "center",
},
headerText: {
width: "100%",
textAlign: "center",
},
contactList: {
width: "100%",
flexDirection: "row",
flexWrap: "wrap",
justifyContent: "center",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(0.75),
},
contactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
sectionGroup: {},
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...baseStyles,
sectionHeading: (context) => ({
...baseStyles.sectionHeading,
color: accentFor(context),
borderBottomColor: 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),
}),
} satisfies KakunaStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,254 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type LaprasStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
contactList: Style;
contactItem: Style;
sectionGroup: Style;
};
type LaprasTemplate = {
colors: TemplateColorRoles;
styles: LaprasStyles;
};
export const LaprasPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useLaprasTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.gapY(1.5) })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: LaprasStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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 style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
);
};
const getAlphaColor = (color: string, alpha: number): string => {
const parsed = parseColorString(color);
if (!parsed) return rgbaStringToHex(color);
return `rgba(${parsed.r}, ${parsed.g}, ${parsed.b}, ${Math.max(0, Math.min(1, alpha))})`;
};
const useLaprasTemplate = (): LaprasTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
const foreground = rgbaStringToHex(metadata.design.colors.text);
const background = rgbaStringToHex(metadata.design.colors.background);
const primary = rgbaStringToHex(metadata.design.colors.primary);
const borderColor = getAlphaColor(metadata.design.colors.text, 0.1);
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.gapY(1.5),
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
section: {
flexDirection: "column",
rowGap: metrics.gapY(0.25),
borderWidth: 1,
borderColor: borderColor,
borderRadius: pictureBorderRadius,
backgroundColor: background,
padding: metrics.gapX(1),
},
sectionHeading: {
alignSelf: "flex-start",
marginTop: -headingNegativeMargin,
backgroundColor: background,
paddingHorizontal: metrics.gapX(1),
},
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "100%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
header: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1),
borderWidth: 1,
borderColor: borderColor,
borderRadius: pictureBorderRadius,
backgroundColor: background,
padding: metrics.gapX(1),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: { rowGap: metrics.gapY(0.5) },
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
contactList: {
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(0.5),
},
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
sectionGroup: {},
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...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),
}),
} satisfies LaprasStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,280 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { parseColorString, rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type LeafishStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
headerIntro: Style;
headerBody: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerContactBand: Style;
contactList: Style;
contactItem: Style;
picture: Style;
body: Style;
mainColumn: Style;
sidebarColumn: Style;
};
type LeafishTemplate = {
colors: TemplateColorRoles;
styles: LeafishStyles;
};
export const LeafishPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useLeafishTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const mainSections = filterSections(page.main, data).filter((section) => section !== "summary");
const sidebarSections = filterSections(page.sidebar, data).filter((section) => section !== "summary");
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={styles.body}>
<View style={composeStyles(styles.mainColumn, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
{!page.fullWidth && (
<View
style={composeStyles(styles.sidebarColumn, {
width: `${metadata.layout.sidebarWidth}%`,
rowGap: metrics.sectionGap,
})}
>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</View>
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: LeafishStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<View style={styles.headerIntro}>
<View style={styles.headerBody}>
{hasPicture && <Image 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>
<Section section="summary" placement="main" showHeading={false} />
</View>
</View>
</View>
<View style={styles.headerContactBand}>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
);
};
const getPrimaryAlpha = (primaryColor: string, opacity: number): string => {
const primary = parseColorString(primaryColor);
if (!primary) return rgbaStringToHex(primaryColor);
const alpha = Math.max(0, Math.min(1, primary.a * opacity));
return `rgba(${primary.r}, ${primary.g}, ${primary.b}, ${alpha})`;
};
const useLeafishTemplate = (): LeafishTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
sectionHeading: { borderBottomWidth: 1, borderBottomColor: primary },
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "100%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
header: {},
headerIntro: {
backgroundColor: primaryTintLight,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
},
headerBody: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1),
},
headerTitle: {
flex: 1,
rowGap: metrics.gapY(0.5),
},
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
headerContactBand: {
backgroundColor: primaryTintDark,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
},
contactList: {
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(1),
},
contactItem: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1 / 6),
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
body: {
flexDirection: "row",
columnGap: metrics.columnGap,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingTop: metrics.page.paddingVertical,
},
mainColumn: { flex: 1 },
sidebarColumn: { flexShrink: 0 },
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...baseStyles,
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),
}),
} satisfies LeafishStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,242 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateFeatures, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type MeowthStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
headerHeadline: Style;
contactList: Style;
contactItem: Style;
picture: Style;
sectionGroup: Style;
};
type MeowthTemplate = {
colors: TemplateColorRoles;
styles: MeowthStyles;
};
const meowthFeatures = {
inlineItemHeader: true,
} satisfies TemplateFeatures;
export const MeowthPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useMeowthTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors} features={meowthFeatures}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: MeowthStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
<View style={styles.headerTitle}>
<View style={styles.headerIdentity}>
<Heading style={styles.headerName}>{basics.name}</Heading>
<Text style={styles.headerHeadline}>{basics.headline}</Text>
</View>
<View style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
{hasPicture && <Image src={picture.url} style={styles.picture} />}
</View>
);
};
const useMeowthTemplate = (): MeowthTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.sectionGap,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
inlineItemHeader: { flexDirection: "row", alignItems: "flex-start", columnGap: metrics.gapX(0.75) },
inlineItemHeaderLeading: { flex: 1, minWidth: 0 },
inlineItemHeaderMiddle: { flex: 1, minWidth: 0 },
inlineItemHeaderTrailing: { flexShrink: 0, textAlign: "right" },
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
sectionHeading: {
color: primary,
textTransform: "uppercase",
letterSpacing: 0,
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.gapY(0.125),
},
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "100%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
header: { flexDirection: "row", alignItems: "flex-start", columnGap: metrics.gapX(1) },
headerTitle: { flex: 1, rowGap: metrics.gapY(0.5) },
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
headerHeadline: { opacity: 0.8 },
contactList: {
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(0.75),
},
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
sectionGroup: {},
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...baseStyles,
sectionHeading: (context) => ({
...baseStyles.sectionHeading,
color: accentFor(context),
borderBottomColor: 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),
}),
} satisfies MeowthStyles,
};
}, [picture, metadata]);
};
@@ -0,0 +1,226 @@
import type { Style } from "@react-pdf/types";
import type { IconName } from "phosphor-icons-react-pdf/dynamic";
import type { TemplatePageProps } from "../../document";
import type { TemplateColorRoles, TemplateStyleContext, TemplateStyleSlots } from "../shared/types";
import { Image, Page, StyleSheet, View } from "@react-pdf/renderer";
import { useMemo } from "react";
import { rgbaStringToHex } from "@reactive-resume/utils/color";
import { useRender } from "../../context";
import { TemplateProvider } from "../shared/context";
import { filterSections } from "../shared/filtering";
import { getTemplateMetrics } from "../shared/metrics";
import { getTemplatePageSize } from "../shared/page-size";
import { hasTemplatePicture } from "../shared/picture";
import { Heading, Icon, Link, Text } from "../shared/primitives";
import { Section } from "../shared/sections";
import { composeStyles } from "../shared/styles";
type OnyxStyles = Omit<TemplateStyleSlots, "page"> & {
page: Style;
header: Style;
picture: Style;
headerTitle: Style;
headerIdentity: Style;
headerName: Style;
contactList: Style;
contactItem: Style;
sectionGroup: Style;
};
type OnyxTemplate = {
colors: TemplateColorRoles;
styles: OnyxStyles;
};
export const OnyxPage = ({ page, pageIndex }: TemplatePageProps) => {
const data = useRender();
const { metadata } = data;
const { colors, styles } = useOnyxTemplate();
const metrics = getTemplateMetrics(metadata.page);
const pageSize = getTemplatePageSize(metadata.page.format);
const showHeader = pageIndex === 0;
const mainSections = filterSections(page.main, data);
const sidebarSections = filterSections(page.sidebar, data);
return (
<Page size={pageSize} style={styles.page}>
<TemplateProvider styles={styles} colors={colors}>
{showHeader && <Header styles={styles} />}
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{mainSections.map((section, index) => (
<Section key={index} section={section} placement="main" />
))}
</View>
{!page.fullWidth && (
<View style={composeStyles(styles.sectionGroup, { rowGap: metrics.sectionGap })}>
{sidebarSections.map((section, index) => (
<Section key={index} section={section} placement="sidebar" />
))}
</View>
)}
</TemplateProvider>
</Page>
);
};
const Header = ({ styles }: { styles: OnyxStyles }) => {
const { basics, picture } = useRender();
const hasPicture = hasTemplatePicture(picture);
return (
<View style={styles.header}>
{hasPicture && <Image 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 style={styles.contactList}>
{basics.email && (
<Link src={`mailto:${basics.email}`} style={styles.contactItem}>
<Icon name="envelope" />
<Text>{basics.email}</Text>
</Link>
)}
{basics.phone && (
<Link src={`tel:${basics.phone}`} style={styles.contactItem}>
<Icon name="phone" />
<Text>{basics.phone}</Text>
</Link>
)}
{basics.location && (
<View style={styles.contactItem}>
<Icon name="map-pin" />
<Text>{basics.location}</Text>
</View>
)}
{basics.website.url && (
<Link src={basics.website.url} style={styles.contactItem}>
<Icon name="globe" />
<Text>{basics.website.label}</Text>
</Link>
)}
{basics.customFields.map((field) => (
<Link key={field.id} src={field.link} style={styles.contactItem}>
<Icon name={field.icon as IconName} />
<Text>{field.text}</Text>
</Link>
))}
</View>
</View>
</View>
);
};
const useOnyxTemplate = (): OnyxTemplate => {
const { picture, metadata } = useRender();
return useMemo(() => {
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 bodyText = {
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
fontWeight: metadata.typography.body.fontWeights[0] ?? "400",
lineHeight: metadata.typography.body.lineHeight,
color: foreground,
} satisfies Style;
const baseStyles = StyleSheet.create({
page: {
color: foreground,
backgroundColor: background,
paddingHorizontal: metrics.page.paddingHorizontal,
paddingVertical: metrics.page.paddingVertical,
rowGap: metrics.sectionGap,
fontFamily: metadata.typography.body.fontFamily,
fontSize: metadata.typography.body.fontSize,
lineHeight: metadata.typography.body.lineHeight,
},
text: bodyText,
heading: {
fontFamily: metadata.typography.heading.fontFamily,
fontSize: metadata.typography.heading.fontSize,
fontWeight: metadata.typography.heading.fontWeights.at(-1) ?? "600",
lineHeight: metadata.typography.heading.lineHeight,
color: foreground,
},
div: { rowGap: metrics.gapY(0.125), columnGap: metrics.gapX(1 / 3) },
inline: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 3) },
link: { textDecoration: "none", color: foreground },
small: { fontSize: metadata.typography.body.fontSize * 0.875 },
bold: { fontWeight: metadata.typography.body.fontWeights.at(-1) ?? "600" },
richParagraph: { margin: 0, ...bodyText },
richListItemRow: { flexDirection: "row", columnGap: metrics.gapX(1 / 3), alignItems: "flex-start" },
richListItemMarker: { width: metadata.typography.body.fontSize, textAlign: "right", ...bodyText },
richListItemContent: { flex: 1, ...bodyText },
splitRow: {
flexDirection: "row",
flexWrap: "wrap",
alignItems: "flex-start",
justifyContent: "space-between",
columnGap: metrics.gapX(2 / 3),
},
alignRight: { textAlign: "right", minWidth: 0, maxWidth: "100%", flexShrink: 1 },
section: { flexDirection: "column", rowGap: metrics.gapY(0.25) },
item: { rowGap: metrics.gapY(0.125) },
levelContainer: { width: "100%" },
levelItem: { borderColor: primary },
levelItemActive: { backgroundColor: primary },
header: {
flexDirection: "row",
alignItems: "center",
columnGap: metrics.gapX(1),
borderBottomWidth: 1,
borderBottomColor: primary,
paddingBottom: metrics.page.paddingVertical,
},
picture: {
width: picture.size,
height: picture.size,
objectFit: "cover",
aspectRatio: picture.aspectRatio,
borderRadius: picture.borderRadius,
borderColor: rgbaStringToHex(picture.borderColor),
borderWidth: picture.borderWidth,
shadowColor: rgbaStringToHex(picture.shadowColor),
shadowWidth: picture.shadowWidth,
transform: `rotate(${picture.rotation}deg)`,
},
headerTitle: { rowGap: metrics.gapY(0.5) },
headerIdentity: { textAlign: "left", alignItems: "flex-start", rowGap: metrics.gapY(0.35) },
headerName: { fontSize: metadata.typography.heading.fontSize * 1.5, lineHeight: 1 },
contactList: {
flexDirection: "row",
flexWrap: "wrap",
rowGap: metrics.gapY(0.125),
columnGap: metrics.gapX(0.75),
},
contactItem: { flexDirection: "row", alignItems: "center", columnGap: metrics.gapX(1 / 6) },
sectionGroup: {},
});
const accentFor = ({ colors }: TemplateStyleContext) => colors.primary;
return {
colors,
styles: {
...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),
}),
} satisfies OnyxStyles,
};
}, [picture, metadata]);
};

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