Add CV template preview and PDF export pipeline

This commit is contained in:
2026-03-29 00:43:54 +01:00
parent 2392b135c2
commit 839a2ed80d
15 changed files with 2288 additions and 97 deletions
+135
View File
@@ -0,0 +1,135 @@
import { joinLines, splitLines } from "./profileCv";
import { TailoredCvDraft } from "./types";
const DEFAULT_SECTION_ORDER = ["summary", "skills", "experience", "education", "custom"];
export function emptyTailoredCvDraft(): TailoredCvDraft {
return {
templateId: "ats-minimal",
headline: "",
summary: [],
selectedSkills: [],
experience: [],
education: [],
customSections: [],
renderOptions: {
showPhoto: false,
pageMode: "one-page",
accentColor: "slate",
sectionOrder: DEFAULT_SECTION_ORDER,
bulletDensity: "balanced",
},
status: "empty",
renderedText: "",
isLegacyFallback: false,
};
}
function formatDateRange(start?: string | null, end?: string | null, isCurrent?: boolean) {
const normalizedStart = start?.trim();
const normalizedEnd = end?.trim();
if (!normalizedStart && !normalizedEnd) return "";
if (!normalizedStart) return normalizedEnd ?? "";
return `${normalizedStart} - ${isCurrent ? "Present" : normalizedEnd || "Present"}`;
}
export function renderTailoredCvDraftText(source?: Partial<TailoredCvDraft> | null) {
const draft = emptyTailoredCvDraft();
const normalized = {
...draft,
...source,
summary: Array.isArray(source?.summary) ? source.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source.experience.filter(Boolean) : [],
education: Array.isArray(source?.education) ? source.education.filter(Boolean) : [],
customSections: Array.isArray(source?.customSections) ? source.customSections.filter(Boolean) : [],
};
const sections: string[] = [];
if (normalized.headline?.trim()) {
sections.push(normalized.headline.trim());
}
if (normalized.summary.length) {
sections.push(`Professional Summary\n${normalized.summary.map((item) => `- ${item.trim()}`).join("\n")}`);
}
if (normalized.selectedSkills.length) {
sections.push(`Core Skills\n${normalized.selectedSkills.map((item) => item.trim()).join("\n")}`);
}
if (normalized.experience.length) {
const body = normalized.experience.map((item) => {
const header = [item.title, item.company, item.location, formatDateRange(item.start, item.end, item.isCurrent)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const bullets = (item.bullets ?? []).filter(Boolean).map((bullet) => `- ${bullet.trim()}`).join("\n");
return [header, bullets].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Experience\n${body}`);
}
if (normalized.education.length) {
const body = normalized.education.map((item) => {
const header = [item.qualification, item.institution, item.location, formatDateRange(item.start, item.end, false)]
.map((value) => value?.trim())
.filter(Boolean)
.join(" | ");
const details = (item.details ?? []).filter(Boolean).map((detail) => `- ${detail.trim()}`).join("\n");
return [header, details].filter(Boolean).join("\n");
}).filter(Boolean).join("\n\n");
if (body) sections.push(`Education\n${body}`);
}
normalized.customSections.forEach((section) => {
const title = section.title?.trim() || "Additional Information";
const items = (section.items ?? []).filter(Boolean).map((item) => item.trim()).join("\n");
if (items) sections.push(`${title}\n${items}`);
});
return sections.join("\n\n").trim();
}
export function normalizeTailoredCvDraft(source?: Partial<TailoredCvDraft> | null): TailoredCvDraft {
const empty = emptyTailoredCvDraft();
const normalized: TailoredCvDraft = {
...empty,
...source,
templateId: source?.templateId?.trim() || empty.templateId,
headline: source?.headline ?? "",
summary: Array.isArray(source?.summary) ? source!.summary.filter(Boolean) : [],
selectedSkills: Array.isArray(source?.selectedSkills) ? source!.selectedSkills.filter(Boolean) : [],
experience: Array.isArray(source?.experience) ? source!.experience.map((item) => ({
title: item?.title ?? "",
company: item?.company ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
isCurrent: Boolean(item?.isCurrent),
bullets: Array.isArray(item?.bullets) ? item!.bullets.filter(Boolean) : [],
})) : [],
education: Array.isArray(source?.education) ? source!.education.map((item) => ({
qualification: item?.qualification ?? "",
institution: item?.institution ?? "",
location: item?.location ?? "",
start: item?.start ?? "",
end: item?.end ?? "",
details: Array.isArray(item?.details) ? item!.details.filter(Boolean) : [],
})) : [],
customSections: Array.isArray(source?.customSections) ? source!.customSections.map((item) => ({
title: item?.title ?? "",
items: Array.isArray(item?.items) ? item!.items.filter(Boolean) : [],
})) : [],
renderOptions: {
...empty.renderOptions,
...source?.renderOptions,
sectionOrder: Array.isArray(source?.renderOptions?.sectionOrder) && source.renderOptions.sectionOrder.length > 0
? source.renderOptions.sectionOrder.filter(Boolean)
: DEFAULT_SECTION_ORDER,
},
status: source?.status?.trim() || empty.status,
renderedText: "",
isLegacyFallback: Boolean(source?.isLegacyFallback),
};
normalized.renderedText = renderTailoredCvDraftText(normalized);
return normalized;
}
export { joinLines, splitLines };