diff --git a/.gitignore b/.gitignore index 527e5dd..0896fee 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ docker-compose.nvidia.yml # Docker manifest staging (generated by .docker/copy-manifests.sh) .docker/.manifests/ + +# Local file-storage output (PDF_OUTPUT_DIR default) +pdf-output/ diff --git a/apps/api/src/modules/cv-template/cv-template.module.ts b/apps/api/src/modules/cv-template/cv-template.module.ts index 7956a86..52c278d 100644 --- a/apps/api/src/modules/cv-template/cv-template.module.ts +++ b/apps/api/src/modules/cv-template/cv-template.module.ts @@ -1,6 +1,7 @@ import { AuthorizationModule, BaseModule, + BUILT_IN_TEMPLATE_SOURCE, CVDataAssemblerService, CVPolicy, CVRendererService, @@ -9,8 +10,8 @@ import { CVTemplateSeedService, CVTemplateService, DatabaseModule, - FrequencySkillPrioritiser, - SKILL_PRIORITISER, + FileBuiltInTemplateSource, + SKILL_PRIORITISER_PROVIDER, } from "@cv/core"; import { Module } from "@nestjs/common"; import { AuthenticationModule } from "@/modules/authentication/authentication.module"; @@ -45,7 +46,8 @@ import { PdfDownloadController } from "./pdf-download.controller"; CVDataLoaderService, CVDataAssemblerService, CVRendererService, - { provide: SKILL_PRIORITISER, useClass: FrequencySkillPrioritiser }, + SKILL_PRIORITISER_PROVIDER, + { provide: BUILT_IN_TEMPLATE_SOURCE, useClass: FileBuiltInTemplateSource }, ], exports: [CVTemplateService, CVService], }) diff --git a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts index dfe8175..e4790a7 100644 --- a/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts +++ b/packages/core/src/modules/cv-template/__tests__/cv-data-assembler.service.spec.ts @@ -8,7 +8,6 @@ const buildMockPrisma = () => ({ userJobExperience: { findMany: vi.fn() }, education: { findMany: vi.fn() }, profile: { findUniqueOrThrow: vi.fn() }, - credentials: { findFirst: vi.fn() }, userFile: { findUnique: vi.fn() }, }); @@ -55,21 +54,17 @@ describe("CVDataAssemblerService", () => { fullName: "Jane Doe", headline: "Staff Engineer", phone: "+31 6 12345678", - locationId: "loc-amsterdam", website: "https://janedoe.dev", linkedInUrl: "https://www.linkedin.com/in/janedoe", summary: "Backend engineer.", }); prisma.profile.findUniqueOrThrow.mockResolvedValue({ - userId: "user-1", location: { name: "Amsterdam", type: "CITY", parent: { name: "Netherlands", type: "COUNTRY", parent: null }, }, - }); - prisma.credentials.findFirst.mockResolvedValue({ - email: "jane@example.com", + user: { credentials: { email: "jane@example.com" } }, }); prisma.userJobExperience.findMany.mockResolvedValue([ { @@ -134,6 +129,54 @@ describe("CVDataAssemblerService", () => { ); }); + it("resolves the country through a deep DISTRICT->...->COUNTRY chain", async () => { + setupHappyPath(); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ + location: { + name: "Jordaan", + type: "DISTRICT", + parent: { + name: "Amsterdam", + type: "CITY", + parent: { + name: "Noord-Holland", + type: "PROVINCE", + parent: { name: "Netherlands", type: "COUNTRY", parent: null }, + }, + }, + }, + user: { credentials: { email: "jane@example.com" } }, + }); + + const result = await service.assemble("cv-1"); + + expect(result.profile.location).toBe("Jordaan, Netherlands"); + }); + + it("falls back to the location name when there is no COUNTRY ancestor", async () => { + setupHappyPath(); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ + location: { name: "Noord-Holland", type: "PROVINCE", parent: null }, + user: { credentials: { email: "jane@example.com" } }, + }); + + const result = await service.assemble("cv-1"); + + expect(result.profile.location).toBe("Noord-Holland"); + }); + + it("renders a null location when the profile has none", async () => { + setupHappyPath(); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ + location: null, + user: { credentials: { email: "jane@example.com" } }, + }); + + const result = await service.assemble("cv-1"); + + expect(result.profile.location).toBeNull(); + }); + it("formats dates as `MMM YYYY` and renders open-ended roles with a null endDate", async () => { setupHappyPath(); @@ -164,7 +207,6 @@ describe("CVDataAssemblerService", () => { fullName: null, headline: null, phone: null, - locationId: null, website: null, linkedInUrl: null, summary: null, @@ -177,7 +219,10 @@ describe("CVDataAssemblerService", () => { it("returns null email when no credential row exists for the user", async () => { setupHappyPath(); - prisma.credentials.findFirst.mockResolvedValue(null); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ + location: null, + user: { credentials: null }, + }); const result = await service.assemble("cv-1"); @@ -270,14 +315,15 @@ describe("CVDataAssemblerService avatar inlining", () => { fullName: "Jane Doe", headline: null, phone: null, - locationId: null, website: null, linkedInUrl: null, summary: null, avatarFileId: overrides?.avatarFileId ?? null, }); - prisma.profile.findUniqueOrThrow.mockResolvedValue({ userId: "user-1" }); - prisma.credentials.findFirst.mockResolvedValue(null); + prisma.profile.findUniqueOrThrow.mockResolvedValue({ + location: null, + user: { credentials: null }, + }); prisma.userJobExperience.findMany.mockResolvedValue([]); prisma.education.findMany.mockResolvedValue([]); return { prisma, profileService, storage }; diff --git a/packages/core/src/modules/cv-template/built-in-template-source.ts b/packages/core/src/modules/cv-template/built-in-template-source.ts new file mode 100644 index 0000000..ed871bd --- /dev/null +++ b/packages/core/src/modules/cv-template/built-in-template-source.ts @@ -0,0 +1,95 @@ +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Injectable } from "@nestjs/common"; + +export interface BuiltInTemplate { + name: string; + description: string; + engine: string; + body: string; + css: string; +} + +/** + * Source of the built-in (developer-authored) CV templates the seeder loads. + * Behind an interface so the backing store can change later (e.g. a + * DB-authoritative model for custom/per-org templates) without touching the + * seeder. See CVG-173. + */ +export interface BuiltInTemplateSource { + load(): BuiltInTemplate[]; +} + +export const BUILT_IN_TEMPLATE_SOURCE = Symbol("BUILT_IN_TEMPLATE_SOURCE"); + +interface TemplateSpec { + slug: string; + name: string; + description: string; + engine: string; +} + +const TEMPLATE_SPECS: TemplateSpec[] = [ + { + slug: "modern-professional", + name: "Modern Professional", + description: "A clean, modern template perfect for tech professionals", + engine: "handlebars", + }, + { + slug: "classic-executive", + name: "Classic Executive", + description: "A traditional template suitable for executive positions", + engine: "handlebars", + }, + { + slug: "creative-portfolio", + name: "Creative Portfolio", + description: "A creative template for designers and artists", + engine: "handlebars", + }, + { + slug: "minimal-clean", + name: "Minimal Clean", + description: "A minimal, single-column template that puts content first", + engine: "handlebars", + }, + { + slug: "technical-resume", + name: "Technical Resume", + description: + "A two-column template highlighting skills for technical roles", + engine: "handlebars", + }, +]; + +/** + * Reads the built-in templates from the bundled `templates/` directory. Files + * are read lazily in load() (not at module-load) so consumers that import the + * module without seeding don't pay the readFileSync cost or crash when the + * directory is absent from the image. + */ +@Injectable() +export class FileBuiltInTemplateSource implements BuiltInTemplateSource { + private readonly dir = join(__dirname, "templates"); + + load(): BuiltInTemplate[] { + return TEMPLATE_SPECS.map(({ slug, ...meta }) => ({ + ...meta, + body: this.readAsset(slug, "hbs"), + css: this.readAsset(slug, "css"), + })); + } + + private readAsset(slug: string, ext: "hbs" | "css"): string { + const path = join(this.dir, `${slug}.${ext}`); + try { + return readFileSync(path, "utf-8"); + } catch (cause) { + // Name the missing template instead of leaking a raw fs ENOENT - this + // repo has a history of .hbs/.css assets missing from the prod image + // (CVG-133/CVG-134). + throw new Error(`Built-in template asset missing: ${path}`, { cause }); + } + } +} diff --git a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts index cfa0830..929db23 100644 --- a/packages/core/src/modules/cv-template/cv-data-assembler.service.ts +++ b/packages/core/src/modules/cv-template/cv-data-assembler.service.ts @@ -1,6 +1,7 @@ import { CVRenderContext } from "@cv/cv-renderer"; import { FILE_STORAGE, type FileStorage } from "@cv/file-storage"; import { Inject, Injectable, Logger, Optional } from "@nestjs/common"; +import type { LocationType } from "@prisma/client"; import { ClockService } from "../../shared"; import { PrismaService } from "../database"; import { ProfileService } from "../profile/profile.service"; @@ -31,17 +32,57 @@ const computeDuration = (start: Date, end: Date | null, now: Date): string => { const totalMonths = (endDate.getFullYear() - start.getFullYear()) * 12 + (endDate.getMonth() - start.getMonth()); - const years = Math.floor(totalMonths / 12); const months = totalMonths % 12; - const parts: string[] = []; - if (years > 0) parts.push(`${years}y`); - if (months > 0) parts.push(`${months}m`); + return ( + [years > 0 && `${years}y`, months > 0 && `${months}m`] + .filter(Boolean) + .join(" ") || "<1m" + ); +}; + +interface LocationNode { + name: string; + type: LocationType; + parent?: LocationNode | null; +} - return parts.length === 0 ? "<1m" : parts.join(" "); +const ancestorOfType = ( + location: LocationNode | null | undefined, + type: LocationType, +): LocationNode | null => + location == null + ? null + : location.type === type + ? location + : ancestorOfType(location.parent, type); + +const formatLocation = ( + location: LocationNode | null | undefined, +): string | null => { + if (location == null) { + return null; + } + const country = ancestorOfType(location.parent, "COUNTRY"); + return [location.name, country?.name].filter(Boolean).join(", ") || null; }; +interface TimelineSource { + startDate: Date; + endDate: Date | null; + description: string | null; + skills: ReadonlyArray<{ name: string }>; +} + +const toTimelineEntry = (entry: TimelineSource, now: Date) => ({ + startDate: formatDate(entry.startDate), + endDate: entry.endDate ? formatDate(entry.endDate) : null, + duration: computeDuration(entry.startDate, entry.endDate, now), + description: entry.description ?? null, + skills: entry.skills.map((s) => s.name), +}); + /** * Optional signals that personalise CV assembly. Forward-extensible: future * pipeline steps (description-tailoring, length-budget, company research, ...) @@ -96,7 +137,10 @@ export class CVDataAssemblerService { ): Promise { const cv = await this.prisma.cV.findUniqueOrThrow({ where: { id: cvId } }); - const [profile, experiences, educations] = await Promise.all([ + // `profileService` decrypts profile fields; `relations` adds the location + // hierarchy + email (via the user relation) the domain object doesn't carry. + // include depth tracks the location hierarchy max (COUNTRY→…→DISTRICT). + const [profile, experiences, educations, relations] = await Promise.all([ this.profileService.findByIdOrFail(cv.profileId), this.prisma.userJobExperience.findMany({ where: { profileId: cv.profileId }, @@ -108,46 +152,32 @@ export class CVDataAssemblerService { orderBy: { startDate: "desc" }, include: { institution: true, skills: true }, }), - ]); - - const profileRecord = await this.prisma.profile.findUniqueOrThrow({ - where: { id: cv.profileId }, - select: { - userId: true, - location: { - include: { - parent: { include: { parent: { include: { parent: true } } } }, + this.prisma.profile.findUniqueOrThrow({ + where: { id: cv.profileId }, + select: { + location: { + include: { + parent: { include: { parent: { include: { parent: true } } } }, + }, }, + user: { select: { credentials: { select: { email: true } } } }, }, - }, - }); - - const credential = await this.prisma.credentials.findFirst({ - where: { userId: profileRecord.userId }, - select: { email: true }, - }); + }), + ]); const now = this.clock.now(); const experienceItems = experiences.map((exp) => ({ company: exp.company.name, role: exp.role.name, level: exp.level?.name ?? null, - startDate: formatDate(exp.startDate), - endDate: exp.endDate ? formatDate(exp.endDate) : null, - duration: computeDuration(exp.startDate, exp.endDate ?? null, now), - description: exp.description ?? null, - skills: exp.skills.map((s) => s.name), + ...toTimelineEntry(exp, now), })); const educationItems = educations.map((edu) => ({ institution: edu.institution.name, degree: edu.degree, fieldOfStudy: edu.fieldOfStudy ?? null, - startDate: formatDate(edu.startDate), - endDate: edu.endDate ? formatDate(edu.endDate) : null, - duration: computeDuration(edu.startDate, edu.endDate ?? null, now), - description: edu.description ?? null, - skills: edu.skills.map((s) => s.name), + ...toTimelineEntry(edu, now), })); const vacancySkills = context?.vacancy?.skills.map((s) => s.name); @@ -160,33 +190,23 @@ export class CVDataAssemblerService { profile.avatarFileId ?? null, ); - const location = profileRecord.location; - const country = [ - location?.parent, - location?.parent?.parent, - location?.parent?.parent?.parent, - ].find((l) => l?.type === "COUNTRY"); - const locationLabel = location - ? [location.name, country?.name].filter(Boolean).join(", ") - : null; - return { cv: { title: cv.title, introduction: cv.introduction ?? null }, profile: { name: profile.fullName ?? profile.name, headline: profile.headline ?? null, phone: profile.phone ?? null, - location: locationLabel, + location: formatLocation(relations.location), website: profile.website ?? null, linkedInUrl: profile.linkedInUrl ?? null, summary: profile.summary ?? null, - email: credential?.email ?? null, + email: relations.user?.credentials?.email ?? null, avatarDataUri, }, experience: experienceItems, education: educationItems, allSkills, - generatedAt: new Date().toISOString(), + generatedAt: this.clock.now().toISOString(), }; } } diff --git a/packages/core/src/modules/cv-template/cv-template.module.ts b/packages/core/src/modules/cv-template/cv-template.module.ts index f59b2e8..9b7f86f 100644 --- a/packages/core/src/modules/cv-template/cv-template.module.ts +++ b/packages/core/src/modules/cv-template/cv-template.module.ts @@ -10,10 +10,7 @@ import { CVDataAssemblerService } from "./cv-data-assembler.service"; import { CVRendererService } from "./cv-renderer.service"; import { CVTemplatePolicy } from "./cv-template.policy"; import { CVTemplateService } from "./cv-template.service"; -import { - SKILL_PRIORITISER, - VacancyAwareSkillPrioritiser, -} from "./skill-prioritiser"; +import { SKILL_PRIORITISER_PROVIDER } from "./skill-prioritiser"; @Module({ imports: [ @@ -30,7 +27,7 @@ import { CVTemplatePolicy, CVDataAssemblerService, CVRendererService, - { provide: SKILL_PRIORITISER, useClass: VacancyAwareSkillPrioritiser }, + SKILL_PRIORITISER_PROVIDER, ], exports: [CVTemplateService, CVService], }) diff --git a/packages/core/src/modules/cv-template/cv-template.seed.ts b/packages/core/src/modules/cv-template/cv-template.seed.ts index 9a6ca4d..5974bfc 100644 --- a/packages/core/src/modules/cv-template/cv-template.seed.ts +++ b/packages/core/src/modules/cv-template/cv-template.seed.ts @@ -1,88 +1,40 @@ -import { readFileSync } from "node:fs"; -import { join } from "node:path"; -import { Injectable, Logger } from "@nestjs/common"; +import { Inject, Injectable, Logger } from "@nestjs/common"; import { PrismaService } from "../database/prisma.service"; import { Seeder } from "../database/seed/seed.service"; import { Seeder as SeederDecorator } from "../database/seed/seeder.decorator"; - -// Templates are read at seed() time, not at module-load. Consumers that -// import this module without calling seed() (e.g. the worker, which only -// needs the rest of @cv/core) don't pay the readFileSync cost or crash -// when dist/modules/cv-template/templates is absent from the image. -const readTemplate = (name: string) => { - const dir = join(__dirname, "templates"); - return { - body: readFileSync(join(dir, `${name}.hbs`), "utf-8"), - css: readFileSync(join(dir, `${name}.css`), "utf-8"), - }; -}; - -interface TemplateSpec { - name: string; - description: string; - filename: string; - engine: string; -} - -const TEMPLATE_SPECS: TemplateSpec[] = [ - { - name: "Modern Professional", - description: "A clean, modern template perfect for tech professionals", - filename: "modern-professional", - engine: "handlebars", - }, - { - name: "Classic Executive", - description: "A traditional template suitable for executive positions", - filename: "classic-executive", - engine: "handlebars", - }, - { - name: "Creative Portfolio", - description: "A creative template for designers and artists", - filename: "creative-portfolio", - engine: "handlebars", - }, -]; +import { + BUILT_IN_TEMPLATE_SOURCE, + type BuiltInTemplateSource, +} from "./built-in-template-source"; @Injectable() @SeederDecorator({ name: "CV Templates", production: true }) export class CVTemplateSeedService implements Seeder { private readonly logger = new Logger(CVTemplateSeedService.name); - constructor(readonly _prisma: PrismaService) {} + constructor( + @Inject(BUILT_IN_TEMPLATE_SOURCE) + private readonly templateSource: BuiltInTemplateSource, + ) {} async seed(prisma: PrismaService): Promise { this.logger.log("Seeding CV templates..."); await Promise.all( - TEMPLATE_SPECS.map(async (spec) => { - const { body, css } = readTemplate(spec.filename); - const template = { - name: spec.name, - description: spec.description, - body, - css, - engine: spec.engine, + this.templateSource.load().map((template) => { + const fields = { + description: template.description, + body: template.body, + css: template.css, + engine: template.engine, }; - - const existing = await prisma["cVTemplate"].findFirst({ + // `name` is unique (add_unique_constraint_to_cv_template_name), so + // upsert is atomic and only ever touches the built-in row by name. + return prisma["cVTemplate"].upsert({ where: { name: template.name }, + update: fields, + create: { name: template.name, ...fields }, }); - - if (existing) { - await prisma["cVTemplate"].update({ - where: { id: existing.id }, - data: { - description: template.description, - body: template.body, - css: template.css, - engine: template.engine, - }, - }); - } else { - await prisma["cVTemplate"].create({ data: template }); - } }), ); } diff --git a/packages/core/src/modules/cv-template/index.ts b/packages/core/src/modules/cv-template/index.ts index c2068d7..c2fe92a 100644 --- a/packages/core/src/modules/cv-template/index.ts +++ b/packages/core/src/modules/cv-template/index.ts @@ -1,3 +1,11 @@ +export type { + BuiltInTemplate, + BuiltInTemplateSource, +} from "./built-in-template-source"; +export { + BUILT_IN_TEMPLATE_SOURCE, + FileBuiltInTemplateSource, +} from "./built-in-template-source"; export { CV } from "./cv.entity"; export type { PrismaCVWithTemplate } from "./cv.mapper"; export { cvMapper } from "./cv.mapper"; @@ -13,6 +21,7 @@ export { CVTemplatePolicy } from "./cv-template.policy"; export { CVTemplateSeedService } from "./cv-template.seed"; export { CVTemplateService } from "./cv-template.service"; export type { + PrioritiseContext, PrioritisedSkill, SkillPrioritiser, SkillSource, @@ -20,4 +29,6 @@ export type { export { FrequencySkillPrioritiser, SKILL_PRIORITISER, + SKILL_PRIORITISER_PROVIDER, + VacancyAwareSkillPrioritiser, } from "./skill-prioritiser"; diff --git a/packages/core/src/modules/cv-template/seed/templates/classic-executive.css b/packages/core/src/modules/cv-template/seed/templates/classic-executive.css deleted file mode 100644 index a45a8d7..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/classic-executive.css +++ /dev/null @@ -1,25 +0,0 @@ -body { - font-family: Georgia, 'Times New Roman', serif; - color: #222; - line-height: 1.5; - font-size: 14px; - padding: 2rem; -} -.cv { max-width: 780px; margin: 0 auto; } -.header { text-align: center; border-bottom: 2px solid #222; padding-bottom: 1rem; margin-bottom: 1.5rem; } -.header h1 { font-size: 2rem; font-weight: 400; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 0.25rem; } -.headline { font-style: italic; color: #555; font-size: 1rem; margin-bottom: 0.5rem; } -.contact-row { display: flex; justify-content: center; flex-wrap: wrap; gap: 1.2rem; font-size: 0.85rem; color: #555; } -.section { margin-bottom: 1.5rem; } -.section h2 { font-size: 1.1rem; text-transform: uppercase; letter-spacing: 0.08em; border-bottom: 1px solid #999; padding-bottom: 0.2rem; margin-bottom: 0.75rem; color: #333; } -.entry { margin-bottom: 1rem; } -.entry-header { display: flex; justify-content: space-between; align-items: baseline; } -.org { color: #555; font-size: 0.9rem; margin-bottom: 0.25rem; } -.dates { font-size: 0.85rem; color: #555; white-space: nowrap; } -.description { margin-top: 0.3rem; color: #333; white-space: pre-line; } -.skill-line { font-size: 0.9rem; color: #444; margin-top: 0.3rem; } -.core-competencies { column-count: 2; column-gap: 2rem; text-align: justify; } -.intro { font-style: italic; color: #444; } -@media print { - body { padding: 0; font-size: 11pt; } -} diff --git a/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs b/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs deleted file mode 100644 index 3b3b2cf..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/classic-executive.hbs +++ /dev/null @@ -1,66 +0,0 @@ -
-
-

