mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-24 17:03:55 +10:00
v5.1.0 (#2970)
* chore(release): v5.1.0 * feat: implement resume thumbnails * fix: remove unused mcp tools * docs: fix formatting of docs
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@reactive-resume/utils",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"exports": {
|
||||
"./color": "./src/color.ts",
|
||||
"./date": "./src/date.ts",
|
||||
"./field": "./src/field.ts",
|
||||
"./file": "./src/file.ts",
|
||||
"./html": "./src/html.ts",
|
||||
"./level": "./src/level.ts",
|
||||
"./locale": "./src/locale.ts",
|
||||
"./network-icons": "./src/network-icons.ts",
|
||||
"./rate-limit": "./src/rate-limit.ts",
|
||||
"./resume/docx": "./src/resume/docx/index.ts",
|
||||
"./resume/patch": "./src/resume/patch.ts",
|
||||
"./sanitize": "./src/sanitize.ts",
|
||||
"./string": "./src/string.ts",
|
||||
"./style": "./src/style.ts",
|
||||
"./url": "./src/url.ts",
|
||||
"./url-security": "./src/url-security.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:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@uiw/color-convert": "^2.10.1",
|
||||
"clsx": "^2.1.1",
|
||||
"docx": "^9.6.1",
|
||||
"dompurify": "^3.4.2",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.0",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { ColorResult } from "@uiw/color-convert";
|
||||
import { hsvaToHex, rgbaStringToHsva } from "@uiw/color-convert";
|
||||
|
||||
export function rgbaStringToHex(rgba: string): string {
|
||||
const hsva = rgbaStringToHsva(rgba);
|
||||
return hsvaToHex(hsva);
|
||||
}
|
||||
|
||||
export function parseColorString(value: string): ColorResult["rgba"] | null {
|
||||
const trimmed = value.trim();
|
||||
|
||||
// Parse rgb/rgba colors
|
||||
const rgbMatch = trimmed.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*(?:,\s*([\d.]+))?\s*\)$/);
|
||||
|
||||
if (rgbMatch) {
|
||||
return {
|
||||
r: Number.parseInt(rgbMatch[1] ?? "0", 10),
|
||||
g: Number.parseInt(rgbMatch[2] ?? "0", 10),
|
||||
b: Number.parseInt(rgbMatch[3] ?? "0", 10),
|
||||
a: rgbMatch[4] ? Number.parseFloat(rgbMatch[4]) : 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse hex colors (convert to RGB)
|
||||
if (trimmed.startsWith("#")) {
|
||||
const hexMatch = trimmed.match(/^#([a-fA-F0-9]{2})([a-fA-F0-9]{2})([a-fA-F0-9]{2})$/i);
|
||||
if (hexMatch) {
|
||||
return {
|
||||
r: Number.parseInt(hexMatch[1] ?? "0", 16),
|
||||
g: Number.parseInt(hexMatch[2] ?? "0", 16),
|
||||
b: Number.parseInt(hexMatch[3] ?? "0", 16),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// Support 3-digit hex
|
||||
const hexMatch3 = trimmed.match(/^#([a-fA-F0-9])([a-fA-F0-9])([a-fA-F0-9])$/i);
|
||||
if (hexMatch3) {
|
||||
return {
|
||||
r: Number.parseInt((hexMatch3[1] ?? "0") + (hexMatch3[1] ?? "0"), 16),
|
||||
g: Number.parseInt((hexMatch3[2] ?? "0") + (hexMatch3[2] ?? "0"), 16),
|
||||
b: Number.parseInt((hexMatch3[3] ?? "0") + (hexMatch3[3] ?? "0"), 16),
|
||||
a: 1,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
const MONTH_NAMES = [
|
||||
"January",
|
||||
"February",
|
||||
"March",
|
||||
"April",
|
||||
"May",
|
||||
"June",
|
||||
"July",
|
||||
"August",
|
||||
"September",
|
||||
"October",
|
||||
"November",
|
||||
"December",
|
||||
];
|
||||
|
||||
/**
|
||||
* Formats a partial ISO 8601 date string (YYYY, YYYY-MM, or YYYY-MM-DD)
|
||||
* into a human-readable format like "January 2024" or "January 15, 2024".
|
||||
*/
|
||||
export function formatDate(date: string, includeDay = false): string {
|
||||
const parts = date.split("-");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
const [year, month] = parts;
|
||||
const monthName = MONTH_NAMES[Number.parseInt(month ?? "1", 10) - 1];
|
||||
|
||||
if (parts.length === 3 && includeDay) {
|
||||
return `${monthName} ${parts[2]}, ${year}`;
|
||||
}
|
||||
|
||||
return `${monthName} ${year}`;
|
||||
}
|
||||
|
||||
// YYYY only
|
||||
return date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range from start and end dates.
|
||||
* Returns "Start - End", "Start - Present" if no end, or just the end date if no start.
|
||||
*/
|
||||
export function formatPeriod(startDate?: string, endDate?: string): string {
|
||||
if (!startDate && !endDate) return "";
|
||||
if (!startDate) return endDate || "";
|
||||
if (!endDate) return `${formatDate(startDate)} - Present`;
|
||||
|
||||
return `${formatDate(startDate)} - ${formatDate(endDate)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a single date with day included (e.g., "January 15, 2024").
|
||||
* Falls back to month-year or year-only for partial dates.
|
||||
*/
|
||||
export function formatSingleDate(date?: string): string {
|
||||
if (!date) return "";
|
||||
return formatDate(date, true);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
type KeyedField<TKey extends string> = {
|
||||
key: TKey;
|
||||
};
|
||||
|
||||
type KeyValueMap<TKey extends string> = Partial<Record<TKey, string | null | undefined>>;
|
||||
|
||||
/**
|
||||
* Filters keyed field descriptors to only those whose corresponding value in the given
|
||||
* values object is a non-empty string.
|
||||
*
|
||||
* This is useful for rendering or processing only the fields with actual, non-blank content,
|
||||
* e.g., when displaying optional sections in a UI or assembling objects with present values.
|
||||
*
|
||||
* @param values - An object mapping keys to string values (which may be empty, null, or undefined)
|
||||
* @param fields - Field descriptors with { key: TKey }
|
||||
* @returns An array of fields whose corresponding values[key] is a non-empty string
|
||||
*/
|
||||
export function filterFieldValues<TKey extends string, TField extends KeyedField<TKey>>(
|
||||
values: KeyValueMap<TKey>,
|
||||
...fields: TField[]
|
||||
) {
|
||||
const filteredFields = fields.filter((field) => Boolean(values[field.key]?.trim()));
|
||||
return new Map(filteredFields.map((field) => [field.key, field] as const));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { slugify } from "./string";
|
||||
|
||||
export function generateFilename(prefix: string, extension?: string) {
|
||||
const name = slugify(prefix);
|
||||
return `${name}${extension ? `.${extension}` : ""}`;
|
||||
}
|
||||
|
||||
export function downloadWithAnchor(blob: Blob, filename: string) {
|
||||
const a = document.createElement("a");
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
a.href = url;
|
||||
a.rel = "noopener";
|
||||
a.download = filename;
|
||||
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||
}
|
||||
|
||||
export async function downloadFromUrl(url: string, filename: string) {
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
|
||||
downloadWithAnchor(blob, filename);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Converts a summary string and optional highlights array into an HTML description.
|
||||
* Summary becomes a <p> tag, highlights become a <ul> list.
|
||||
*/
|
||||
export function toHtmlDescription(summary?: string, highlights?: string[]): string {
|
||||
const parts: string[] = [];
|
||||
|
||||
if (summary) {
|
||||
parts.push(`<p>${summary}</p>`);
|
||||
}
|
||||
|
||||
if (highlights && highlights.length > 0) {
|
||||
parts.push("<ul>");
|
||||
|
||||
for (const highlight of highlights) {
|
||||
parts.push(`<li>${highlight}</li>`);
|
||||
}
|
||||
|
||||
parts.push("</ul>");
|
||||
}
|
||||
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an array of strings into an HTML unordered list.
|
||||
*/
|
||||
export function arrayToHtmlList(items: string[]): string {
|
||||
if (items.length === 0) return "";
|
||||
return `<ul>${items.map((item) => `<li>${item}</li>`).join("")}</ul>`;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Parses a skill/language level string to a numeric value (0-5).
|
||||
* Supports numeric values, text levels (beginner/intermediate/advanced/expert),
|
||||
* and CEFR levels (A1-C2).
|
||||
*/
|
||||
export function parseLevel(level?: string): number {
|
||||
if (!level) return 0;
|
||||
|
||||
const levelLower = level.toLowerCase();
|
||||
|
||||
// Try to parse numeric values
|
||||
const numeric = Number.parseInt(levelLower, 10);
|
||||
if (!Number.isNaN(numeric) && numeric >= 0 && numeric <= 5) return numeric;
|
||||
|
||||
// Map text levels to numbers
|
||||
if (levelLower.includes("native") || levelLower.includes("expert") || levelLower.includes("master")) return 5;
|
||||
if (levelLower.includes("fluent") || levelLower.includes("advanced") || levelLower.includes("proficient")) return 4;
|
||||
if (levelLower.includes("intermediate") || levelLower.includes("conversational")) return 3;
|
||||
if (levelLower.includes("beginner") || levelLower.includes("basic") || levelLower.includes("elementary")) return 2;
|
||||
if (levelLower.includes("novice")) return 1;
|
||||
|
||||
// CEFR levels
|
||||
if (levelLower.includes("c2")) return 5;
|
||||
if (levelLower.includes("c1")) return 4;
|
||||
if (levelLower.includes("b2")) return 3;
|
||||
if (levelLower.includes("b1")) return 2;
|
||||
if (levelLower.includes("a2")) return 1;
|
||||
if (levelLower.includes("a1")) return 1;
|
||||
|
||||
return 0;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// Minimal locale type — the full lingui wiring lives in apps/web (Phase 13).
|
||||
export type Locale = string;
|
||||
|
||||
export const defaultLocale: Locale = "en-US";
|
||||
|
||||
export function isLocale(value: unknown): value is Locale {
|
||||
return typeof value === "string" && value.length > 0;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { IconName } from "@reactive-resume/schema/icons";
|
||||
|
||||
const NETWORK_ICON_MAP: Array<{ match: string[]; icon: IconName }> = [
|
||||
{ match: ["github"], icon: "github-logo" },
|
||||
{ match: ["linkedin"], icon: "linkedin-logo" },
|
||||
{ match: ["twitter", "x", "x.com"], icon: "twitter-logo" },
|
||||
{ match: ["facebook"], icon: "facebook-logo" },
|
||||
{ match: ["instagram"], icon: "instagram-logo" },
|
||||
{ match: ["youtube"], icon: "youtube-logo" },
|
||||
{ match: ["stackoverflow", "stack-overflow"], icon: "stack-overflow-logo" },
|
||||
{ match: ["medium"], icon: "medium-logo" },
|
||||
{ match: ["dev.to", "devto"], icon: "code" },
|
||||
{ match: ["dribbble"], icon: "dribbble-logo" },
|
||||
{ match: ["behance"], icon: "behance-logo" },
|
||||
{ match: ["gitlab"], icon: "git-branch" },
|
||||
{ match: ["bitbucket", "codepen"], icon: "code" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Maps a social network name to a Phosphor icon name.
|
||||
* Returns "star" as default if no match is found.
|
||||
*/
|
||||
export function getNetworkIcon(network?: string): IconName {
|
||||
if (!network) return "star";
|
||||
|
||||
const networkLower = network.toLowerCase();
|
||||
|
||||
for (const entry of NETWORK_ICON_MAP) {
|
||||
if (entry.match.some((keyword) => networkLower.includes(keyword))) {
|
||||
return entry.icon;
|
||||
}
|
||||
}
|
||||
|
||||
return "star";
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
export const TRUSTED_IP_HEADERS = [
|
||||
"CF-Connecting-IP",
|
||||
"CF-Connecting-IPv6",
|
||||
"True-Client-IP",
|
||||
"X-Forwarded-For",
|
||||
"X-Real-IP",
|
||||
];
|
||||
|
||||
export const rateLimitConfig = {
|
||||
betterAuth: {
|
||||
global: {
|
||||
enabled: true,
|
||||
window: 60,
|
||||
max: 60,
|
||||
customRules: {
|
||||
"/sign-in/email": { window: 60, max: 5 },
|
||||
"/sign-up/email": { window: 60, max: 3 },
|
||||
"/request-password-reset": { window: 600, max: 3 },
|
||||
"/send-verification-email": { window: 600, max: 3 },
|
||||
"/two-factor/verify-otp": { window: 600, max: 5 },
|
||||
"/two-factor/verify-totp": { window: 600, max: 5 },
|
||||
"/two-factor/verify-backup-code": { window: 600, max: 5 },
|
||||
"/is-username-available": { window: 60, max: 20 },
|
||||
},
|
||||
},
|
||||
oauthProvider: {
|
||||
register: { window: 60, max: 5 },
|
||||
authorize: { window: 60, max: 30 },
|
||||
token: { window: 60, max: 20 },
|
||||
introspect: { window: 60, max: 60 },
|
||||
revoke: { window: 60, max: 30 },
|
||||
userinfo: { window: 60, max: 60 },
|
||||
},
|
||||
apiKey: {
|
||||
enabled: true,
|
||||
timeWindow: 60 * 60 * 1000,
|
||||
maxRequests: 1000,
|
||||
},
|
||||
},
|
||||
orpc: {
|
||||
resumePassword: { maxRequests: 5, window: 10 * 60 * 1000 },
|
||||
pdfExport: { maxRequests: 5, window: 60 * 1000 },
|
||||
aiRequest: { maxRequests: 20, window: 60 * 1000 },
|
||||
jobsSearch: { maxRequests: 30, window: 60 * 1000 },
|
||||
jobsTestConnection: { maxRequests: 10, window: 60 * 1000 },
|
||||
storageUpload: { maxRequests: 20, window: 60 * 1000 },
|
||||
storageDelete: { maxRequests: 30, window: 60 * 1000 },
|
||||
resumeMutations: { maxRequests: 60, window: 60 * 1000 },
|
||||
},
|
||||
} as const;
|
||||
@@ -0,0 +1,507 @@
|
||||
import type { ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import {
|
||||
BorderStyle,
|
||||
convertMillimetersToTwip,
|
||||
Document,
|
||||
ExternalHyperlink,
|
||||
HeadingLevel,
|
||||
Paragraph,
|
||||
ShadingType,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from "docx";
|
||||
import { parseColorString } from "@reactive-resume/utils/color";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
import { renderBuiltInSection, renderCustomSection, renderSummary, setRenderConfig } from "./section-renderers";
|
||||
|
||||
// --- Color helpers ---
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
return [r, g, b].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function getColorHex(rgba: string, fallback: string): string {
|
||||
const parsed = parseColorString(rgba);
|
||||
if (!parsed) return fallback;
|
||||
return rgbToHex(parsed.r, parsed.g, parsed.b);
|
||||
}
|
||||
|
||||
// --- Unit conversion helpers ---
|
||||
|
||||
/** Convert pt font size to docx half-points (docx uses half-pt units). */
|
||||
function ptToHalfPt(pt: number): number {
|
||||
return Math.round(pt * 2);
|
||||
}
|
||||
|
||||
/** Convert pt spacing to twips (1pt = 20 twips). */
|
||||
function ptToTwips(pt: number): number {
|
||||
return Math.round(pt * 20);
|
||||
}
|
||||
|
||||
// --- Page size constants (in mm) ---
|
||||
|
||||
interface PageSize {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE: PageSize = { width: 210, height: 297 };
|
||||
|
||||
const PAGE_SIZES: Record<string, PageSize> = {
|
||||
a4: DEFAULT_PAGE_SIZE,
|
||||
letter: { width: 215.9, height: 279.4 },
|
||||
};
|
||||
|
||||
// --- Invisible border preset for table cells ---
|
||||
|
||||
const NO_BORDERS = {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
} as const;
|
||||
|
||||
// --- Template layout config ---
|
||||
|
||||
interface TemplateConfig {
|
||||
/** Which side the sidebar appears on */
|
||||
sidebarSide: "left" | "right";
|
||||
/** Sidebar background: "solid" = full primary color, "tint" = 20% opacity, "none" = no background */
|
||||
sidebarBackground: "solid" | "tint" | "none";
|
||||
/** Where the header is rendered */
|
||||
headerPosition: "full-width" | "main-only" | "sidebar-only";
|
||||
}
|
||||
|
||||
const TEMPLATE_CONFIGS: Record<string, TemplateConfig> = {
|
||||
azurill: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
bronzor: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
chikorita: { sidebarSide: "right", sidebarBackground: "solid", headerPosition: "main-only" },
|
||||
ditgar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
ditto: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
gengar: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
glalie: { sidebarSide: "left", sidebarBackground: "tint", headerPosition: "sidebar-only" },
|
||||
kakuna: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
lapras: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
leafish: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
onyx: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
pikachu: { sidebarSide: "left", sidebarBackground: "none", headerPosition: "main-only" },
|
||||
rhyhorn: { sidebarSide: "right", sidebarBackground: "none", headerPosition: "full-width" },
|
||||
};
|
||||
|
||||
const DEFAULT_TEMPLATE_CONFIG: TemplateConfig = {
|
||||
sidebarSide: "left",
|
||||
sidebarBackground: "none",
|
||||
headerPosition: "full-width",
|
||||
};
|
||||
|
||||
/**
|
||||
* Blends a hex color toward white at the given opacity (0-1).
|
||||
* Used to approximate CSS `background-color: rgba(r,g,b, 0.2)` on a white background.
|
||||
*/
|
||||
function blendWithWhite(hex: string, opacity: number): string {
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
const blend = (c: number) => Math.round(c * opacity + 255 * (1 - opacity));
|
||||
return [blend(r), blend(g), blend(b)].map((c) => c.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
// --- Section rendering dispatch ---
|
||||
|
||||
const BUILT_IN_SECTIONS = new Set<string>([
|
||||
"profiles",
|
||||
"experience",
|
||||
"education",
|
||||
"projects",
|
||||
"skills",
|
||||
"languages",
|
||||
"interests",
|
||||
"awards",
|
||||
"certifications",
|
||||
"publications",
|
||||
"volunteer",
|
||||
"references",
|
||||
]);
|
||||
|
||||
function renderSection(sectionId: string, data: ResumeData, colorHex: string): Paragraph[] {
|
||||
if (sectionId === "summary") {
|
||||
return renderSummary(data.summary, colorHex);
|
||||
}
|
||||
|
||||
if (BUILT_IN_SECTIONS.has(sectionId)) {
|
||||
const section = data.sections[sectionId as SectionType];
|
||||
if (section) {
|
||||
return renderBuiltInSection(sectionId as SectionType, section, colorHex);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
const customSection = data.customSections.find((cs) => cs.id === sectionId);
|
||||
if (customSection) {
|
||||
return renderCustomSection(customSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
// --- Header ---
|
||||
|
||||
function buildHeader(data: ResumeData, colorHex: string, textColorHex: string): Paragraph[] {
|
||||
const paragraphs: Paragraph[] = [];
|
||||
const { basics } = data;
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
// h2 = calc(var(--page-heading-font-size) * 1.25pt)
|
||||
const nameSize = ptToHalfPt(data.metadata.typography.heading.fontSize * 1.25);
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
|
||||
if (basics.name) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
heading: HeadingLevel.TITLE,
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.name,
|
||||
bold: true,
|
||||
size: nameSize,
|
||||
font: headingFont,
|
||||
color: colorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (basics.headline) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 60 },
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.headline,
|
||||
italics: true,
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
color: textColorHex,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const contactParts: (TextRun | ExternalHyperlink)[] = [];
|
||||
const addSeparator = () => {
|
||||
if (contactParts.length > 0) {
|
||||
contactParts.push(new TextRun({ text: " | ", color: "999999", size: bodySize, font: bodyFont }));
|
||||
}
|
||||
};
|
||||
|
||||
if (basics.email) {
|
||||
const mailtoLink = toSafeDocxLink(`mailto:${basics.email}`);
|
||||
if (mailtoLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: mailtoLink,
|
||||
children: [
|
||||
new TextRun({ text: basics.email, color: colorHex, underline: {}, size: bodySize, font: bodyFont }),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (basics.phone) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.phone, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.location) {
|
||||
addSeparator();
|
||||
contactParts.push(new TextRun({ text: basics.location, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
|
||||
if (basics.website.url) {
|
||||
const websiteLink = toSafeDocxLink(basics.website.url);
|
||||
if (websiteLink) {
|
||||
addSeparator();
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: websiteLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: basics.website.label || websiteLink,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for (const field of basics.customFields) {
|
||||
if (!field.text) continue;
|
||||
addSeparator();
|
||||
if (field.link) {
|
||||
const customLink = toSafeDocxLink(field.link);
|
||||
if (customLink) {
|
||||
contactParts.push(
|
||||
new ExternalHyperlink({
|
||||
link: customLink,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: field.text,
|
||||
color: colorHex,
|
||||
underline: {},
|
||||
size: bodySize,
|
||||
font: bodyFont,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
} else {
|
||||
contactParts.push(new TextRun({ text: field.text, size: bodySize, font: bodyFont, color: textColorHex }));
|
||||
}
|
||||
}
|
||||
|
||||
if (contactParts.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { after: 200 },
|
||||
children: contactParts,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// --- Two-column table layout ---
|
||||
|
||||
function buildTwoColumnTable(
|
||||
mainParagraphs: Paragraph[],
|
||||
sidebarParagraphs: Paragraph[],
|
||||
sidebarWidthPct: number,
|
||||
gapXTwips: number,
|
||||
sidebarSide: "left" | "right",
|
||||
sidebarShadingHex?: string,
|
||||
): Table {
|
||||
const mainWidthPct = 100 - sidebarWidthPct;
|
||||
|
||||
// DOCX table cells require at least one child
|
||||
const mainChildren = mainParagraphs.length > 0 ? mainParagraphs : [new Paragraph({})];
|
||||
const sidebarChildren = sidebarParagraphs.length > 0 ? sidebarParagraphs : [new Paragraph({})];
|
||||
|
||||
const sidebarShading = sidebarShadingHex
|
||||
? { fill: sidebarShadingHex, type: ShadingType.CLEAR, color: "auto" }
|
||||
: undefined;
|
||||
|
||||
const sidebarCell = new TableCell({
|
||||
width: { size: sidebarWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { right: gapXTwips } : { left: gapXTwips },
|
||||
children: sidebarChildren,
|
||||
...(sidebarShading ? { shading: sidebarShading } : {}),
|
||||
});
|
||||
|
||||
const mainCell = new TableCell({
|
||||
width: { size: mainWidthPct, type: WidthType.PERCENTAGE },
|
||||
borders: NO_BORDERS,
|
||||
margins: sidebarSide === "left" ? { left: gapXTwips } : { right: gapXTwips },
|
||||
children: mainChildren,
|
||||
});
|
||||
|
||||
const cells = sidebarSide === "left" ? [sidebarCell, mainCell] : [mainCell, sidebarCell];
|
||||
|
||||
return new Table({
|
||||
rows: [new TableRow({ children: cells })],
|
||||
width: { size: 100, type: WidthType.PERCENTAGE },
|
||||
borders: {
|
||||
top: { style: BorderStyle.NONE, size: 0 },
|
||||
bottom: { style: BorderStyle.NONE, size: 0 },
|
||||
left: { style: BorderStyle.NONE, size: 0 },
|
||||
right: { style: BorderStyle.NONE, size: 0 },
|
||||
insideHorizontal: { style: BorderStyle.NONE, size: 0 },
|
||||
insideVertical: { style: BorderStyle.NONE, size: 0 },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a complete docx Document from resume data.
|
||||
*
|
||||
* Mirrors the resume builder's visual layout:
|
||||
* - Header with name, headline, and contact info (centered, full-width)
|
||||
* - Two-column table layout (main + sidebar) matching `metadata.layout`
|
||||
* - Typography (font family, size, line height) from `metadata.typography`
|
||||
* - Page margins and format (A4/Letter) from `metadata.page`
|
||||
* - Primary, text, and background colors from `metadata.design.colors`
|
||||
*/
|
||||
export function buildDocument(data: ResumeData): Document {
|
||||
const colorHex = getColorHex(data.metadata.design.colors.primary, "DC2626");
|
||||
const textColorHex = getColorHex(data.metadata.design.colors.text, "000000");
|
||||
const bgColorHex = getColorHex(data.metadata.design.colors.background, "FFFFFF");
|
||||
|
||||
const bodyFont = data.metadata.typography.body.fontFamily || "Calibri";
|
||||
const bodySize = ptToHalfPt(data.metadata.typography.body.fontSize);
|
||||
const lineSpacing = Math.round(data.metadata.typography.body.lineHeight * 240);
|
||||
|
||||
const { page } = data.metadata;
|
||||
const pageSize = PAGE_SIZES[page.format] ?? DEFAULT_PAGE_SIZE;
|
||||
// Margins and gaps are defined in points (pt), not mm
|
||||
const marginXTwips = ptToTwips(page.marginX);
|
||||
const marginYTwips = ptToTwips(page.marginY);
|
||||
const gapXTwips = ptToTwips(page.gapX);
|
||||
|
||||
const sidebarWidth = data.metadata.layout.sidebarWidth;
|
||||
|
||||
// Template-aware layout config
|
||||
const templateConfig = TEMPLATE_CONFIGS[data.metadata.template] ?? DEFAULT_TEMPLATE_CONFIG;
|
||||
|
||||
// Compute sidebar background shading hex
|
||||
let sidebarShadingHex: string | undefined;
|
||||
if (templateConfig.sidebarBackground === "solid") {
|
||||
sidebarShadingHex = colorHex;
|
||||
} else if (templateConfig.sidebarBackground === "tint") {
|
||||
sidebarShadingHex = blendWithWhite(colorHex, 0.2);
|
||||
}
|
||||
|
||||
// Determine sidebar text colors — inverted when sidebar has a solid background
|
||||
const sidebarTextColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : textColorHex;
|
||||
const sidebarHeadingColorHex = templateConfig.sidebarBackground === "solid" ? bgColorHex : colorHex;
|
||||
|
||||
// Configure heading typography for section renderers
|
||||
const headingFont = data.metadata.typography.heading.fontFamily || "Calibri";
|
||||
const headingSize = ptToHalfPt(data.metadata.typography.heading.fontSize);
|
||||
|
||||
const documentChildren: (Paragraph | Table)[] = [];
|
||||
|
||||
// Header placement depends on template
|
||||
if (templateConfig.headerPosition === "full-width") {
|
||||
// Configure colors for main content
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
documentChildren.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
|
||||
// Process each page in the layout
|
||||
for (const layoutPage of data.metadata.layout.pages) {
|
||||
const isFullWidth = layoutPage.fullWidth || layoutPage.sidebar.length === 0;
|
||||
|
||||
if (isFullWidth) {
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
for (const sectionId of [...layoutPage.main, ...layoutPage.sidebar]) {
|
||||
documentChildren.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
} else {
|
||||
// Render main sections with normal colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex,
|
||||
primaryColorHex: colorHex,
|
||||
});
|
||||
|
||||
const mainParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "main-only") {
|
||||
mainParagraphs.push(...buildHeader(data, colorHex, textColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.main) {
|
||||
mainParagraphs.push(...renderSection(sectionId, data, colorHex));
|
||||
}
|
||||
|
||||
// Render sidebar sections with potentially inverted colors
|
||||
setRenderConfig({
|
||||
headingFont,
|
||||
headingSizeHalfPt: headingSize,
|
||||
bodyFont,
|
||||
bodySizeHalfPt: bodySize,
|
||||
textColorHex: sidebarTextColorHex,
|
||||
primaryColorHex: sidebarHeadingColorHex,
|
||||
});
|
||||
|
||||
const sidebarParagraphs: Paragraph[] = [];
|
||||
if (templateConfig.headerPosition === "sidebar-only") {
|
||||
sidebarParagraphs.push(...buildHeader(data, sidebarHeadingColorHex, sidebarTextColorHex));
|
||||
}
|
||||
for (const sectionId of layoutPage.sidebar) {
|
||||
sidebarParagraphs.push(...renderSection(sectionId, data, sidebarHeadingColorHex));
|
||||
}
|
||||
|
||||
if (mainParagraphs.length > 0 || sidebarParagraphs.length > 0) {
|
||||
documentChildren.push(
|
||||
buildTwoColumnTable(
|
||||
mainParagraphs,
|
||||
sidebarParagraphs,
|
||||
sidebarWidth,
|
||||
gapXTwips,
|
||||
templateConfig.sidebarSide,
|
||||
sidebarShadingHex,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Document({
|
||||
styles: {
|
||||
default: {
|
||||
document: {
|
||||
run: {
|
||||
font: bodyFont,
|
||||
size: bodySize,
|
||||
color: textColorHex,
|
||||
},
|
||||
paragraph: {
|
||||
spacing: { line: lineSpacing },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...(bgColorHex !== "FFFFFF" ? { background: { color: bgColorHex } } : {}),
|
||||
sections: [
|
||||
{
|
||||
properties: {
|
||||
page: {
|
||||
size: {
|
||||
width: convertMillimetersToTwip(pageSize.width),
|
||||
height: convertMillimetersToTwip(pageSize.height),
|
||||
},
|
||||
margin: {
|
||||
top: marginYTwips,
|
||||
bottom: marginYTwips,
|
||||
left: marginXTwips,
|
||||
right: marginXTwips,
|
||||
},
|
||||
},
|
||||
},
|
||||
children: documentChildren,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import type { IShadingAttributesProperties, ISpacingProperties } from "docx";
|
||||
import { ExternalHyperlink, HeadingLevel, Paragraph, TextRun } from "docx";
|
||||
import { parseColorString } from "@reactive-resume/utils/color";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
export interface HtmlStyleConfig {
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
linkColor?: string;
|
||||
}
|
||||
|
||||
interface InlineStyle {
|
||||
bold?: boolean;
|
||||
italics?: boolean;
|
||||
underline?: Record<string, never>;
|
||||
strike?: boolean;
|
||||
font?: string;
|
||||
size?: number;
|
||||
color?: string;
|
||||
shading?: IShadingAttributesProperties;
|
||||
}
|
||||
|
||||
type InlineChild = TextRun | ExternalHyperlink;
|
||||
type NumberingOptions = { reference: string; level: number; instance?: number };
|
||||
|
||||
/** Module-level link color, set per htmlToParagraphs invocation. */
|
||||
let currentLinkColor = "0563C1";
|
||||
|
||||
const HEADING_MAP: Record<string, (typeof HeadingLevel)[keyof typeof HeadingLevel]> = {
|
||||
H1: HeadingLevel.HEADING_1,
|
||||
H2: HeadingLevel.HEADING_2,
|
||||
H3: HeadingLevel.HEADING_3,
|
||||
H4: HeadingLevel.HEADING_4,
|
||||
H5: HeadingLevel.HEADING_5,
|
||||
H6: HeadingLevel.HEADING_6,
|
||||
};
|
||||
|
||||
function toDocxColorValue(value: string) {
|
||||
const rgba = parseColorString(value);
|
||||
if (!rgba) return null;
|
||||
|
||||
return [rgba.r, rgba.g, rgba.b].map((channel) => channel.toString(16).padStart(2, "0").toUpperCase()).join("");
|
||||
}
|
||||
|
||||
function mergeStyle(parent: InlineStyle, tag: string, element?: HTMLElement): InlineStyle {
|
||||
const next = { ...parent };
|
||||
|
||||
switch (tag) {
|
||||
case "STRONG":
|
||||
case "B":
|
||||
next.bold = true;
|
||||
break;
|
||||
case "EM":
|
||||
case "I":
|
||||
next.italics = true;
|
||||
break;
|
||||
case "U":
|
||||
next.underline = {};
|
||||
break;
|
||||
case "S":
|
||||
case "STRIKE":
|
||||
next.strike = true;
|
||||
break;
|
||||
case "CODE":
|
||||
next.font = "Courier New";
|
||||
break;
|
||||
case "MARK":
|
||||
next.shading = { fill: "FFFF00" };
|
||||
break;
|
||||
}
|
||||
|
||||
const colorValue = (element as HTMLElement | undefined)?.style.color;
|
||||
if (colorValue) {
|
||||
const color = toDocxColorValue(colorValue);
|
||||
if (color) next.color = color;
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function collectInlineChildren(node: Node, style: InlineStyle): InlineChild[] {
|
||||
const children: InlineChild[] = [];
|
||||
|
||||
for (const child of node.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = child.textContent ?? "";
|
||||
if (text) {
|
||||
children.push(new TextRun({ text, ...style }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType !== Node.ELEMENT_NODE) continue;
|
||||
|
||||
const el = child as HTMLElement;
|
||||
const tag = el.tagName;
|
||||
|
||||
if (tag === "BR") {
|
||||
children.push(new TextRun({ break: 1 }));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tag === "A") {
|
||||
const href = toSafeDocxLink(el.getAttribute("href") ?? "");
|
||||
const linkChildren = collectInlineChildren(el, { ...style, color: currentLinkColor, underline: {} });
|
||||
if (linkChildren.length > 0 && href) {
|
||||
children.push(new ExternalHyperlink({ link: href, children: linkChildren as TextRun[] }));
|
||||
} else {
|
||||
children.push(...linkChildren);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const merged = mergeStyle(style, tag, el);
|
||||
children.push(...collectInlineChildren(el, merged));
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
function getNumberingOptions(reference: string, level: number, instance?: number): NumberingOptions {
|
||||
return instance === undefined ? { reference, level } : { reference, level, instance };
|
||||
}
|
||||
|
||||
function processBlockElement(
|
||||
el: HTMLElement,
|
||||
style: InlineStyle,
|
||||
paragraphs: Paragraph[],
|
||||
listLevel?: number,
|
||||
numberingRef?: string,
|
||||
listIndex?: number,
|
||||
): void {
|
||||
const tag = el.tagName;
|
||||
const mergedStyle = mergeStyle(style, tag, el);
|
||||
|
||||
if (HEADING_MAP[tag]) {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ heading: HEADING_MAP[tag], children: inlineChildren }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "P" || tag === "DIV") {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(listLevel != null && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, listLevel, listIndex) }
|
||||
: {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "UL" || tag === "OL") {
|
||||
const isOrdered = tag === "OL";
|
||||
const level = listLevel != null ? listLevel + 1 : 0;
|
||||
|
||||
for (const li of el.children) {
|
||||
if (li.tagName !== "LI") continue;
|
||||
|
||||
const hasNestedBlocks = Array.from(li.children).some(
|
||||
(c) => c.tagName === "UL" || c.tagName === "OL" || c.tagName === "P",
|
||||
);
|
||||
|
||||
if (hasNestedBlocks) {
|
||||
for (const liChild of li.childNodes) {
|
||||
if (liChild.nodeType === Node.TEXT_NODE) {
|
||||
const text = (liChild.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, ...mergedStyle })],
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, level, listIndex) }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (liChild.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(liChild as HTMLElement, mergedStyle, paragraphs, level, numberingRef, listIndex);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const inlineChildren = collectInlineChildren(li, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
...(isOrdered && numberingRef
|
||||
? { numbering: getNumberingOptions(numberingRef, level, listIndex) }
|
||||
: { bullet: { level } }),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "BLOCKQUOTE") {
|
||||
const indent: ISpacingProperties = {};
|
||||
const inlineChildren = collectInlineChildren(el, { ...mergedStyle, italics: true });
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
indent: { left: 720 },
|
||||
spacing: indent,
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "PRE") {
|
||||
const text = el.textContent ?? "";
|
||||
if (text) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text, font: "Courier New", ...mergedStyle })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "HR") {
|
||||
paragraphs.push(new Paragraph({ children: [] }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (tag === "LI") {
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: inlineChildren,
|
||||
bullet: { level: listLevel ?? 0 },
|
||||
}),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: treat as inline container
|
||||
const inlineChildren = collectInlineChildren(el, mergedStyle);
|
||||
if (inlineChildren.length > 0) {
|
||||
paragraphs.push(new Paragraph({ children: inlineChildren }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts an HTML string (from TipTap rich text editor) into an array of docx Paragraphs.
|
||||
* Uses the browser's DOMParser to parse HTML, then walks the DOM tree to produce
|
||||
* structured docx content with proper formatting (bold, italic, lists, links, etc.).
|
||||
*
|
||||
* @param html - The HTML string to convert
|
||||
* @param styleConfig - Optional base typography (font, size, color) to apply to all text runs
|
||||
*/
|
||||
export function htmlToParagraphs(html: string, styleConfig?: HtmlStyleConfig): Paragraph[] {
|
||||
if (!html?.trim()) return [];
|
||||
|
||||
currentLinkColor = styleConfig?.linkColor ?? "0563C1";
|
||||
|
||||
const baseStyle: InlineStyle = {};
|
||||
if (styleConfig?.font) baseStyle.font = styleConfig.font;
|
||||
if (styleConfig?.size) baseStyle.size = styleConfig.size;
|
||||
if (styleConfig?.color) baseStyle.color = styleConfig.color;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(html, "text/html");
|
||||
const paragraphs: Paragraph[] = [];
|
||||
|
||||
for (const child of doc.body.childNodes) {
|
||||
if (child.nodeType === Node.TEXT_NODE) {
|
||||
const text = (child.textContent ?? "").trim();
|
||||
if (text) {
|
||||
paragraphs.push(new Paragraph({ children: [new TextRun({ text, ...baseStyle })] }));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (child.nodeType === Node.ELEMENT_NODE) {
|
||||
processBlockElement(child as HTMLElement, baseStyle, paragraphs);
|
||||
}
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import { Packer } from "docx";
|
||||
import { buildDocument } from "./builder";
|
||||
|
||||
/** Builds a DOCX file from resume data and returns it as a Blob. */
|
||||
export async function buildDocx(data: ResumeData): Promise<Blob> {
|
||||
const doc = buildDocument(data);
|
||||
return Packer.toBlob(doc);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
const SAFE_DOCX_LINK_SCHEMES = new Set(["http:", "https:", "mailto:"]);
|
||||
|
||||
export function toSafeDocxLink(value: string): string | null {
|
||||
const input = value.trim();
|
||||
if (!input) return null;
|
||||
|
||||
if (input.startsWith("mailto:")) {
|
||||
const email = input.slice("mailto:".length).trim();
|
||||
if (!email) return null;
|
||||
return `mailto:${email}`;
|
||||
}
|
||||
|
||||
try {
|
||||
const url = new URL(input);
|
||||
if (!SAFE_DOCX_LINK_SCHEMES.has(url.protocol)) return null;
|
||||
return url.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import type { CustomSection, CustomSectionType, ResumeData, SectionType } from "@reactive-resume/schema/resume/data";
|
||||
import type { HtmlStyleConfig } from "./html-to-docx";
|
||||
import { BorderStyle, ExternalHyperlink, HeadingLevel, Paragraph, TabStopPosition, TabStopType, TextRun } from "docx";
|
||||
import { htmlToParagraphs } from "./html-to-docx";
|
||||
import { toSafeDocxLink } from "./link-utils";
|
||||
|
||||
type Sections = ResumeData["sections"];
|
||||
|
||||
/** Module-level typography/color config, set by the builder before rendering. */
|
||||
let headingFont: string | undefined;
|
||||
let headingSize: number | undefined;
|
||||
let bodyFont: string | undefined;
|
||||
let bodySize: number | undefined;
|
||||
let textColor: string | undefined;
|
||||
let primaryColor: string | undefined;
|
||||
|
||||
/**
|
||||
* Configures the typography and colors used by all section renderers.
|
||||
* Must be called before any render functions.
|
||||
*/
|
||||
export function setRenderConfig(config: {
|
||||
headingFont: string;
|
||||
headingSizeHalfPt: number;
|
||||
bodyFont: string;
|
||||
bodySizeHalfPt: number;
|
||||
textColorHex: string;
|
||||
primaryColorHex: string;
|
||||
}): void {
|
||||
headingFont = config.headingFont;
|
||||
headingSize = config.headingSizeHalfPt;
|
||||
bodyFont = config.bodyFont;
|
||||
bodySize = config.bodySizeHalfPt;
|
||||
textColor = config.textColorHex;
|
||||
primaryColor = config.primaryColorHex;
|
||||
}
|
||||
|
||||
function getHtmlStyle(): HtmlStyleConfig {
|
||||
return {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
...(primaryColor ? { linkColor: primaryColor } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a section heading paragraph with primary color and bottom border.
|
||||
* Uses the heading typography set via `setRenderConfig`.
|
||||
*/
|
||||
function sectionHeading(title: string, colorHex: string): Paragraph {
|
||||
// Section headings use <h6> in templates: calc(var(--page-heading-font-size) * 0.75pt)
|
||||
const h6Size = headingSize ? Math.round(headingSize * 0.75) : 16;
|
||||
|
||||
return new Paragraph({
|
||||
heading: HeadingLevel.HEADING_6,
|
||||
spacing: { before: 240, after: 120 },
|
||||
border: {
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: colorHex.replace("#", "") },
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: title,
|
||||
bold: true,
|
||||
color: colorHex.replace("#", ""),
|
||||
size: h6Size,
|
||||
...(headingFont ? { font: headingFont } : {}),
|
||||
}),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
function titleAndSubtitle(primary: string, secondary: string, rightText?: string): Paragraph {
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: primary, bold: true, ...baseRun })];
|
||||
|
||||
if (secondary) {
|
||||
children.push(new TextRun({ text: ` — ${secondary}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (rightText) {
|
||||
children.push(new TextRun({ text: `\t${rightText}`, italics: true, ...baseRun }));
|
||||
}
|
||||
|
||||
return new Paragraph({
|
||||
tabStops: [{ type: TabStopType.RIGHT, position: TabStopPosition.MAX }],
|
||||
spacing: { before: 120 },
|
||||
children,
|
||||
});
|
||||
}
|
||||
|
||||
function locationAndPeriod(location: string, period: string): Paragraph | null {
|
||||
const parts = [location, period].filter(Boolean);
|
||||
if (parts.length === 0) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: parts.join(" | "),
|
||||
italics: true,
|
||||
color: textColor ?? "666666",
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
function websiteParagraph(url: string, label: string): Paragraph | null {
|
||||
const safeUrl = toSafeDocxLink(url);
|
||||
if (!safeUrl) return null;
|
||||
|
||||
return new Paragraph({
|
||||
children: [
|
||||
new ExternalHyperlink({
|
||||
link: safeUrl,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: label || safeUrl,
|
||||
color: primaryColor ?? "0563C1",
|
||||
underline: {},
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
spacing: { after: 60 },
|
||||
});
|
||||
}
|
||||
|
||||
// --- Section-specific renderers ---
|
||||
|
||||
export function renderSummary(summary: ResumeData["summary"], colorHex: string): Paragraph[] {
|
||||
if (summary.hidden || !summary.content) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [];
|
||||
if (summary.title) {
|
||||
paragraphs.push(sectionHeading(summary.title, colorHex));
|
||||
}
|
||||
paragraphs.push(...htmlToParagraphs(summary.content, getHtmlStyle()));
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderExperience(section: Sections["experience"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
if (item.roles && item.roles.length > 0) {
|
||||
paragraphs.push(titleAndSubtitle(item.company, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
for (const role of item.roles) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
spacing: { before: 80 },
|
||||
children: [
|
||||
new TextRun({ text: role.position, bold: true, italics: true, ...baseRun }),
|
||||
...(role.period ? [new TextRun({ text: ` — ${role.period}`, italics: true, ...baseRun })] : []),
|
||||
],
|
||||
}),
|
||||
);
|
||||
if (role.description) {
|
||||
paragraphs.push(...htmlToParagraphs(role.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
paragraphs.push(titleAndSubtitle(item.company, item.position, item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderEducation(section: Sections["education"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const degreeArea = [item.degree, item.area].filter(Boolean).join(", ");
|
||||
paragraphs.push(titleAndSubtitle(item.school, degreeArea, item.period));
|
||||
|
||||
if (item.grade) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: `Grade: ${item.grade}`, italics: true, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProjects(section: Sections["projects"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.name, "", item.period));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderSkills(section: Sections["skills"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.proficiency) {
|
||||
children.push(new TextRun({ text: ` — ${item.proficiency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderLanguages(section: Sections["languages"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.language, bold: true, ...baseRun })];
|
||||
|
||||
if (item.fluency) {
|
||||
children.push(new TextRun({ text: ` — ${item.fluency}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderInterests(section: Sections["interests"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.keywords.length > 0) {
|
||||
children.push(new TextRun({ text: `: ${item.keywords.join(", ")}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderAwards(section: Sections["awards"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.awarder, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderCertifications(section: Sections["certifications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.issuer, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderPublications(section: Sections["publications"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.title, item.publisher, item.date));
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderVolunteer(section: Sections["volunteer"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
|
||||
for (const item of items) {
|
||||
paragraphs.push(titleAndSubtitle(item.organization, "", item.period));
|
||||
|
||||
const loc = locationAndPeriod(item.location, "");
|
||||
if (loc) paragraphs.push(loc);
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderReferences(section: Sections["references"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: TextRun[] = [new TextRun({ text: item.name, bold: true, ...baseRun })];
|
||||
|
||||
if (item.position) {
|
||||
children.push(new TextRun({ text: ` — ${item.position}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 120 }, children }));
|
||||
|
||||
if (item.phone) {
|
||||
paragraphs.push(
|
||||
new Paragraph({
|
||||
children: [new TextRun({ text: item.phone, ...baseRun })],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (item.description) {
|
||||
paragraphs.push(...htmlToParagraphs(item.description, getHtmlStyle()));
|
||||
}
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
function renderProfiles(section: Sections["profiles"], colorHex: string): Paragraph[] {
|
||||
const items = section.items.filter((item) => !item.hidden);
|
||||
if (section.hidden || items.length === 0) return [];
|
||||
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
const baseRun = {
|
||||
...(bodyFont ? { font: bodyFont } : {}),
|
||||
...(bodySize ? { size: bodySize } : {}),
|
||||
...(textColor ? { color: textColor } : {}),
|
||||
};
|
||||
|
||||
for (const item of items) {
|
||||
const children: (TextRun | ExternalHyperlink)[] = [new TextRun({ text: item.network, bold: true, ...baseRun })];
|
||||
|
||||
if (item.username) {
|
||||
children.push(new TextRun({ text: ` — ${item.username}`, ...baseRun }));
|
||||
}
|
||||
|
||||
paragraphs.push(new Paragraph({ spacing: { before: 60 }, children }));
|
||||
|
||||
const ws = websiteParagraph(item.website.url, item.website.label);
|
||||
if (ws) paragraphs.push(ws);
|
||||
}
|
||||
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mapping from built-in section type to its renderer.
|
||||
*/
|
||||
const sectionRenderers: Record<SectionType, (section: Sections[SectionType], colorHex: string) => Paragraph[]> = {
|
||||
experience: renderExperience as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
education: renderEducation as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
projects: renderProjects as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
skills: renderSkills as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
languages: renderLanguages as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
interests: renderInterests as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
awards: renderAwards as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
certifications: renderCertifications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
publications: renderPublications as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
volunteer: renderVolunteer as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
references: renderReferences as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
profiles: renderProfiles as (section: Sections[SectionType], colorHex: string) => Paragraph[],
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a built-in section by type.
|
||||
*/
|
||||
export function renderBuiltInSection(type: SectionType, section: Sections[SectionType], colorHex: string): Paragraph[] {
|
||||
const renderer = sectionRenderers[type];
|
||||
if (!renderer) return [];
|
||||
return renderer(section, colorHex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a custom section by dispatching to the matching built-in renderer based on its type.
|
||||
*/
|
||||
export function renderCustomSection(section: CustomSection, colorHex: string): Paragraph[] {
|
||||
if (section.hidden) return [];
|
||||
|
||||
const visibleItems = section.items.filter((item) => !item.hidden);
|
||||
if (visibleItems.length === 0) return [];
|
||||
|
||||
const sectionType = section.type as CustomSectionType;
|
||||
|
||||
// Summary-type custom sections render HTML content
|
||||
if (sectionType === "summary") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Cover letter type — render recipient + content
|
||||
if (sectionType === "cover-letter") {
|
||||
const paragraphs: Paragraph[] = [sectionHeading(section.title, colorHex)];
|
||||
for (const item of visibleItems) {
|
||||
if ("recipient" in item && item.recipient) {
|
||||
paragraphs.push(...htmlToParagraphs(item.recipient, getHtmlStyle()));
|
||||
}
|
||||
if ("content" in item && item.content) {
|
||||
paragraphs.push(...htmlToParagraphs(item.content, getHtmlStyle()));
|
||||
}
|
||||
}
|
||||
return paragraphs;
|
||||
}
|
||||
|
||||
// Build a synthetic section matching the built-in type for the renderer
|
||||
const sectionKey = sectionType as SectionType;
|
||||
if (sectionKey in sectionRenderers) {
|
||||
const syntheticSection = {
|
||||
title: section.title,
|
||||
columns: section.columns,
|
||||
hidden: false,
|
||||
items: visibleItems,
|
||||
} as Sections[SectionType];
|
||||
|
||||
return renderBuiltInSection(sectionKey, syntheticSection, colorHex);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { ResumeData } from "@reactive-resume/schema/resume/data";
|
||||
import jsonpatch, { type JsonPatchError, type Operation } from "fast-json-patch";
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
|
||||
/**
|
||||
* A Zod schema that models JSON Patch (RFC 6902) operations as a discriminated union on `op`.
|
||||
* This ensures required fields (`value` for add/replace/test, `from` for move/copy) are
|
||||
* validated at the request boundary rather than failing later at the `fast-json-patch` layer.
|
||||
*/
|
||||
export const jsonPatchOperationSchema = z.discriminatedUnion("op", [
|
||||
z.object({ op: z.literal("add"), path: z.string(), value: z.unknown() }),
|
||||
z.object({ op: z.literal("remove"), path: z.string() }),
|
||||
z.object({ op: z.literal("replace"), path: z.string(), value: z.unknown() }),
|
||||
z.object({ op: z.literal("move"), path: z.string(), from: z.string() }),
|
||||
z.object({ op: z.literal("copy"), path: z.string(), from: z.string() }),
|
||||
z.object({ op: z.literal("test"), path: z.string(), value: z.unknown() }),
|
||||
]);
|
||||
|
||||
/**
|
||||
* A structured error thrown when a JSON Patch operation fails.
|
||||
* Contains only the relevant details -- never the full document tree.
|
||||
*/
|
||||
export class ResumePatchError extends Error {
|
||||
/** The error code from `fast-json-patch`, e.g. `TEST_OPERATION_FAILED`. */
|
||||
code: string;
|
||||
/** The zero-based index of the failing operation in the operations array. */
|
||||
index: number;
|
||||
/** The operation object that caused the failure. */
|
||||
operation: Operation;
|
||||
|
||||
constructor(code: string, message: string, index: number, operation: Operation) {
|
||||
super(message);
|
||||
this.name = "ResumePatchError";
|
||||
this.code = code;
|
||||
this.index = index;
|
||||
this.operation = operation;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Human-readable messages for each `fast-json-patch` error code.
|
||||
* These are returned to the API consumer instead of the raw library output.
|
||||
*/
|
||||
const patchErrorMessages: Record<string, string> = {
|
||||
SEQUENCE_NOT_AN_ARRAY: "Patch sequence must be an array.",
|
||||
OPERATION_NOT_AN_OBJECT: "Operation is not an object.",
|
||||
OPERATION_OP_INVALID: "Operation `op` property is not one of the operations defined in RFC 6902.",
|
||||
OPERATION_PATH_INVALID: "Operation `path` property is not a valid JSON Pointer string.",
|
||||
OPERATION_FROM_REQUIRED: "Operation `from` property is required for `move` and `copy` operations.",
|
||||
OPERATION_VALUE_REQUIRED: "Operation `value` property is required for `add`, `replace`, and `test` operations.",
|
||||
OPERATION_VALUE_CANNOT_CONTAIN_UNDEFINED:
|
||||
"Operation `value` contains an `undefined` value, which is not valid in JSON.",
|
||||
OPERATION_PATH_CANNOT_ADD: "Cannot perform an `add` operation at the desired path.",
|
||||
OPERATION_PATH_UNRESOLVABLE: "Cannot perform the operation at a path that does not exist.",
|
||||
OPERATION_FROM_UNRESOLVABLE: "Cannot perform the operation from a path that does not exist.",
|
||||
OPERATION_PATH_ILLEGAL_ARRAY_INDEX: "Array index in path must be an unsigned base-10 integer.",
|
||||
OPERATION_VALUE_OUT_OF_BOUNDS: "The specified array index is greater than the number of elements in the array.",
|
||||
TEST_OPERATION_FAILED: "Test operation failed -- the value at the given path did not match the expected value.",
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks whether an error is a `JsonPatchError` from `fast-json-patch`.
|
||||
* The library doesn't export the class directly, so we duck-type it.
|
||||
*/
|
||||
function isJsonPatchError(error: unknown): error is JsonPatchError {
|
||||
return error instanceof Error && "index" in error && "operation" in error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a `JsonPatchError` into a clean `ResumePatchError` that omits the document tree.
|
||||
*/
|
||||
function toResumePatchError(error: JsonPatchError): ResumePatchError {
|
||||
const code = error.name;
|
||||
const message = patchErrorMessages[code] ?? error.message;
|
||||
const index = error.index ?? 0;
|
||||
const operation = error.operation as Operation;
|
||||
|
||||
return new ResumePatchError(code, message, index, operation);
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies an array of JSON Patch (RFC 6902) operations to a `ResumeData` object.
|
||||
*
|
||||
* This function validates the operations before applying them, then validates the
|
||||
* resulting document against the `resumeDataSchema` to ensure the patched data is
|
||||
* still a valid resume.
|
||||
*
|
||||
* The original `data` object is not mutated; a deep clone is created internally.
|
||||
*
|
||||
* @see https://docs.rxresu.me/guides/using-the-patch-api — for usage examples and API details.
|
||||
* @see https://datatracker.ietf.org/doc/html/rfc6902 — JSON Patch specification.
|
||||
*
|
||||
* @param data - The current resume data to patch.
|
||||
* @param operations - An array of JSON Patch operations to apply.
|
||||
* @returns The patched and validated `ResumeData` object.
|
||||
* @throws {ResumePatchError} If the operations are structurally invalid or target non-existent paths.
|
||||
* @throws {ResumePatchError} If a `test` operation does not match.
|
||||
* @throws {Error} If the patched document does not conform to the `ResumeData` schema.
|
||||
*/
|
||||
export function applyResumePatches(data: ResumeData, operations: Operation[]): ResumeData {
|
||||
// Validate operations structurally before applying.
|
||||
const validationError = jsonpatch.validate(operations, data);
|
||||
if (validationError) throw toResumePatchError(validationError);
|
||||
|
||||
// Apply operations. applyPatch throws on `test` failures.
|
||||
let patched: ResumeData;
|
||||
|
||||
try {
|
||||
const result = jsonpatch.applyPatch(data, operations, false, false);
|
||||
patched = result.newDocument;
|
||||
} catch (error: unknown) {
|
||||
if (isJsonPatchError(error)) throw toResumePatchError(error);
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Validate the result still conforms to ResumeData.
|
||||
const parsed = resumeDataSchema.safeParse(patched);
|
||||
if (!parsed.success) throw new Error(`Patch produced invalid resume data: ${parsed.error.message}`);
|
||||
|
||||
return parsed.data;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import DOMPurify, { type Config } from "dompurify";
|
||||
|
||||
const RICH_TEXT_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [
|
||||
"p",
|
||||
"br",
|
||||
"hr",
|
||||
"span",
|
||||
"div",
|
||||
"h1",
|
||||
"h2",
|
||||
"h3",
|
||||
"h4",
|
||||
"h5",
|
||||
"h6",
|
||||
"strong",
|
||||
"b",
|
||||
"em",
|
||||
"i",
|
||||
"u",
|
||||
"s",
|
||||
"strike",
|
||||
"mark",
|
||||
"code",
|
||||
"pre",
|
||||
"ul",
|
||||
"ol",
|
||||
"li",
|
||||
"table",
|
||||
"thead",
|
||||
"tbody",
|
||||
"tfoot",
|
||||
"tr",
|
||||
"th",
|
||||
"td",
|
||||
"colgroup",
|
||||
"col",
|
||||
"a",
|
||||
"blockquote",
|
||||
],
|
||||
ALLOWED_ATTR: ["class", "style", "href", "target", "rel", "colspan", "rowspan", "data-type", "data-label"],
|
||||
ALLOWED_URI_REGEXP: /^(?:(?:https?):\/\/|[^a-z]|[a-z+.-]+(?:[^a-z+.\-:]|$))/i,
|
||||
ADD_ATTR: ["target", "rel"],
|
||||
ALLOW_DATA_ATTR: false,
|
||||
};
|
||||
|
||||
const PLAIN_TEXT_SANITIZE_CONFIG: Config = {
|
||||
ALLOWED_TAGS: [],
|
||||
ALLOWED_ATTR: [],
|
||||
KEEP_CONTENT: true,
|
||||
RETURN_TRUSTED_TYPE: false,
|
||||
};
|
||||
|
||||
function sanitizePlainTextContent(value: string): string {
|
||||
return DOMPurify.sanitize(value, PLAIN_TEXT_SANITIZE_CONFIG) as string;
|
||||
}
|
||||
|
||||
function stripCssComments(value: string): string {
|
||||
if (!value) return "";
|
||||
return value.replace(/\/\*[\s\S]*?\*\//g, "");
|
||||
}
|
||||
|
||||
function decodeCssEscapes(value: string): string {
|
||||
if (!value) return "";
|
||||
|
||||
return value.replace(/\\([0-9a-fA-F]{1,6})(?:\r\n|[ \t\r\n\f])?|\\(.)/g, (_match, hex, escapedChar) => {
|
||||
if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
|
||||
return escapedChar ?? "";
|
||||
});
|
||||
}
|
||||
|
||||
export function sanitizeHtml(html: string): string {
|
||||
if (!html) return "";
|
||||
return DOMPurify.sanitize(html, { ...RICH_TEXT_CONFIG, RETURN_TRUSTED_TYPE: false }) as string;
|
||||
}
|
||||
|
||||
export function sanitizeCss(css: string): string {
|
||||
if (!css) return "";
|
||||
|
||||
const normalized = decodeCssEscapes(stripCssComments(css));
|
||||
|
||||
const preSanitized = normalized
|
||||
.replace(/javascript\s*:/gi, "")
|
||||
.replace(/expression\s*\(/gi, "")
|
||||
.replace(/behavior\s*:[^;}]*/gi, "")
|
||||
.replace(/-moz-binding\s*:[^;}]*/gi, "");
|
||||
|
||||
const sanitized = preSanitized
|
||||
.replace(/@import[^;]*;/gi, "")
|
||||
.replace(/@font-face\s*\{[^}]*\}/gi, "")
|
||||
.replace(/\b(?:url|image-set|cross-fade)\s*\([^)]*\)/gi, "")
|
||||
.replace(/^\s*src\s*:[^;]*;?/gim, "");
|
||||
|
||||
return sanitizePlainTextContent(sanitized);
|
||||
}
|
||||
|
||||
export function isObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import _slugify from "@sindresorhus/slugify";
|
||||
import { adjectives, animals, colors, uniqueNamesGenerator } from "unique-names-generator";
|
||||
import { v7 as uuidv7 } from "uuid";
|
||||
|
||||
/**
|
||||
* Generates a unique ID using the UUIDv7 algorithm.
|
||||
* @returns The generated ID.
|
||||
*/
|
||||
export function generateId() {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
/** Slugifies a string, with some pre-defined options.
|
||||
*
|
||||
* @param value - The value to slugify.
|
||||
* @returns The slugified value.
|
||||
*/
|
||||
export function slugify(value: string) {
|
||||
return _slugify(value, { decamelize: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates initials from a name.
|
||||
* @param name - The name to generate initials from.
|
||||
* @returns The initials.
|
||||
*/
|
||||
export function getInitials(name: string) {
|
||||
return name
|
||||
.split(" ")
|
||||
.map((n) => n[0])
|
||||
.slice(0, 2)
|
||||
.join("")
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Transforms a string to a valid username (lowercase, no special characters except for dots, hyphens and underscores).
|
||||
* @param value - The value to transform.
|
||||
* @returns The transformed username.
|
||||
*/
|
||||
export function toUsername(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9._-]/g, "")
|
||||
.slice(0, 64);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a random name using the unique-names-generator library.
|
||||
* @returns The random name.
|
||||
*/
|
||||
export function generateRandomName() {
|
||||
return uniqueNamesGenerator({
|
||||
dictionaries: [adjectives, colors, animals],
|
||||
style: "capital",
|
||||
separator: " ",
|
||||
length: 3,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strips HTML tags from a string and returns the text content.
|
||||
* @param html - The HTML string to strip.
|
||||
* @returns The text content without HTML tags.
|
||||
*/
|
||||
export function stripHtml(html: string | undefined): string {
|
||||
if (!html) return "";
|
||||
return html.replace(/<[^>]*>/g, "").trim();
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ClassValue } from "clsx";
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import { isIP } from "node:net";
|
||||
|
||||
function normalizeHostname(hostname: string) {
|
||||
return hostname.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripIpv6Brackets(hostname: string): string {
|
||||
return hostname.replace(/^\[/, "").replace(/\]$/, "");
|
||||
}
|
||||
|
||||
function isLoopbackOrLocalHostname(hostname: string) {
|
||||
const normalized = normalizeHostname(hostname);
|
||||
return (
|
||||
normalized === "localhost" || normalized === "::1" || normalized === "[::1]" || normalized.endsWith(".localhost")
|
||||
);
|
||||
}
|
||||
|
||||
function isPrivateIPv4(hostname: string) {
|
||||
const [first = 0, second = 0] = hostname.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (Number.isNaN(first) || Number.isNaN(second)) return false;
|
||||
|
||||
if (first === 10) return true;
|
||||
if (first === 127) return true;
|
||||
if (first === 169 && second === 254) return true;
|
||||
if (first === 172 && second >= 16 && second <= 31) return true;
|
||||
if (first === 192 && second === 168) return true;
|
||||
if (first === 0) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isPrivateIPv6(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
return (
|
||||
normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:")
|
||||
);
|
||||
}
|
||||
|
||||
export function isPrivateOrLoopbackHost(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
if (isLoopbackOrLocalHostname(normalized)) return true;
|
||||
|
||||
const ipVersion = isIP(normalized);
|
||||
if (ipVersion === 4) return isPrivateIPv4(normalized);
|
||||
if (ipVersion === 6) return isPrivateIPv6(normalized);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function isOAuthLoopbackRedirectHost(hostname: string) {
|
||||
const normalized = stripIpv6Brackets(normalizeHostname(hostname));
|
||||
return normalized === "localhost" || normalized === "127.0.0.1" || normalized === "::1";
|
||||
}
|
||||
|
||||
export function parseUrl(input: string) {
|
||||
try {
|
||||
return new URL(input);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseAllowedHostList(value?: string) {
|
||||
if (!value) return new Set<string>();
|
||||
|
||||
const hosts = value
|
||||
.split(",")
|
||||
.map((entry) => entry.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
|
||||
return new Set(hosts);
|
||||
}
|
||||
|
||||
export function isAllowedExternalUrl(input: string, allowedHosts: Set<string>) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) return false;
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
if (isPrivateOrLoopbackHost(parsed.hostname)) return false;
|
||||
|
||||
const hostname = normalizeHostname(parsed.hostname);
|
||||
if (allowedHosts.has(hostname)) return true;
|
||||
|
||||
const origin = parsed.origin.toLowerCase();
|
||||
return allowedHosts.has(origin);
|
||||
}
|
||||
|
||||
export function isAllowedOAuthRedirectUri(input: string, trustedOrigins: string[], allowedHosts: Set<string>) {
|
||||
const parsed = parseUrl(input);
|
||||
if (!parsed) return false;
|
||||
if (parsed.username || parsed.password) return false;
|
||||
if (parsed.hash) return false;
|
||||
|
||||
const origin = parsed.origin.toLowerCase();
|
||||
const hostname = normalizeHostname(parsed.hostname);
|
||||
|
||||
if (parsed.protocol === "http:") return isOAuthLoopbackRedirectHost(hostname);
|
||||
if (parsed.protocol !== "https:") return false;
|
||||
if (isPrivateOrLoopbackHost(hostname)) return false;
|
||||
|
||||
if (trustedOrigins.includes(origin)) return true;
|
||||
if (allowedHosts.has(origin)) return true;
|
||||
|
||||
return allowedHosts.has(hostname);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Creates a URL object with a url and label.
|
||||
* Returns empty strings if no URL is provided.
|
||||
*/
|
||||
export function createUrl(url?: string, label?: string): { url: string; label: string } {
|
||||
if (!url) return { url: "", label: "" };
|
||||
return { url, label: label || url };
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["ESNext", "DOM", "DOM.Iterable"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "@reactive-resume/utils",
|
||||
dirname: fileURLToPath(new URL(".", import.meta.url)),
|
||||
});
|
||||
Reference in New Issue
Block a user