{{profile.name}}

- {{#if profile.headline}}

{{profile.headline}}

{{/if}} -
- {{#if profile.email}}{{profile.email}}{{/if}} - {{#if profile.phone}}{{profile.phone}}{{/if}} - {{#if profile.city}}{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}{{/if}} - {{#if profile.website}}{{profile.website}}{{/if}} - {{#if profile.linkedInUrl}}{{profile.linkedInUrl}}{{/if}} -
-
- - {{#if profile.summary}} -
-

Professional Summary

-

{{profile.summary}}

-
- {{/if}} - - {{#if cv.introduction}} -
-

{{cv.introduction}}

-
- {{/if}} - - {{#if (hasItems experience)}} -
-

Professional Experience

- {{#each experience}} -
-
- {{this.role}} - {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} ({{this.duration}}) -
-

{{this.company}}{{#if this.level}}, {{this.level}}{{/if}}

- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} - {{#if (hasItems this.skills)}}

Key skills: {{join this.skills ", "}}

{{/if}} -
- {{/each}} -
- {{/if}} - - {{#if (hasItems education)}} -
-

Education

- {{#each education}} -
-
- {{this.degree}}{{#if this.fieldOfStudy}}, {{this.fieldOfStudy}}{{/if}} - {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} -
-

{{this.institution}}

- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} -
- {{/each}} -
- {{/if}} - - {{#if (hasItems allSkills)}} -
-

Core Competencies

-

{{join (transform (limit allSkills 24) "name") " • "}}

-
- {{/if}} -
diff --git a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css deleted file mode 100644 index 1890c23..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.css +++ /dev/null @@ -1,35 +0,0 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - color: #2d3748; - line-height: 1.6; - font-size: 14px; - margin: 0; - padding: 0; -} -.cv { display: flex; min-height: 100vh; } -.sidebar { width: 280px; background: #1a1a2e; color: #e2e8f0; padding: 2rem 1.5rem; flex-shrink: 0; } -.sidebar-header h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 0.25rem; } -.headline { color: #a78bfa; font-size: 0.95rem; } -.sidebar-section { margin-top: 1.5rem; } -.sidebar-section h3 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.1em; color: #a78bfa; margin-bottom: 0.5rem; } -.sidebar-section p { font-size: 0.85rem; margin-bottom: 0.25rem; } -.sidebar-section a { color: #c4b5fd; text-decoration: none; } -.skill-tags { display: flex; flex-wrap: wrap; gap: 0.3rem; } -.tag { background: rgba(167,139,250,0.2); color: #c4b5fd; padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; } -.main { flex: 1; padding: 2rem; } -.section { margin-bottom: 1.5rem; } -.section h2 { font-size: 1.15rem; color: #1a1a2e; border-bottom: 2px solid #a78bfa; padding-bottom: 0.2rem; margin-bottom: 0.75rem; } -.entry { margin-bottom: 1rem; } -.entry-header { display: flex; justify-content: space-between; align-items: baseline; flex-wrap: wrap; } -.entry-header h3 { font-size: 1rem; font-weight: 600; } -.org { color: #718096; font-size: 0.9rem; } -.dates { font-size: 0.85rem; color: #718096; white-space: nowrap; } -.description { margin-top: 0.3rem; color: #4a5568; white-space: pre-line; } -.intro { color: #718096; font-style: italic; } -.inline-skills { display: flex; flex-wrap: wrap; gap: 0.3rem; margin-top: 0.4rem; } -.inline-tag { background: #ede9fe; color: #6d28d9; padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.75rem; } -@media print { - .cv { display: flex; } - .sidebar { background: #1a1a2e !important; color: #e2e8f0 !important; } - .tag { border: 1px solid #a78bfa; } -} diff --git a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs b/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs deleted file mode 100644 index fa23013..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/creative-portfolio.hbs +++ /dev/null @@ -1,72 +0,0 @@ -
- - -
- {{#if profile.summary}} -
-

Profile

-

{{profile.summary}}

-
- {{/if}} - - {{#if cv.introduction}} -
-

{{cv.introduction}}

-
- {{/if}} - - {{#if (hasItems experience)}} -
-

Experience

- {{#each experience}} -
-
-

{{this.role}}

- {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} · {{this.duration}} -
-

{{this.company}}{{#if this.level}} · {{this.level}}{{/if}}

- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} - {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} -
- {{/each}} -
- {{/if}} - - {{#if (hasItems education)}} -
-

Education

- {{#each education}} -
-
-

{{this.degree}}

- {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} -
-

{{this.institution}}{{#if this.fieldOfStudy}} · {{this.fieldOfStudy}}{{/if}}

- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} -
- {{/each}} -
- {{/if}} -
-
diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.css b/packages/core/src/modules/cv-template/seed/templates/modern-professional.css deleted file mode 100644 index 0ae55b6..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/modern-professional.css +++ /dev/null @@ -1,31 +0,0 @@ -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - color: #1a1a2e; - line-height: 1.6; - font-size: 14px; - padding: 2rem; -} -.cv { max-width: 800px; margin: 0 auto; } -.header { display: flex; align-items: center; gap: 1.25rem; border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } -.header-text { flex: 1; min-width: 0; } -.avatar { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } -.header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; } -.headline { color: #2563eb; font-size: 1.1rem; margin-bottom: 0.5rem; } -.contact-row { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: #555; } -.contact-row a { color: #2563eb; text-decoration: none; } -.section { margin-bottom: 1.5rem; } -.section h2 { font-size: 1.2rem; text-transform: uppercase; letter-spacing: 0.05em; color: #2563eb; border-bottom: 1px solid #e5e7eb; padding-bottom: 0.25rem; margin-bottom: 0.75rem; } -.entry { margin-bottom: 1rem; } -.entry-header { display: flex; justify-content: space-between; align-items: flex-start; } -.entry-header h3 { font-size: 1rem; font-weight: 600; } -.company { color: #555; font-size: 0.9rem; } -.dates { text-align: right; font-size: 0.85rem; color: #555; white-space: nowrap; } -.duration { display: block; font-size: 0.8rem; color: #888; } -.description { margin-top: 0.4rem; color: #333; white-space: pre-line; } -.skills { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-top: 0.5rem; } -.tag { background: #eff6ff; color: #2563eb; padding: 0.15rem 0.6rem; border-radius: 9999px; font-size: 0.8rem; } -@media print { - body { padding: 0; font-size: 11pt; } - .header { border-color: #2563eb; } - .tag { border: 1px solid #2563eb; background: transparent; } -} diff --git a/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs b/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs deleted file mode 100644 index fa8a7eb..0000000 --- a/packages/core/src/modules/cv-template/seed/templates/modern-professional.hbs +++ /dev/null @@ -1,81 +0,0 @@ -
-
- {{#if profile.avatarDataUri}}{{/if}} -
-

{{profile.name}}

- {{#if profile.headline}}

{{profile.headline}}

{{/if}} -
- {{#if profile.email}}{{profile.email}}{{/if}} - {{#if profile.phone}}{{profile.phone}}{{/if}} - {{#if profile.city}}{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}{{/if}} - {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} - {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} -
-
-
- - {{#if profile.summary}} -
-

Profile

-

{{profile.summary}}

-
- {{/if}} - - {{#if cv.introduction}} -
-

About This CV

-

{{cv.introduction}}

-
- {{/if}} - - {{#if (hasItems experience)}} -
-

Experience

- {{#each experience}} -
-
-
-

{{this.role}}

-

{{this.company}}{{#if this.level}} · {{this.level}}{{/if}}

-
-
- {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} - {{this.duration}} -
-
- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} - {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} -
- {{/each}} -
- {{/if}} - - {{#if (hasItems education)}} -
-

Education

- {{#each education}} -
-
-
-

{{this.degree}}

-

{{this.institution}}{{#if this.fieldOfStudy}} · {{this.fieldOfStudy}}{{/if}}

-
-
- {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} - {{this.duration}} -
-
- {{#if this.description}}
{{{markdown this.description}}}
{{/if}} - {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} -
- {{/each}} -
- {{/if}} - - {{#if (hasItems allSkills)}} -
-

Skills

-
{{#each (limit allSkills 20)}}{{this.name}}{{/each}}
-
- {{/if}} -
diff --git a/packages/core/src/modules/cv-template/skill-prioritiser.ts b/packages/core/src/modules/cv-template/skill-prioritiser.ts index 4789c01..eb1507a 100644 --- a/packages/core/src/modules/cv-template/skill-prioritiser.ts +++ b/packages/core/src/modules/cv-template/skill-prioritiser.ts @@ -1,4 +1,4 @@ -import { Injectable } from "@nestjs/common"; +import { Injectable, type Provider } from "@nestjs/common"; export interface PrioritisedSkill { name: string; @@ -77,3 +77,14 @@ export class VacancyAwareSkillPrioritiser implements SkillPrioritiser { ]; } } + +/** + * Single wiring for `SKILL_PRIORITISER`, spread into every module that provides + * it so the impl is defined once and cannot diverge between modules (CVG-175). + * `VacancyAwareSkillPrioritiser` already degrades to plain frequency order when + * no vacancy skills are present, so it covers both cases without a dispatcher. + */ +export const SKILL_PRIORITISER_PROVIDER: Provider = { + provide: SKILL_PRIORITISER, + useClass: VacancyAwareSkillPrioritiser, +}; diff --git a/packages/core/src/modules/cv-template/templates/classic-executive.hbs b/packages/core/src/modules/cv-template/templates/classic-executive.hbs index fdd9ba2..b3b91c3 100644 --- a/packages/core/src/modules/cv-template/templates/classic-executive.hbs +++ b/packages/core/src/modules/cv-template/templates/classic-executive.hbs @@ -6,8 +6,8 @@ {{#if profile.email}}{{profile.email}}{{/if}} {{#if profile.phone}}{{profile.phone}}{{/if}} {{#if profile.location}}{{profile.location}}{{/if}} - {{#if profile.website}}{{profile.website}}{{/if}} - {{#if profile.linkedInUrl}}{{profile.linkedInUrl}}{{/if}} + {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} + {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} @@ -34,7 +34,7 @@ {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} ({{this.duration}})

{{this.company}}{{#if this.level}}, {{this.level}}{{/if}}

- {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{#if (hasItems this.skills)}}

Key skills: {{join this.skills ", "}}

{{/if}} {{/each}} @@ -51,7 +51,7 @@ {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}

{{this.institution}}

- {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{/each}} diff --git a/packages/core/src/modules/cv-template/templates/creative-portfolio.css b/packages/core/src/modules/cv-template/templates/creative-portfolio.css index 1890c23..9631620 100644 --- a/packages/core/src/modules/cv-template/templates/creative-portfolio.css +++ b/packages/core/src/modules/cv-template/templates/creative-portfolio.css @@ -8,6 +8,7 @@ body { } .cv { display: flex; min-height: 100vh; } .sidebar { width: 280px; background: #1a1a2e; color: #e2e8f0; padding: 2rem 1.5rem; flex-shrink: 0; } +.avatar { width: 120px; height: 120px; border-radius: 50%; object-fit: cover; display: block; margin: 0 auto 1rem; border: 3px solid rgba(167,139,250,0.4); } .sidebar-header h1 { font-size: 1.5rem; font-weight: 700; color: #fff; margin-bottom: 0.25rem; } .headline { color: #a78bfa; font-size: 0.95rem; } .sidebar-section { margin-top: 1.5rem; } diff --git a/packages/core/src/modules/cv-template/templates/creative-portfolio.hbs b/packages/core/src/modules/cv-template/templates/creative-portfolio.hbs index 2c22173..b06b9da 100644 --- a/packages/core/src/modules/cv-template/templates/creative-portfolio.hbs +++ b/packages/core/src/modules/cv-template/templates/creative-portfolio.hbs @@ -1,6 +1,7 @@
{{#if (hasItems allSkills)}} @@ -46,7 +47,7 @@ {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}} · {{this.duration}}

{{this.company}}{{#if this.level}} · {{this.level}}{{/if}}

- {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} {{/each}} @@ -63,7 +64,7 @@ {{this.startDate}} – {{#if this.endDate}}{{this.endDate}}{{else}}Present{{/if}}

{{this.institution}}{{#if this.fieldOfStudy}} · {{this.fieldOfStudy}}{{/if}}

- {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{/each}} diff --git a/packages/core/src/modules/cv-template/seed/templates/minimal-clean.css b/packages/core/src/modules/cv-template/templates/minimal-clean.css similarity index 85% rename from packages/core/src/modules/cv-template/seed/templates/minimal-clean.css rename to packages/core/src/modules/cv-template/templates/minimal-clean.css index 9a7be84..bd15e26 100644 --- a/packages/core/src/modules/cv-template/seed/templates/minimal-clean.css +++ b/packages/core/src/modules/cv-template/templates/minimal-clean.css @@ -7,7 +7,9 @@ body { font-weight: 300; } .cv { max-width: 700px; margin: 0 auto; } -.header { margin-bottom: 3rem; } +.header { display: flex; align-items: center; gap: 1.5rem; margin-bottom: 3rem; } +.avatar { width: 80px; height: 80px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } +.header-text { flex: 1; min-width: 0; } .header h1 { font-size: 2.4rem; font-weight: 200; letter-spacing: 0.04em; margin-bottom: 0.15rem; } .headline { font-size: 1rem; color: #666; font-weight: 300; margin-bottom: 0.75rem; } .contact { display: flex; flex-wrap: wrap; gap: 1.5rem; font-size: 0.8rem; color: #888; letter-spacing: 0.02em; } diff --git a/packages/core/src/modules/cv-template/seed/templates/minimal-clean.hbs b/packages/core/src/modules/cv-template/templates/minimal-clean.hbs similarity index 64% rename from packages/core/src/modules/cv-template/seed/templates/minimal-clean.hbs rename to packages/core/src/modules/cv-template/templates/minimal-clean.hbs index f95255d..ad862c8 100644 --- a/packages/core/src/modules/cv-template/seed/templates/minimal-clean.hbs +++ b/packages/core/src/modules/cv-template/templates/minimal-clean.hbs @@ -1,25 +1,28 @@
-

{{profile.name}}

- {{#if profile.headline}}

{{profile.headline}}

{{/if}} -
- {{#if profile.email}}{{profile.email}}{{/if}} - {{#if profile.phone}}{{profile.phone}}{{/if}} - {{#if profile.city}}{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}{{/if}} - {{#if profile.website}}{{profile.website}}{{/if}} - {{#if profile.linkedInUrl}}{{profile.linkedInUrl}}{{/if}} + {{#if profile.avatarDataUri}}{{/if}} +
+

{{profile.name}}

+ {{#if profile.headline}}

{{profile.headline}}

{{/if}} +
+ {{#if profile.email}}{{profile.email}}{{/if}} + {{#if profile.phone}}{{profile.phone}}{{/if}} + {{#if profile.location}}{{profile.location}}{{/if}} + {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} + {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} +
{{#if profile.summary}}
-

{{profile.summary}}

+
{{{markdown profile.summary}}}
{{/if}} {{#if cv.introduction}}
-

{{cv.introduction}}

+
{{{markdown cv.introduction}}}
{{/if}} diff --git a/packages/core/src/modules/cv-template/templates/modern-professional.css b/packages/core/src/modules/cv-template/templates/modern-professional.css index 0840be5..0ae55b6 100644 --- a/packages/core/src/modules/cv-template/templates/modern-professional.css +++ b/packages/core/src/modules/cv-template/templates/modern-professional.css @@ -6,7 +6,9 @@ body { padding: 2rem; } .cv { max-width: 800px; margin: 0 auto; } -.header { border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } +.header { display: flex; align-items: center; gap: 1.25rem; border-bottom: 2px solid #2563eb; padding-bottom: 1rem; margin-bottom: 1.5rem; } +.header-text { flex: 1; min-width: 0; } +.avatar { width: 96px; height: 96px; border-radius: 50%; object-fit: cover; flex-shrink: 0; } .header h1 { font-size: 2rem; font-weight: 700; margin-bottom: 0.25rem; } .headline { color: #2563eb; font-size: 1.1rem; margin-bottom: 0.5rem; } .contact-row { display: flex; flex-wrap: wrap; gap: 1rem; font-size: 0.85rem; color: #555; } diff --git a/packages/core/src/modules/cv-template/templates/modern-professional.hbs b/packages/core/src/modules/cv-template/templates/modern-professional.hbs index 3cc959b..89b41a2 100644 --- a/packages/core/src/modules/cv-template/templates/modern-professional.hbs +++ b/packages/core/src/modules/cv-template/templates/modern-professional.hbs @@ -1,13 +1,16 @@
-

{{profile.name}}

- {{#if profile.headline}}

{{profile.headline}}

{{/if}} -
- {{#if profile.email}}{{profile.email}}{{/if}} - {{#if profile.phone}}{{profile.phone}}{{/if}} - {{#if profile.location}}{{profile.location}}{{/if}} - {{#if profile.website}}{{profile.website}}{{/if}} - {{#if profile.linkedInUrl}}LinkedIn{{/if}} + {{#if profile.avatarDataUri}}{{/if}} +
+

{{profile.name}}

+ {{#if profile.headline}}

{{profile.headline}}

{{/if}} +
+ {{#if profile.email}}{{profile.email}}{{/if}} + {{#if profile.phone}}{{profile.phone}}{{/if}} + {{#if profile.location}}{{profile.location}}{{/if}} + {{#if (safeUrl profile.website)}}{{profile.website}}{{/if}} + {{#if (safeUrl profile.linkedInUrl)}}LinkedIn{{/if}} +
@@ -40,7 +43,7 @@ {{this.duration}}
- {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} {{/each}} @@ -62,7 +65,7 @@ {{this.duration}} - {{#if this.description}}

{{this.description}}

{{/if}} + {{#if this.description}}
{{{markdown this.description}}}
{{/if}} {{#if (hasItems this.skills)}}
{{#each this.skills}}{{this}}{{/each}}
{{/if}} {{/each}} diff --git a/packages/core/src/modules/cv-template/seed/templates/technical-resume.css b/packages/core/src/modules/cv-template/templates/technical-resume.css similarity index 100% rename from packages/core/src/modules/cv-template/seed/templates/technical-resume.css rename to packages/core/src/modules/cv-template/templates/technical-resume.css diff --git a/packages/core/src/modules/cv-template/seed/templates/technical-resume.hbs b/packages/core/src/modules/cv-template/templates/technical-resume.hbs similarity index 87% rename from packages/core/src/modules/cv-template/seed/templates/technical-resume.hbs rename to packages/core/src/modules/cv-template/templates/technical-resume.hbs index 8b2b680..7d4a485 100644 --- a/packages/core/src/modules/cv-template/seed/templates/technical-resume.hbs +++ b/packages/core/src/modules/cv-template/templates/technical-resume.hbs @@ -7,21 +7,21 @@
{{#if profile.email}}

{{profile.email}}

{{/if}} {{#if profile.phone}}

{{profile.phone}}

{{/if}} - {{#if profile.city}}

{{profile.city}}{{#if profile.country}}, {{profile.country}}{{/if}}

{{/if}} - {{#if profile.website}}

{{profile.website}}

{{/if}} - {{#if profile.linkedInUrl}}

{{profile.linkedInUrl}}

{{/if}} + {{#if profile.location}}

{{profile.location}}

{{/if}} + {{#if (safeUrl profile.website)}}

{{profile.website}}

{{/if}} + {{#if (safeUrl profile.linkedInUrl)}}

LinkedIn

{{/if}}
{{#if profile.summary}}
-

{{profile.summary}}

+ {{{markdown profile.summary}}}
{{/if}} {{#if cv.introduction}}
-

{{cv.introduction}}

+ {{{markdown cv.introduction}}}
{{/if}} diff --git a/packages/cv-renderer/src/__test-fixtures__/index.ts b/packages/cv-renderer/src/__test-fixtures__/index.ts index 00e0ea4..aa05d84 100644 --- a/packages/cv-renderer/src/__test-fixtures__/index.ts +++ b/packages/cv-renderer/src/__test-fixtures__/index.ts @@ -59,13 +59,14 @@ export const full: CVRenderContext = { name: "Jane Doe", headline: "Senior Software Engineer", phone: "+31 6 12345678", - location: null, + location: "Amsterdam, Netherlands", website: "https://janedoe.dev", linkedInUrl: "https://www.linkedin.com/in/janedoe", summary: "10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.", email: "jane@example.com", - avatarDataUri: null, + avatarDataUri: + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC", }, experience: [ { diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/adversarial.html deleted file mode 100644 index bf9c49a..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/adversarial.html +++ /dev/null @@ -1,58 +0,0 @@ -
-
-

<b>Evil</b> <script>alert(1)</script> Person

-

<svg onload=alert('headline')>

-
- evil@example.com - - - javascript:alert('website') - data:text/html,<script>alert('linkedin')</script> -
-
- -
-

Professional Summary

-

Markdown with raw HTML: <iframe src='javascript:alert(1)'></iframe> - -[Click](javascript:alert('link')) - -![img](javascript:alert('img-md')) - -<a href="javascript:alert(1)">link</a>

-
- -
-

Intro with <script>alert('intro')</script> and <img src=x onerror=alert('img')>.

-
- -
-

Professional Experience

-
-
- <a onclick=alert(1)>Role</a> - Jan 2020 – Present (5 yrs) -
-

<style>body{display:none}</style>Company

-

Worked on <script> injection.

-
    -
  • -
  • <script>alert('encoded')</script>
  • -
  • text
  • -
  • text
  • -
  • <img src=x onerror=alert('xss')>
  • -
  • -
  • ' OR 1=1 --
  • -
  • -
-
-

Key skills: <script>, javascript:, onerror, "><script>, <svg/onload=alert(1)>

-
-
- - -
-

Core Competencies

-

"><script> • <script> • <svg/onload=alert(1)> • javascript: • onerror

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/developer.html deleted file mode 100644 index 8dad5ca..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/developer.html +++ /dev/null @@ -1,42 +0,0 @@ -
-
-

Dev Coder

-

Backend Developer

-
- dev@example.com - - - - -
-
- -
-

Professional Summary

-

Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.

-
- - -
-

Professional Experience

-
-
- Backend Engineer - Mar 2022 – Present (3 yrs) -
-

TechCo

-

Stack: TypeScript, NestJS, Prisma, PostgreSQL.

-
const x = await service.find();
-
-

Tooling: Vitest, Playwright, Biome.

-
-

Key skills: TypeScript, NestJS, Prisma, PostgreSQL, Vitest, Playwright, Biome, Docker, Kubernetes, Redis, Kafka, GraphQL

-
-
- - -
-

Core Competencies

-

Biome • Docker • GraphQL • Kafka • Kubernetes • NestJS • Playwright • PostgreSQL • Prisma • Redis • TypeScript • Vitest

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/empty.html deleted file mode 100644 index 8c466ff..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/empty.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
-

- -
- - - - - -
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/executive.html deleted file mode 100644 index 510a031..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/executive.html +++ /dev/null @@ -1,62 +0,0 @@ -
-
-

Exec Leader

-

VP Engineering, MBA

-
- exec@example.com - - - - -
-
- -
-

Professional Summary

-

Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.

-
- -
-

Executive with a track record of scaling engineering organisations through hyper-growth phases.

-
- -
-

Professional Experience

-
-
- VP Engineering - Jan 2020 – Present (5 yrs) -
-

GlobalCorp, Executive

-

Strategic leadership of the engineering organisation across three continents.

-

Key accomplishments:

-
    -
  • Scaled engineering org from 50 to 220 across 4 product lines
  • -
  • Reduced production incidents by 73% through SRE practices and observability investment
  • -
  • Drove platform consolidation that cut infra spend by $4.2M annually
  • -
  • Established engineering excellence framework adopted org-wide
  • -
  • Direct sponsor for diversity & inclusion initiatives, increased women in engineering from 18% to 34%
  • -
-

This role required deep collaboration with executive peers in Product, Design, Sales, and Finance. Lots of board reporting.

-
-

Key skills: Leadership, Strategy

-
-
- -
-

Education

-
-
- MBA, Strategic Management - Sep 2008 – Jun 2010 -
-

Stanford Graduate School of Business

- -
-
- -
-

Core Competencies

-

Leadership • Strategy

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/full.html deleted file mode 100644 index 5058ad2..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/full.html +++ /dev/null @@ -1,74 +0,0 @@ -
-
-

Jane Doe

-

Senior Software Engineer

-
- jane@example.com - +31 6 12345678 - - https://janedoe.dev - https://www.linkedin.com/in/janedoe -
-
- -
-

Professional Summary

-

10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.

-
- -
-

Crafting infrastructure that scales while keeping engineering teams shipping fast.

-
- -
-

Professional Experience

-
-
- Staff Engineer - Jan 2024 – Present (1 yr 4 mos) -
-

Acme Corp, Staff

-

Led migration to event-driven architecture.

-
    -
  • Reduced p95 latency from 800ms to 180ms
  • -
  • Mentored 4 engineers through senior promotion
  • -
  • Wrote technical RFCs for service decomposition
  • -
-
-

Key skills: TypeScript, PostgreSQL, Kafka, NestJS

-
-
-
- Senior Engineer - Jun 2020 – Dec 2023 (3 yrs 6 mos) -
-

Beta Inc, Senior

-

Backend platform for B2B SaaS.

-
    -
  1. Designed multi-tenant data model
  2. -
  3. Owned migration from MongoDB to Postgres
  4. -
  5. Built auth platform serving 50k+ users
  6. -
-
-

Key skills: TypeScript, Node.js, Postgres, Docker

-
-
- -
-

Education

-
-
- MSc, Computer Science - Sep 2014 – Jul 2016 -
-

Delft University of Technology

-

Thesis: Distributed consensus in low-latency networks.

-
-
-
- -
-

Core Competencies

-

TypeScript • Algorithms • Distributed Systems • Docker • Kafka • NestJS • Node.js • Postgres • PostgreSQL

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/minimal.html deleted file mode 100644 index 331535c..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/minimal.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
-

Min Person

- -
- - - - - -
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/unicodeHeavy.html deleted file mode 100644 index 6af4ee1..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/classic-executive/unicodeHeavy.html +++ /dev/null @@ -1,47 +0,0 @@ -
-
-

Ünicödé Pérsön 山田太郎 🚀

-

Émojí enthusiast 🎯

-
- test+unicode@example.com - - - - -
-
- -
-

Professional Summary

-

中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.

-
- -
-

العالم 🌍

-
- -
-

Professional Experience

-
-
- Senior 工程师 - Jan 2023 – Present (2 yrs) -
-

中国科技公司

-

Built systems serving users in 中国, 日本, العراق, and 한국.

-
    -
  • Internationalisation 国際化
  • -
  • RTL ←→ LTR layout (mixed: hello مرحبا)
  • -
  • Emoji-heavy comms 💬✨🎨
  • -
-
-

Key skills: i18n, Unicode, RTL

-
-
- - -
-

Core Competencies

-

i18n • RTL • Unicode

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/adversarial.html deleted file mode 100644 index d21eee7..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/adversarial.html +++ /dev/null @@ -1,64 +0,0 @@ -
- - -
-
-

Profile

-

Markdown with raw HTML: <iframe src='javascript:alert(1)'></iframe> - -[Click](javascript:alert('link')) - -![img](javascript:alert('img-md')) - -<a href="javascript:alert(1)">link</a>

-
- -
-

Intro with <script>alert('intro')</script> and <img src=x onerror=alert('img')>.

-
- -
-

Experience

-
-
-

<a onclick=alert(1)>Role</a>

- Jan 2020 – Present · 5 yrs -
-

<style>body{display:none}</style>Company

-

Worked on <script> injection.

-
    -
  • -
  • <script>alert('encoded')</script>
  • -
  • text
  • -
  • text
  • -
  • <img src=x onerror=alert('xss')>
  • -
  • -
  • ' OR 1=1 --
  • -
  • -
-
-
<script>javascript:onerror"><script><svg/onload=alert(1)>
-
-
- -
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/developer.html deleted file mode 100644 index 42d8392..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/developer.html +++ /dev/null @@ -1,48 +0,0 @@ -
- - -
-
-

Profile

-

Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.

-
- - -
-

Experience

-
-
-

Backend Engineer

- Mar 2022 – Present · 3 yrs -
-

TechCo

-

Stack: TypeScript, NestJS, Prisma, PostgreSQL.

-
const x = await service.find();
-
-

Tooling: Vitest, Playwright, Biome.

-
-
TypeScriptNestJSPrismaPostgreSQLVitestPlaywrightBiomeDockerKubernetesRedisKafkaGraphQL
-
-
- -
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/empty.html deleted file mode 100644 index d8af4e9..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/empty.html +++ /dev/null @@ -1,24 +0,0 @@ -
- - -
- - - -
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/executive.html deleted file mode 100644 index d296d41..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/executive.html +++ /dev/null @@ -1,68 +0,0 @@ -
- - -
-
-

Profile

-

Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.

-
- -
-

Executive with a track record of scaling engineering organisations through hyper-growth phases.

-
- -
-

Experience

-
-
-

VP Engineering

- Jan 2020 – Present · 5 yrs -
-

GlobalCorp · Executive

-

Strategic leadership of the engineering organisation across three continents.

-

Key accomplishments:

-
    -
  • Scaled engineering org from 50 to 220 across 4 product lines
  • -
  • Reduced production incidents by 73% through SRE practices and observability investment
  • -
  • Drove platform consolidation that cut infra spend by $4.2M annually
  • -
  • Established engineering excellence framework adopted org-wide
  • -
  • Direct sponsor for diversity & inclusion initiatives, increased women in engineering from 18% to 34%
  • -
-

This role required deep collaboration with executive peers in Product, Design, Sales, and Finance. Lots of board reporting.

-
-
LeadershipStrategy
-
-
- -
-

Education

-
-
-

MBA

- Sep 2008 – Jun 2010 -
-

Stanford Graduate School of Business · Strategic Management

- -
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/full.html deleted file mode 100644 index 951bd8e..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/full.html +++ /dev/null @@ -1,80 +0,0 @@ -
- - -
-
-

Profile

-

10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.

-
- -
-

Crafting infrastructure that scales while keeping engineering teams shipping fast.

-
- -
-

Experience

-
-
-

Staff Engineer

- Jan 2024 – Present · 1 yr 4 mos -
-

Acme Corp · Staff

-

Led migration to event-driven architecture.

-
    -
  • Reduced p95 latency from 800ms to 180ms
  • -
  • Mentored 4 engineers through senior promotion
  • -
  • Wrote technical RFCs for service decomposition
  • -
-
-
TypeScriptPostgreSQLKafkaNestJS
-
-
-
-

Senior Engineer

- Jun 2020 – Dec 2023 · 3 yrs 6 mos -
-

Beta Inc · Senior

-

Backend platform for B2B SaaS.

-
    -
  1. Designed multi-tenant data model
  2. -
  3. Owned migration from MongoDB to Postgres
  4. -
  5. Built auth platform serving 50k+ users
  6. -
-
-
TypeScriptNode.jsPostgresDocker
-
-
- -
-

Education

-
-
-

MSc

- Sep 2014 – Jul 2016 -
-

Delft University of Technology · Computer Science

-

Thesis: Distributed consensus in low-latency networks.

-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/minimal.html deleted file mode 100644 index 0261022..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/minimal.html +++ /dev/null @@ -1,24 +0,0 @@ -
- - -
- - - -
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/unicodeHeavy.html deleted file mode 100644 index 89311ac..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/creative-portfolio/unicodeHeavy.html +++ /dev/null @@ -1,53 +0,0 @@ -
- - -
-
-

Profile

-

中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.

-
- -
-

العالم 🌍

-
- -
-

Experience

-
-
-

Senior 工程师

- Jan 2023 – Present · 2 yrs -
-

中国科技公司

-

Built systems serving users in 中国, 日本, العراق, and 한국.

-
    -
  • Internationalisation 国際化
  • -
  • RTL ←→ LTR layout (mixed: hello مرحبا)
  • -
  • Emoji-heavy comms 💬✨🎨
  • -
-
-
i18nUnicodeRTL
-
-
- -
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/adversarial.html deleted file mode 100644 index 7639f03..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/adversarial.html +++ /dev/null @@ -1,55 +0,0 @@ -
-
-

<b>Evil</b> <script>alert(1)</script> Person

-

<svg onload=alert('headline')>

-
- evil@example.com - - - javascript:alert('website') - data:text/html,<script>alert('linkedin')</script> -
-
- -
-

Markdown with raw HTML: <iframe src='javascript:alert(1)'></iframe> - -[Click](javascript:alert('link')) - -![img](javascript:alert('img-md')) - -<a href="javascript:alert(1)">link</a>

-
- -
-

Intro with <script>alert('intro')</script> and <img src=x onerror=alert('img')>.

-
- -
-

Experience

-
-

Jan 2020 – Present

-

<a onclick=alert(1)>Role</a>

-

<style>body{display:none}</style>Company

-

Worked on <script> injection.

-
    -
  • -
  • <script>alert('encoded')</script>
  • -
  • text
  • -
  • text
  • -
  • <img src=x onerror=alert('xss')>
  • -
  • -
  • ' OR 1=1 --
  • -
  • -
-
-

<script>, javascript:, onerror, "><script>, <svg/onload=alert(1)>

-
-
- - -
-

Skills

-

"><script>, <script>, <svg/onload=alert(1)>, javascript:, onerror

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/developer.html deleted file mode 100644 index 28f793e..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/developer.html +++ /dev/null @@ -1,39 +0,0 @@ -
-
-

Dev Coder

-

Backend Developer

-
- dev@example.com - - - - -
-
- -
-

Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.

-
- - -
-

Experience

-
-

Mar 2022 – Present

-

Backend Engineer

-

TechCo

-

Stack: TypeScript, NestJS, Prisma, PostgreSQL.

-
const x = await service.find();
-
-

Tooling: Vitest, Playwright, Biome.

-
-

TypeScript, NestJS, Prisma, PostgreSQL, Vitest, Playwright, Biome, Docker, Kubernetes, Redis, Kafka, GraphQL

-
-
- - -
-

Skills

-

Biome, Docker, GraphQL, Kafka, Kubernetes, NestJS, Playwright, PostgreSQL, Prisma, Redis, TypeScript, Vitest

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/empty.html deleted file mode 100644 index 0878509..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/empty.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
-

- -
- - - - - -
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/executive.html deleted file mode 100644 index 30360f1..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/executive.html +++ /dev/null @@ -1,57 +0,0 @@ -
-
-

Exec Leader

-

VP Engineering, MBA

-
- exec@example.com - - - - -
-
- -
-

Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.

-
- -
-

Executive with a track record of scaling engineering organisations through hyper-growth phases.

-
- -
-

Experience

-
-

Jan 2020 – Present

-

VP Engineering

-

GlobalCorp / Executive

-

Strategic leadership of the engineering organisation across three continents.

-

Key accomplishments:

-
    -
  • Scaled engineering org from 50 to 220 across 4 product lines
  • -
  • Reduced production incidents by 73% through SRE practices and observability investment
  • -
  • Drove platform consolidation that cut infra spend by $4.2M annually
  • -
  • Established engineering excellence framework adopted org-wide
  • -
  • Direct sponsor for diversity & inclusion initiatives, increased women in engineering from 18% to 34%
  • -
-

This role required deep collaboration with executive peers in Product, Design, Sales, and Finance. Lots of board reporting.

-
-

Leadership, Strategy

-
-
- -
-

Education

-
-

Sep 2008 – Jun 2010

-

MBA — Strategic Management

-

Stanford Graduate School of Business

- -
-
- -
-

Skills

-

Leadership, Strategy

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/full.html deleted file mode 100644 index 3853c50..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/full.html +++ /dev/null @@ -1,67 +0,0 @@ -
-
-

Jane Doe

-

Senior Software Engineer

-
- jane@example.com - +31 6 12345678 - - https://janedoe.dev - https://www.linkedin.com/in/janedoe -
-
- -
-

10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.

-
- -
-

Crafting infrastructure that scales while keeping engineering teams shipping fast.

-
- -
-

Experience

-
-

Jan 2024 – Present

-

Staff Engineer

-

Acme Corp / Staff

-

Led migration to event-driven architecture.

-
    -
  • Reduced p95 latency from 800ms to 180ms
  • -
  • Mentored 4 engineers through senior promotion
  • -
  • Wrote technical RFCs for service decomposition
  • -
-
-

TypeScript, PostgreSQL, Kafka, NestJS

-
-
-

Jun 2020 – Dec 2023

-

Senior Engineer

-

Beta Inc / Senior

-

Backend platform for B2B SaaS.

-
    -
  1. Designed multi-tenant data model
  2. -
  3. Owned migration from MongoDB to Postgres
  4. -
  5. Built auth platform serving 50k+ users
  6. -
-
-

TypeScript, Node.js, Postgres, Docker

-
-
- -
-

Education

-
-

Sep 2014 – Jul 2016

-

MSc — Computer Science

-

Delft University of Technology

-

Thesis: Distributed consensus in low-latency networks.

-
-
-
- -
-

Skills

-

TypeScript, Algorithms, Distributed Systems, Docker, Kafka, NestJS, Node.js, Postgres, PostgreSQL

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/minimal.html deleted file mode 100644 index ba1e6c8..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/minimal.html +++ /dev/null @@ -1,18 +0,0 @@ -
-
-

Min Person

- -
- - - - - -
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/unicodeHeavy.html deleted file mode 100644 index 7cc9145..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/minimal-clean/unicodeHeavy.html +++ /dev/null @@ -1,44 +0,0 @@ -
-
-

Ünicödé Pérsön 山田太郎 🚀

-

Émojí enthusiast 🎯

-
- test+unicode@example.com - - - - -
-
- -
-

中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.

-
- -
-

العالم 🌍

-
- -
-

Experience

-
-

Jan 2023 – Present

-

Senior 工程师

-

中国科技公司

-

Built systems serving users in 中国, 日本, العراق, and 한국.

-
    -
  • Internationalisation 国際化
  • -
  • RTL ←→ LTR layout (mixed: hello مرحبا)
  • -
  • Emoji-heavy comms 💬✨🎨
  • -
-
-

i18n, Unicode, RTL

-
-
- - -
-

Skills

-

i18n, RTL, Unicode

-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html deleted file mode 100644 index 2d66f48..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/adversarial.html +++ /dev/null @@ -1,67 +0,0 @@ -
-
- -
-

<b>Evil</b> <script>alert(1)</script> Person

-

<svg onload=alert('headline')>

-
- evil@example.com - - - - -
-
-
- -
-

Profile

-

Markdown with raw HTML: <iframe src='javascript:alert(1)'></iframe> - -[Click](javascript:alert('link')) - -![img](javascript:alert('img-md')) - -<a href="javascript:alert(1)">link</a>

-
- -
-

About This CV

-

Intro with <script>alert('intro')</script> and <img src=x onerror=alert('img')>.

-
- -
-

Experience

-
-
-
-

<a onclick=alert(1)>Role</a>

-

<style>body{display:none}</style>Company

-
-
- Jan 2020 – Present - 5 yrs -
-
-

Worked on <script> injection.

-
    -
  • -
  • <script>alert('encoded')</script>
  • -
  • text
  • -
  • text
  • -
  • <img src=x onerror=alert('xss')>
  • -
  • -
  • ' OR 1=1 --
  • -
  • -
-
-
<script>javascript:onerror"><script><svg/onload=alert(1)>
-
-
- - -
-

Skills

-
"><script><script><svg/onload=alert(1)>javascript:onerror
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html deleted file mode 100644 index 093f0ec..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/developer.html +++ /dev/null @@ -1,50 +0,0 @@ -
-
- -
-

Dev Coder

-

Backend Developer

-
- dev@example.com - - - - -
-
-
- -
-

Profile

-

Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.

-
- - -
-

Experience

-
-
-
-

Backend Engineer

-

TechCo

-
-
- Mar 2022 – Present - 3 yrs -
-
-

Stack: TypeScript, NestJS, Prisma, PostgreSQL.

-
const x = await service.find();
-
-

Tooling: Vitest, Playwright, Biome.

-
-
TypeScriptNestJSPrismaPostgreSQLVitestPlaywrightBiomeDockerKubernetesRedisKafkaGraphQL
-
-
- - -
-

Skills

-
BiomeDockerGraphQLKafkaKubernetesNestJSPlaywrightPostgreSQLPrismaRedisTypeScriptVitest
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html deleted file mode 100644 index 0a5b6cf..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/empty.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
- -
-

- -
- - - - - -
-
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html deleted file mode 100644 index 8dd97d3..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/executive.html +++ /dev/null @@ -1,77 +0,0 @@ -
-
- -
-

Exec Leader

-

VP Engineering, MBA

-
- exec@example.com - - - - -
-
-
- -
-

Profile

-

Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.

-
- -
-

About This CV

-

Executive with a track record of scaling engineering organisations through hyper-growth phases.

-
- -
-

Experience

-
-
-
-

VP Engineering

-

GlobalCorp · Executive

-
-
- Jan 2020 – Present - 5 yrs -
-
-

Strategic leadership of the engineering organisation across three continents.

-

Key accomplishments:

-
    -
  • Scaled engineering org from 50 to 220 across 4 product lines
  • -
  • Reduced production incidents by 73% through SRE practices and observability investment
  • -
  • Drove platform consolidation that cut infra spend by $4.2M annually
  • -
  • Established engineering excellence framework adopted org-wide
  • -
  • Direct sponsor for diversity & inclusion initiatives, increased women in engineering from 18% to 34%
  • -
-

This role required deep collaboration with executive peers in Product, Design, Sales, and Finance. Lots of board reporting.

-
-
LeadershipStrategy
-
-
- -
-

Education

-
-
-
-

MBA

-

Stanford Graduate School of Business · Strategic Management

-
-
- Sep 2008 – Jun 2010 - 1 yr 9 mos -
-
- - -
-
- -
-

Skills

-
LeadershipStrategy
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html deleted file mode 100644 index de2c003..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/full.html +++ /dev/null @@ -1,94 +0,0 @@ -
-
- -
-

Jane Doe

-

Senior Software Engineer

-
- jane@example.com - +31 6 12345678 - - https://janedoe.dev - LinkedIn -
-
-
- -
-

Profile

-

10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.

-
- -
-

About This CV

-

Crafting infrastructure that scales while keeping engineering teams shipping fast.

-
- -
-

Experience

-
-
-
-

Staff Engineer

-

Acme Corp · Staff

-
-
- Jan 2024 – Present - 1 yr 4 mos -
-
-

Led migration to event-driven architecture.

-
    -
  • Reduced p95 latency from 800ms to 180ms
  • -
  • Mentored 4 engineers through senior promotion
  • -
  • Wrote technical RFCs for service decomposition
  • -
-
-
TypeScriptPostgreSQLKafkaNestJS
-
-
-
-
-

Senior Engineer

-

Beta Inc · Senior

-
-
- Jun 2020 – Dec 2023 - 3 yrs 6 mos -
-
-

Backend platform for B2B SaaS.

-
    -
  1. Designed multi-tenant data model
  2. -
  3. Owned migration from MongoDB to Postgres
  4. -
  5. Built auth platform serving 50k+ users
  6. -
-
-
TypeScriptNode.jsPostgresDocker
-
-
- -
-

Education

-
-
-
-

MSc

-

Delft University of Technology · Computer Science

-
-
- Sep 2014 – Jul 2016 - 1 yr 10 mos -
-
-

Thesis: Distributed consensus in low-latency networks.

-
-
Distributed SystemsAlgorithms
-
-
- -
-

Skills

-
TypeScriptAlgorithmsDistributed SystemsDockerKafkaNestJSNode.jsPostgresPostgreSQL
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html deleted file mode 100644 index 34262f3..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/minimal.html +++ /dev/null @@ -1,21 +0,0 @@ -
-
- -
-

Min Person

- -
- - - - - -
-
-
- - - - - -
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html deleted file mode 100644 index 981d0de..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/modern-professional/unicodeHeavy.html +++ /dev/null @@ -1,56 +0,0 @@ -
-
- -
-

Ünicödé Pérsön 山田太郎 🚀

-

Émojí enthusiast 🎯

-
- test+unicode@example.com - - - - -
-
-
- -
-

Profile

-

中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.

-
- -
-

About This CV

-

العالم 🌍

-
- -
-

Experience

-
-
-
-

Senior 工程师

-

中国科技公司

-
-
- Jan 2023 – Present - 2 yrs -
-
-

Built systems serving users in 中国, 日本, العراق, and 한국.

-
    -
  • Internationalisation 国際化
  • -
  • RTL ←→ LTR layout (mixed: hello مرحبا)
  • -
  • Emoji-heavy comms 💬✨🎨
  • -
-
-
i18nUnicodeRTL
-
-
- - -
-

Skills

-
i18nRTLUnicode
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/adversarial.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/adversarial.html deleted file mode 100644 index c98bd56..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/adversarial.html +++ /dev/null @@ -1,69 +0,0 @@ -
-
-
-

<b>Evil</b> <script>alert(1)</script> Person

-

<svg onload=alert('headline')>

-
-
-

evil@example.com

- - -

javascript:alert('website')

-

data:text/html,<script>alert('linkedin')</script>

-
-
- -
-

Markdown with raw HTML: <iframe src='javascript:alert(1)'></iframe> - -[Click](javascript:alert('link')) - -![img](javascript:alert('img-md')) - -<a href="javascript:alert(1)">link</a>

-
- -
-

Intro with <script>alert('intro')</script> and <img src=x onerror=alert('img')>.

-
- -
- - -
-
-

Professional Experience

-
-
-
-

<a onclick=alert(1)>Role</a>

-

<style>body{display:none}</style>Company

-
-

Jan 2020 – Present
5 yrs

-
-

Worked on <script> injection.

-
    -
  • -
  • <script>alert('encoded')</script>
  • -
  • text
  • -
  • text
  • -
  • <img src=x onerror=alert('xss')>
  • -
  • -
  • ' OR 1=1 --
  • -
  • -
-
-
<script>javascript:onerror"><script><svg/onload=alert(1)>
-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/developer.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/developer.html deleted file mode 100644 index 7bbc3b9..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/developer.html +++ /dev/null @@ -1,53 +0,0 @@ -
-
-
-

Dev Coder

-

Backend Developer

-
-
-

dev@example.com

- - - - -
-
- -
-

Backend engineer focused on `TypeScript` and `Postgres`. Strong opinions about `null` vs `undefined`.

-
- - -
- - -
-
-

Professional Experience

-
-
-
-

Backend Engineer

-

TechCo

-
-

Mar 2022 – Present
3 yrs

-
-

Stack: TypeScript, NestJS, Prisma, PostgreSQL.

-
const x = await service.find();
-
-

Tooling: Vitest, Playwright, Biome.

-
-
TypeScriptNestJSPrismaPostgreSQLVitestPlaywrightBiomeDockerKubernetesRedisKafkaGraphQL
-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/empty.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/empty.html deleted file mode 100644 index 00a8db1..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/empty.html +++ /dev/null @@ -1,26 +0,0 @@ -
-
-
-

- -
-
- - - - - -
-
- - - -
- - -
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/executive.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/executive.html deleted file mode 100644 index c3552ac..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/executive.html +++ /dev/null @@ -1,72 +0,0 @@ -
-
-
-

Exec Leader

-

VP Engineering, MBA

-
-
-

exec@example.com

- - - - -
-
- -
-

Engineering executive with 15 years of experience building and leading high-performing teams. Track record across fintech, healthcare, and consumer SaaS. Board advisor at three early-stage companies.

-
- -
-

Executive with a track record of scaling engineering organisations through hyper-growth phases.

-
- -
- - -
-
-

Professional Experience

-
-
-
-

VP Engineering

-

GlobalCorp · Executive

-
-

Jan 2020 – Present
5 yrs

-
-

Strategic leadership of the engineering organisation across three continents.

-

Key accomplishments:

-
    -
  • Scaled engineering org from 50 to 220 across 4 product lines
  • -
  • Reduced production incidents by 73% through SRE practices and observability investment
  • -
  • Drove platform consolidation that cut infra spend by $4.2M annually
  • -
  • Established engineering excellence framework adopted org-wide
  • -
  • Direct sponsor for diversity & inclusion initiatives, increased women in engineering from 18% to 34%
  • -
-

This role required deep collaboration with executive peers in Product, Design, Sales, and Finance. Lots of board reporting.

-
-
LeadershipStrategy
-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/full.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/full.html deleted file mode 100644 index 3cefd1a..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/full.html +++ /dev/null @@ -1,86 +0,0 @@ -
-
-
-

Jane Doe

-

Senior Software Engineer

-
-
-

jane@example.com

-

+31 6 12345678

- -

https://janedoe.dev

-

https://www.linkedin.com/in/janedoe

-
-
- -
-

10+ years building backend systems for fintech and e-commerce. Specialised in NestJS, GraphQL, and Postgres. Mentor and tech lead.

-
- -
-

Crafting infrastructure that scales while keeping engineering teams shipping fast.

-
- -
- - -
-
-

Professional Experience

-
-
-
-

Staff Engineer

-

Acme Corp · Staff

-
-

Jan 2024 – Present
1 yr 4 mos

-
-

Led migration to event-driven architecture.

-
    -
  • Reduced p95 latency from 800ms to 180ms
  • -
  • Mentored 4 engineers through senior promotion
  • -
  • Wrote technical RFCs for service decomposition
  • -
-
-
TypeScriptPostgreSQLKafkaNestJS
-
-
-
-
-

Senior Engineer

-

Beta Inc · Senior

-
-

Jun 2020 – Dec 2023
3 yrs 6 mos

-
-

Backend platform for B2B SaaS.

-
    -
  1. Designed multi-tenant data model
  2. -
  3. Owned migration from MongoDB to Postgres
  4. -
  5. Built auth platform serving 50k+ users
  6. -
-
-
TypeScriptNode.jsPostgresDocker
-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/minimal.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/minimal.html deleted file mode 100644 index 7d77df1..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/minimal.html +++ /dev/null @@ -1,26 +0,0 @@ -
-
-
-

Min Person

- -
-
- - - - - -
-
- - - -
- - -
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/unicodeHeavy.html b/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/unicodeHeavy.html deleted file mode 100644 index b02c5e0..0000000 --- a/packages/cv-renderer/src/__tests__/__snapshots__/technical-resume/unicodeHeavy.html +++ /dev/null @@ -1,58 +0,0 @@ -
-
-
-

Ünicödé Pérsön 山田太郎 🚀

-

Émojí enthusiast 🎯

-
-
-

test+unicode@example.com

- - - - -
-
- -
-

中文 / 日本語 / العربية / Ελληνικά / עברית — all in one CV.

-
- -
-

العالم 🌍

-
- -
- - -
-
-

Professional Experience

-
-
-
-

Senior 工程师

-

中国科技公司

-
-

Jan 2023 – Present
2 yrs

-
-

Built systems serving users in 中国, 日本, العراق, and 한국.

-
    -
  • Internationalisation 国際化
  • -
  • RTL ←→ LTR layout (mixed: hello مرحبا)
  • -
  • Emoji-heavy comms 💬✨🎨
  • -
-
-
i18nUnicodeRTL
-
-
-
-
-
diff --git a/packages/cv-renderer/src/__tests__/template-rendering.spec.ts b/packages/cv-renderer/src/__tests__/template-rendering.spec.ts new file mode 100644 index 0000000..d0c948f --- /dev/null +++ b/packages/cv-renderer/src/__tests__/template-rendering.spec.ts @@ -0,0 +1,114 @@ +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { + adversarial, + allFixtures, + developer, + empty, + type FixtureName, + full, +} from "../__test-fixtures__"; +import { HandlebarsEngine } from "../engines/handlebars.engine"; + +const here = dirname(fileURLToPath(import.meta.url)); +const templatesDir = resolve( + here, + "../../../core/src/modules/cv-template/templates", +); + +const TEMPLATES = [ + "classic-executive", + "creative-portfolio", + "minimal-clean", + "modern-professional", + "technical-resume", +] as const; + +// Templates whose markup includes the avatar ; the rest intentionally have +// no avatar slot (CVG-80 / CVG-172 scope). +const AVATAR_TEMPLATES = new Set([ + "creative-portfolio", + "minimal-clean", + "modern-professional", +]); + +const engine = new HandlebarsEngine(); +const render = (template: string, context: object): string => + engine.render( + readFileSync(resolve(templatesDir, `${template}.hbs`), "utf-8"), + context, + ); + +const fixtureNames = Object.keys(allFixtures) as FixtureName[]; + +describe.each(TEMPLATES)("template: %s", (template) => { + it("renders profile identity, contact, and resolved location", () => { + const html = render(template, full); + expect(html).toContain("Jane Doe"); + expect(html).toContain("jane@example.com"); + expect(html).toContain("Amsterdam, Netherlands"); + }); + + it("renders experience entries", () => { + const html = render(template, full); + expect(html).toContain("Acme Corp"); + expect(html).toContain("Staff Engineer"); + }); + + it("renders markdown descriptions as HTML, not literal markup", () => { + const html = render(template, full); + expect(html).toContain("Backend platform"); + expect(html).not.toContain("**Backend platform**"); + }); + + it("renders the empty fixture without throwing", () => { + expect(() => render(template, empty)).not.toThrow(); + }); + + it("leaves no unrendered handlebars expressions for any fixture", () => { + for (const name of fixtureNames) { + expect(render(template, allFixtures[name]), name).not.toMatch(/\{\{/); + } + }); + + it("does not double-escape HTML entities for any fixture", () => { + for (const name of fixtureNames) { + const html = render(template, allFixtures[name]); + expect(html.match(/&[a-zA-Z]+;/), `${template}/${name}`).toBeNull(); + } + }); + + it("strips executable markup from adversarial input", () => { + const html = render(template, adversarial).toLowerCase(); + // Raw script/iframe tags must never survive escaping + DOMPurify. + expect(html).not.toContain(" { + for (const template of TEMPLATES) { + const supportsAvatar = AVATAR_TEMPLATES.has(template); + + it(`${template} ${supportsAvatar ? "shows" : "omits"} the avatar when one is set`, () => { + const html = render(template, full); + if (supportsAvatar) { + expect(html).toContain('class="avatar"'); + expect(html).toContain(full.profile.avatarDataUri as string); + } else { + expect(html).not.toContain('class="avatar"'); + } + }); + + if (supportsAvatar) { + it(`${template} omits the avatar when none is set`, () => { + expect(render(template, developer)).not.toContain('class="avatar"'); + }); + } + } +}); diff --git a/packages/cv-renderer/src/__tests__/template-snapshots.spec.ts b/packages/cv-renderer/src/__tests__/template-snapshots.spec.ts deleted file mode 100644 index 5dc9298..0000000 --- a/packages/cv-renderer/src/__tests__/template-snapshots.spec.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { readFileSync } from "node:fs"; -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; -import { describe, expect, it } from "vitest"; -import { allFixtures, type FixtureName } from "../__test-fixtures__"; -import { HandlebarsEngine } from "../engines/handlebars.engine"; - -const here = dirname(fileURLToPath(import.meta.url)); -const templatesDir = resolve( - here, - "../../../core/src/modules/cv-template/seed/templates", -); - -const templates = [ - "classic-executive", - "creative-portfolio", - "minimal-clean", - "modern-professional", - "technical-resume", -] as const; -type TemplateName = (typeof templates)[number]; - -const fixtures = Object.keys(allFixtures) as FixtureName[]; - -const loadTemplate = (name: TemplateName): string => - readFileSync(resolve(templatesDir, `${name}.hbs`), "utf-8"); - -const engine = new HandlebarsEngine(); - -describe("template snapshots", () => { - for (const template of templates) { - describe(template, () => { - const source = loadTemplate(template); - - for (const fixtureName of fixtures) { - it(`renders fixture: ${fixtureName}`, async () => { - const html = engine.render(source, allFixtures[fixtureName]); - await expect(html).toMatchFileSnapshot( - resolve(here, `__snapshots__/${template}/${fixtureName}.html`), - ); - }); - } - }); - } -}); - -/** - * Catches the regression where an HTML entity (e.g. `•`) is passed as an - * argument inside a mustache expression. Handlebars HTML-escapes the helper - * output, so the leading `&` turns into `&` and the rendered page shows - * literal `•` text instead of a bullet. The signature in the output is - * `&;` — a real entity in static template text stays as `•`. - * - * If this fires: find the `{{...}}` in the offending template that includes - * an HTML entity in its string argument, and replace the entity with the raw - * character (e.g. `•` → `•`, `–` → `–`). - */ -describe("template render pipeline does not double-escape entities", () => { - const DOUBLE_ESCAPED_ENTITY = /&[a-zA-Z]+;/; - - for (const template of templates) { - const source = loadTemplate(template); - for (const fixtureName of fixtures) { - it(`${template} / ${fixtureName} has no &; sequences`, () => { - const html = engine.render(source, allFixtures[fixtureName]); - const match = html.match(DOUBLE_ESCAPED_ENTITY); - expect( - match, - match - ? `Double-escaped entity ${match[0]} in ${template}/${fixtureName}` - : undefined, - ).toBeNull(); - }); - } - } -});