feat: initialize CV Engine project with core functionality

- Set up React + TypeScript + Vite project with TailwindCSS
- Implement Zustand store for CV data management
- Create personal details editor with validation
- Add ATS-friendly template for CV preview
- Build stepper navigation component
- Include schema validation with Zod
- Configure ESLint and Prettier for code quality
This commit is contained in:
geulah 2025-10-05 22:53:40 +01:00
commit 1bede93cd1
27 changed files with 7268 additions and 0 deletions

View File

@ -0,0 +1,36 @@
You are a Senior Front-End Developer and an Expert in ReactJS, NextJS, JavaScript, TypeScript, HTML, CSS and modern UI/UX frameworks (e.g., TailwindCSS, Shadcn, Radix). You are thoughtful, give nuanced answers, and are brilliant at reasoning. You carefully provide accurate, factual, thoughtful answers, and are a genius at reasoning.
- Follow the users requirements carefully & to the letter.
- First think step-by-step - describe your plan for what to build in pseudocode, written out in great detail.
- Confirm, then write code!
- Always check to see if there is an existing code that can be used to implement the requested functionality.
- Always check to see that no linting errors and all new code compiles without errors.
- Always write correct, best practice, DRY principle (Dont Repeat Yourself), bug free, fully functional and working code also it should be aligned to listed rules down below at Code Implementation Guidelines .
- Focus on easy and readability code, over being performant.
- Fully implement all requested functionality.
- Leave NO todos, placeholders or missing pieces.
- Ensure code is complete! Verify thoroughly finalised.
- Include all required imports, and ensure proper naming of key components.
- Be concise Minimize any other prose.
- If you think there might not be a correct answer, you say so.
- If you do not know the answer, say so, instead of guessing.
### Coding Environment
The user asks questions about the following coding languages:
- ReactJS
- Vite
- Zustand
- JavaScript
- TypeScript
- TailwindCSS
- HTML
- CSS
### Code Implementation Guidelines
Follow these rules when you write code:
- Use early returns whenever possible to make the code more readable.
- Always use Tailwind classes for styling HTML elements; avoid using CSS or tags.
- Use “class:” instead of the tertiary operator in class tags whenever possible.
- Use descriptive variable and function/const names. Also, event functions should be named with a “handle” prefix, like “handleClick” for onClick and “handleKeyDown” for onKeyDown.
- Implement accessibility features on elements. For example, a tag should have a tabindex=“0”, aria-label, on:click, and on:keydown, and similar attributes.
- Use consts instead of functions, for example, “const toggle = () =>”. Also, define a type if possible.

424
Cv-engine.md Normal file
View File

@ -0,0 +1,424 @@
# CV Editor React Component — README
A reusable, plug-and-play React component that provides a CV/resume builder with:
* a form-based editor (stepper UI),
* a rich-text **Tiptap** summary editor,
* live preview (inline) and iframe-isolated printable preview (A4),
* structured JSON CV model (single source of truth),
* easy integration points for saving, exporting (server-side Puppeteer example), theming and templates.
This README explains how to install, integrate, customize, test and deploy the component in an existing React app or run it standalone.
---
# Table of contents
1. What this component does
2. Key features & design decisions
3. Quick start (install + run)
4. Component API (props & events)
5. File / folder structure (suggested)
6. How to plug into an existing React app (examples)
7. Templates: how they work & how to add more
8. Exporting PDFs (server-side Puppeteer example)
9. Security & sanitization (DOMPurify)
10. State management recommendations (Zustand + TanStack)
11. Styling and theming (Tailwind / CSS isolation)
12. Accessibility checklist
13. Testing & CI suggestions
14. Troubleshooting / common errors
15. Next steps & extension ideas
---
# 1. What this component does
This component provides a full CV editor UI you can drop into your React app. It stores the CV as a structured JSON model and renders templates from that model. The summary field is a rich editor (Tiptap) that stores content as HTML/JSON; previews are rendered inline and inside an iframe (print-ready HTML). For production PDF export you can reuse the same printable HTML on the server and render it with Puppeteer.
---
# 2. Key features & design decisions
* **Single source of truth**: the CV is a JSON object (easy to save, modify, export).
* **Templates as renderers**: templates are modular React components / HTML + CSS that accept the CV JSON and return DOM or printable HTML.
* **Rich summary**: Tiptap (ProseMirror) used for structured, extensible rich text with predictable HTML output.
* **Preview parity**: inline preview uses the same React template rendering to reduce export surprises; iframe preview isolates CSS for print fidelity.
* **Server-side parity**: printable HTML builder can be used on server in Puppeteer to generate pixel-perfect PDFs.
---
# 3. Quick start (install + run)
### Minimal dependencies
Add these packages to your project:
```bash
# Core
npm install react react-dom
# UI + styling (suggested)
npm install tailwindcss
# Rich text
npm install @tiptap/react @tiptap/starter-kit @tiptap/extension-placeholder
# Optional & recommended
npm install dompurify
npm install axios
npm install zustand @tanstack/react-query
```
### Example package.json dev/start scripts
```json
{
"scripts": {
"start": "vite", // or react-scripts start / next dev
"build": "vite build",
"test": "vitest"
}
}
```
### Run
Add the component file (e.g. `CVEditorWireframe.jsx`) to your app and import it into a page. Start your app as usual (`npm start` / `npm run dev`).
---
# 4. Component API (props & events)
Use this component as `<CVEditor />`. Example usage:
```jsx
import CVEditor from './components/CVEditorWireframe'
function App() {
const initialCV = { /* optional initial JSON model */ }
async function handleSave(cvJson) {
// e.g. POST /api/cv
await axios.post('/api/cv', cvJson)
}
return (
<CVEditor
initialCV={initialCV}
templates={myTemplateList}
onSave={handleSave}
exportEndpoint="/api/export/pdf" // optional: server endpoint for export jobs
allowDownload={true}
theme="light"
/>
)
}
```
### Props
* `initialCV``{}` CV JSON model to pre-populate the editor (optional).
* `templates``[{ id, name, renderer }]` list of templates. `renderer` can be a React component that accepts `{cv}` or a function that returns printable HTML.
* `onSave(cv)` — async callback called when user saves (or autosave triggers).
* `onExportRequest(cv)` — callback to call when user requests PDF export. If provided, component can call it (you can send to server for Puppeteer).
* `exportEndpoint` — optional server endpoint URL to POST CV JSON for export.
* `allowDownload``boolean`, whether to show Export/Download controls.
* `theme``'light' | 'dark'` (optional, used to add top-level class).
* `className` — additional CSS class for outer container.
* `onChange(cv)` — optional callback fired on any CV change (useful for autosave).
---
# 5. File / folder structure (suggested)
```
src/
components/
CVEditorWireframe.jsx // main component (editor + preview)
templates/
TemplateClassic.jsx
TemplateATS.jsx
editors/
RichSummaryEditor.jsx // Tiptap wrapper
utils/
printableHtml.js // buildPrintableHtml(cv)
escapeHtml.js
hooks/
useCvAutosave.js
pages/
EditorPage.jsx // where you mount CVEditor
server/
export/
exportPdf.js // Express + Puppeteer server code (optional)
```
---
# 6. How to plug into an existing React app
### A. As a child component (recommended)
1. Copy `CVEditorWireframe.jsx` and its dependencies into your project.
2. Import and render it inside an existing route or component.
3. Provide `initialCV`, `onSave` and `templates` props as needed.
```jsx
// routes/ProfileEditor.jsx
import CVEditor from '../components/CVEditorWireframe'
import TemplateClassic from '../components/templates/TemplateClassic'
const templates = [
{ id: 'classic', name: 'Classic', renderer: TemplateClassic },
// add ATS or other templates
]
export default function ProfileEditor() {
const initialCV = {...}
async function handleSave(cv) { await api.saveCv(cv) }
return <CVEditor initialCV={initialCV} templates={templates} onSave={handleSave} exportEndpoint="/api/export/pdf" />
}
```
### B. As an isolated micro-frontend
If you prefer it to be self-contained: bundle the component into its own package or iframe it from a separate deployment. But prefer simple import for easier code sharing and debugging.
---
# 7. Templates: how they work & how to add more
**Two options for templates:**
1. **React component template** — a React component that accepts `{ cv }` and renders markup. Use for inline preview and server rendering (with `renderToString`).
2. **HTML template function** — a function that receives `cv` and returns printable HTML string. Use for server-side export or when you want designers to provide standalone HTML/CSS files.
**Example React template**
```jsx
export default function TemplateClassic({ cv }) {
return (
<div className="template-root">
<h1>{cv.personal.firstName} {cv.personal.lastName}</h1>
<div dangerouslySetInnerHTML={{ __html: cv.summary }} />
{cv.work.map(w => (
<div key={w.id}>
<h3>{w.title}</h3>
<div>{w.company} • {w.period}</div>
<ul>{w.bullets.map((b,i) => <li key={i}>{b}</li>)}</ul>
</div>
))}
</div>
);
}
```
**Guidelines**
* Provide both `visual` templates (fancier CSS) and `ATS` templates (semantic, simple markup).
* Keep template CSS isolated (CSS modules, scoped styles or render in iframe).
* Use the same rendering logic on the server for PDFs to ensure parity.
---
# 8. Exporting PDFs (server-side Puppeteer example)
### Why server-side?
Puppeteer produces consistent, pixel-perfect PDFs (server or worker) and you can offload CPU work off the client.
### Minimal Express endpoint (example)
```js
// server/export/exportPdf.js
import express from 'express';
import puppeteer from 'puppeteer';
const router = express.Router();
router.post('/export/pdf', async (req, res) => {
try {
const cv = req.body;
// reuse printable-html builder (same logic as client)
const html = buildPrintableHtml(cv);
const browser = await puppeteer.launch({ args: ['--no-sandbox', '--disable-setuid-sandbox'] });
const page = await browser.newPage();
await page.setContent(html, { waitUntil: 'networkidle0' });
const pdfBuffer = await page.pdf({ format: 'A4', printBackground: true });
await browser.close();
res.set({
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${cv.personal.firstName}_${cv.personal.lastName}_CV.pdf"`
});
res.send(pdfBuffer);
} catch (err) {
console.error(err);
res.status(500).json({ error: 'Failed to create PDF' });
}
});
export default router;
```
**Notes**
* Use a worker or job queue (BullMQ) for high volume.
* Reuse the same HTML builder (`buildPrintableHtml`) on the server (move the builder to a shared package or duplicate carefully).
* If using serverless (Vercel / Netlify), be aware Puppeteer needs special setup (use `chrome-aws-lambda`).
---
# 9. Security & sanitization (DOMPurify)
Because summary HTML comes from users, always sanitize before rendering into `srcDoc` or sending to server.
```bash
npm install dompurify
```
```js
import DOMPurify from 'dompurify'
const safeHtml = DOMPurify.sanitize(cv.summary)
<div dangerouslySetInnerHTML={{ __html: safeHtml }} />
```
Sanitize on both client and server if the server stores the HTML or returns it to other users.
---
# 10. State management recommendations
* **Zustand** — simple local/global client state (editor draft, UI toggles).
* **TanStack Query** — server-state (fetching/saving CVs, export job status).
* Use both: keep in-memory editing state in Zustand, and use React Query for network operations (cache, optimistic updates, retries).
Example:
```js
// useCvAutosave.js
import { useMutation } from '@tanstack/react-query';
export function useAutosave() {
return useMutation(cv => api.saveCv(cv), { /* ... */ })
}
```
---
# 11. Styling & theming
* Use **Tailwind CSS** for fast iteration: the wireframe uses tailwind utility classes.
* For templates, prefer scoped CSS (CSS modules) or provide a `template.css` imported only by the template to avoid leakage.
* For iframe preview, `srcDoc` includes template CSS directly—this isolates styles perfectly.
---
# 12. Accessibility checklist
* All form fields have accessible labels (use `label` with `htmlFor`).
* Provide keyboard navigation for stepper and drag/reorder (if implemented).
* Ensure color contrast meets WCAG AA for text.
* Use semantic elements (`<header>`, `<main>`, `<section>`, `<ul>`) in templates.
* Add `aria-live` regions for autosave status and export progress.
---
# 13. Testing & CI suggestions
* **Unit tests**: Vitest/Jest for utils (`escapeHtml`, `buildPrintableHtml`).
* **Visual regression**: Percy / Chromatic for templates and export output.
* **E2E**: Cypress or Playwright for full editor flows (create CV → preview → export).
* **CI**: run tests + lint; optionally run Puppeteer to generate sample PDFs in a job (be mindful of resource usage).
Example unit test for `escapeHtml`:
```js
import { escapeHtml } from '../utils/escapeHtml'
test('escapes special chars', () => {
expect(escapeHtml(`Tom & Jerry <script>`)).toContain('&amp;')
})
```
---
# 14. Troubleshooting / common errors
* **Unterminated string constant**: often caused by unescaped newline characters inside template literals or improperly closed quotes. Ensure:
* Use backticks `` ` `` for multi-line template strings.
* Escape `\n` inside single/double quoted strings (e.g. `join('\\n')`).
* Ensure `escapeHtml` function returns properly and ends with `}`.
* **Tiptap SSR**: Tiptap relies on DOM; rendering server-side requires guarding the editor initialisation (only run in browser). Use `if (typeof window !== 'undefined')` or dynamic import.
* **Puppeteer memory/CPU**: scale PDF generation using a worker pool (e.g. `puppeteer-cluster`) or separate service.
* **Iframe `srcDoc` not rendering**: ensure the printable HTML is a full HTML document (doctype, head, meta charset). Some browsers block certain inline scripts—best to avoid inline scripts in `srcDoc`.
---
# 15. Next steps & extension ideas
* Add **template gallery** with thumbnails and filters (ATS vs Visual).
* Add **resume parser** (import old CVs) — use Rchilli/Sovren or build simple NER workflow.
* Add **AI suggestions** (OpenAI) for summary and bullet generation — integrate as optional helper.
* Add **collaboration** (CRDT or ProseMirror-based real-time editing).
* Add **version history** and downloads per version.
* Add **internationalisation** for multi-language templates.
---
# Example: Minimal `buildPrintableHtml` and `escapeHtml` utils
```js
// utils/escapeHtml.js
export function escapeHtml(str) {
if (!str) return ''
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/\"/g, '&quot;')
.replace(/'/g, '&#39;');
}
```
```js
// utils/printableHtml.js
import { escapeHtml } from './escapeHtml'
export function buildPrintableHtml(cv) {
const css = `@page{size:A4;margin:20mm}body{font-family:Inter,Arial...}`;
const summary = cv.summary || '';
const workHtml = (cv.work || []).map(w => `
<article>
<div style="display:flex;justify-content:space-between">
<div><strong>${escapeHtml(w.title)}</strong><div>${escapeHtml(w.company)}</div></div>
<div>${escapeHtml(w.period)}</div>
</div>
<ul>${(w.bullets || []).map(b => `<li>${escapeHtml(b)}</li>`).join('')}</ul>
</article>
`).join('');
return `<!doctype html><html><head><meta charset="utf-8"><style>${css}</style></head><body>
<header><h1>${escapeHtml(cv.personal.firstName)} ${escapeHtml(cv.personal.lastName)}</h1></header>
<section>${summary}</section>
<section>${workHtml}</section>
</body></html>`;
}
```
---
# Final notes
* This component is intentionally modular: swap the templates, storage, and export backend independently.
* For production, focus on sanitization (DOMPurify), server-side rendering parity (use same templates or same printable-html builder), and performance (worker pool for PDF generation).
If you'd like, I can:
* generate a ready-to-drop `CVEditorWireframe.jsx` file (complete component) and `TemplateClassic.jsx` for you to copy, or
* provide the Express + Puppeteer server file and a docker-compose example to run the PDF service, or
* scaffold a small repo with the full stack (client + server export).
Which would you like me to produce next?

243
README.md Normal file
View File

@ -0,0 +1,243 @@
# CV Editor Engine — Development Guide
This guide turns the CV editor engine plan into concrete, incremental tasks you can implement. It defines scope, architecture, data model, workflows, templates, preview/export, validation, accessibility, security, performance, testing, and milestones with acceptance criteria.
## Objectives
- Deliver a fast, accessible CV builder with live preview and high-quality PDF export.
- Keep data structured and templates decoupled for ATS-friendly and visual layouts.
- Ensure printable parity between inline preview and final PDF output.
## Tech Stack
- Client: React, TailwindCSS, Zustand, TanStack Query, Tiptap (+ DOMPurify)
- Server: Node.js + Express for export, Puppeteer/Playwright for PDF rendering
- Validation: Zod (or Yup)
- Testing: Vitest/Jest, React Testing Library, Playwright for E2E
## Repo Layout (proposed)
```
src/
components/ # UI building blocks (forms, lists, stepper)
editors/ # Step editors (Heading, Work, Education, Skills, Summary)
templates/ # Inline templates (ATS, Visual) + shared formatters
printable/ # Printable HTML builder and styles (@page, A4)
store/ # Zustand store, mutations and selectors
services/ # API calls (save/load/export) and helpers
utils/ # escapeHtml, sanitizers, validators, formatters
hooks/ # autosave, debounced change tracking, accessibility
server/
export/ # Express route for PDF export
tests/ # Unit, integration, E2E, visual regression
```
## Conventions
- Strictly sanitize user HTML via `DOMPurify` and escape all text in templates.
- Keep templates pure: accept CV JSON and return JSX/HTML; no side effects.
- Scope styles via CSS modules or Tailwind; avoid global leakage.
- Maintain preview ↔ printable parity using shared formatters and data.
## Data Model (Zod-style)
```ts
const CvSchema = z.object({
personal: z.object({
firstName: z.string().min(1),
lastName: z.string().min(1),
email: z.string().email(),
phone: z.string().optional(),
street: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
postcode: z.string().optional(),
links: z.array(z.object({ label: z.string(), url: z.string().url() })).optional(),
extras: z.array(z.string()).optional(), // e.g., LinkedIn, Website, Driving licence
}),
summary: z.string().max(600).optional(),
work: z.array(z.object({
id: z.string(),
title: z.string().min(1),
company: z.string().min(1),
location: z.string().optional(),
startDate: z.string(), // ISO or YYYY-MM
endDate: z.string().optional(),
bullets: z.array(z.string().max(160)).max(6),
employmentType: z.enum(['full_time','part_time','contract','intern','freelance']).optional(),
})),
education: z.array(z.object({
id: z.string(),
degree: z.string().min(1),
school: z.string().min(1),
startDate: z.string(),
endDate: z.string().optional(),
notes: z.string().optional(),
})),
skills: z.array(z.object({ name: z.string(), level: z.enum(['Beginner','Intermediate','Advanced']).optional() })),
languages: z.array(z.object({ name: z.string(), level: z.enum(['Basic','Conversational','Fluent','Native']) })).optional(),
certifications: z.array(z.object({ name: z.string(), issuer: z.string().optional(), date: z.string().optional() })).optional(),
templateId: z.string().default('ats'),
});
```
Validation rules
- Required: `firstName`, `lastName`, `email`; at least one of `work` or `education`.
- Bullets: 16 per role; each ≤160 chars; avoid trailing punctuation.
- Summary: ≤600 chars; discourage emojis; sanitize on save/render.
- Links: normalize to `https://`; restrict protocols to `http`, `https`.
## Editor Flow
- Stepper: Heading → Work → Education → Skills → Summary → Finalize
- Each step runs field validation on blur and pre-navigation; critical errors block Next (e.g., invalid email).
- Drag-and-drop reorder for Work and Skills; inline error messages with `aria-describedby`.
## Template System
- Template API: `{ id, name, renderer(cv), thumbnail? }`
- Shared formatters: `formatDate`, `formatLocation`, `escapeText`, `sanitizeHtml`.
- Templates
- ATS: semantic lists, neutral styles, no sidebars, simple typographic accents.
- Visual: sidebar contact, accented headings, subtle color blocks (similar to sample CV).
## Inline Preview
- Render chosen template directly from CV JSON; debounce heavy updates.
- Memoize template components; isolate styles via Tailwind scopes or modules.
## Printable Preview (Iframe)
- `buildPrintableHtml(cv)` returns a complete HTML document:
- `<html>` with embedded CSS: `@page { size: A4; margin: 18mm }` and print-safe font stack.
- No external scripts; all assets inline; background printing enabled.
- Load via `iframe srcDoc`; maintain parity with inline by sharing formatters and sanitized summary.
## Export Service
- Express `POST /export/pdf`
- Input: CV JSON (validated on server)
- Compose: `buildPrintableHtml(cv)`
- Render: Puppeteer `page.setContent(html, { waitUntil: 'networkidle0' })`, `page.pdf({ format: 'A4', printBackground: true })`
- Output: `application/pdf` stream with filename `cv-<lastName>-<YYYYMMDD>.pdf`
- Client: Export button calls endpoint; stream download; show progress & errors.
## Persistence & Autosave
- Autosave: debounce 12s after idle; mark dirty while saving; show last-saved timestamp.
- API
- `GET /cv/:id` → load draft
- `PUT /cv/:id` → save draft
- `POST /export/pdf` → export
- Store integration via TanStack Query mutations and selectors in Zustand.
## Accessibility
- Labels with `htmlFor`; inputs with `aria-describedby` for errors; ensure WCAG AA contrast.
- Keyboard navigation for stepper and list CRUD; focus management after add/delete.
- Templates use semantic elements: `header`, `section`, `ul`, avoiding purely presentational markup.
## Security
- Sanitize summary on input and before render; escape all template-bound text.
- Restrict allowed URL protocols; validate `mailto:` only for email display links (never user-provided).
- No inline scripts; printable builder is self-contained HTML/CSS.
## Performance
- Debounce preview/autosave; throttle rapid list operations.
- Lazy-load noncritical components (template gallery); memoize heavy subtrees.
- Avoid large assets; prefer CSS accents; keep DOM size lean.
## Testing Strategy
- Unit
- `escapeHtml`, `sanitizeHtml`, formatters, Zod validators, store selectors
- Integration
- Inline ↔ printable parity across templates
- Autosave flows and error handling
- Export success/failure
- E2E (Playwright)
- Create CV → navigate steps → validate errors → switch templates → preview → export PDF
- Visual Regression
- Printable HTML screenshots per template and key data permutations
## Error Handling
- Centralized error boundary; step-level error summaries; non-blocking for non-critical issues.
- Graceful server errors with retry; maintain local draft if save/export fails.
## Milestones & Tasks
### M1 — Foundations
Tasks
- Initialize client app and Tailwind; set up routing.
- Create `CvSchema`, types, and helpers (normalization, IDs).
- Implement Zustand store: CV draft, active step, templateId, UI flags.
- Build Stepper and Heading editor form with validation.
- Implement ATS inline template and preview panel.
Acceptance
- User can enter heading details with inline validation and see preview update.
- Data stored in Zustand; template renders from CV JSON.
### M2 — Content Steps
Tasks
- Work editor: CRUD, reorder, bullet editor with length guidance.
- Education editor: CRUD; reuse shared list components.
- Skills editor: tag input + quick-add chips; optional level.
Acceptance
- Add/update/delete/reorder across steps; errors appear in-place; preview updates live.
### M3 — Summary & Printable
Tasks
- Integrate Tiptap (StarterKit + Placeholder); toolbar with basic marks.
- Sanitize output; store HTML + optional JSON.
- Implement `buildPrintableHtml(cv)` and iframe preview with A4 styles.
Acceptance
- Printable preview matches inline layout (content, spacing, formatting); summary sanitized.
### M4 — Persistence
Tasks
- Wire autosave with debounced mutations; load on mount.
- Add last-saved timestamp and dirty state.
- Handle save/load errors gracefully.
Acceptance
- Draft persists across reloads; autosave feels responsive and safe.
### M5 — Export
Tasks
- Server: Express route for `POST /export/pdf` using Puppeteer.
- Client: Export button; download with progress; error UI.
Acceptance
- PDF export produces a visually faithful A4 document with correct filename.
### M6 — Templates
Tasks
- Add Visual template; gallery picker with thumbnails and filters (ATS vs Visual).
- Persist `templateId`; ensure both inline and printable support switching.
Acceptance
- Users switch templates instantly; parity holds across preview and PDF.
### M7 — QA & Polish
Tasks
- Accessibility pass; performance tuning; code cleanup.
- Unit, integration, E2E, visual tests; CI workflow.
Acceptance
- Tests green; meets accessibility and performance targets.
### M8 — Enhancements (optional)
Tasks
- Import structured CV (JSON); version history; i18n; AI assist.
Acceptance
- Optional features are behind flags and do not regress core flows.
## Setup (placeholder)
Commands to run once you scaffold the app:
```
npm create vite@latest cv-engine -- --template react-ts
cd cv-engine
npm i tailwindcss @tiptap/react @tiptap/starter-kit dompurify zustand @tanstack/react-query zod puppeteer express
npx tailwindcss init -p
```
Adapt paths to your chosen structure. This repo currently contains planning documents only.
## Acceptance Criteria Checklist (global)
- Structured CV JSON validated end-to-end
- Live inline preview with ATS template by M1
- Printable HTML parity by M3 (layout, spacing, typography, sanitization)
- Autosave with clear feedback by M4
- Reliable PDF export by M5
- At least two templates with instant switching by M6
- Accessibility AA and test coverage by M7
## Glossary
- ATS: Applicant Tracking System; favors semantic, minimal styling.
- Parity: Inline preview and exported PDF display equivalent content/layout.
- Printable builder: Function that returns a self-contained HTML document for PDF rendering.
---
Use this README as the source of truth during implementation. Update sections as architecture evolves, but keep template parity, sanitization, and accessibility as non-negotiable constraints.

24
cv-engine/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
cv-engine/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs['recommended-latest'],
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
cv-engine/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>cv-engine</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

5025
cv-engine/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

40
cv-engine/package.json Normal file
View File

@ -0,0 +1,40 @@
{
"name": "cv-engine",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tanstack/react-query": "^5.90.2",
"@tiptap/react": "^3.6.5",
"@tiptap/starter-kit": "^3.6.5",
"autoprefixer": "^10.4.21",
"dompurify": "^3.2.7",
"postcss": "^8.5.6",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"tailwindcss": "^4.1.14",
"zod": "^4.1.11",
"zustand": "^5.0.8"
},
"devDependencies": {
"@eslint/js": "^9.36.0",
"@tailwindcss/postcss": "^4.1.14",
"@types/node": "^24.6.0",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"eslint": "^9.36.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.22",
"globals": "^16.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.45.0",
"vite": "^7.1.7"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
autoprefixer: {},
},
};

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
cv-engine/src/App.css Normal file
View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

65
cv-engine/src/App.tsx Normal file
View File

@ -0,0 +1,65 @@
import React from 'react';
import Stepper from './components/Stepper';
import PersonalEditor from './editors/PersonalEditor';
import PreviewPanel from './components/PreviewPanel';
import { useActiveStep } from './store/cvStore';
const App: React.FC = () => {
const activeStep = useActiveStep();
// Render the appropriate editor based on the active step
const renderEditor = () => {
switch (activeStep) {
case 'personal':
return <PersonalEditor />;
case 'work':
return <div className="p-6 bg-white rounded-lg shadow-sm">Work Experience editor will be implemented in M2</div>;
case 'education':
return <div className="p-6 bg-white rounded-lg shadow-sm">Education editor will be implemented in M2</div>;
case 'skills':
return <div className="p-6 bg-white rounded-lg shadow-sm">Skills editor will be implemented in M2</div>;
case 'summary':
return <div className="p-6 bg-white rounded-lg shadow-sm">Summary editor will be implemented in M3</div>;
case 'finalize':
return <div className="p-6 bg-white rounded-lg shadow-sm">Finalize step will be implemented in M5</div>;
default:
return <PersonalEditor />;
}
};
return (
<div className="min-h-screen bg-gray-50">
<header className="bg-white shadow-sm">
<div className="max-w-7xl mx-auto px-4 py-4 sm:px-6 lg:px-8">
<h1 className="text-2xl font-bold text-gray-900">CV Engine</h1>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 py-6 sm:px-6 lg:px-8">
<Stepper className="mb-8" />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Editor Panel */}
<div className="lg:col-span-1">
{renderEditor()}
</div>
{/* Preview Panel */}
<div className="lg:col-span-1 h-[calc(100vh-240px)]">
<PreviewPanel className="h-full" />
</div>
</div>
</main>
<footer className="bg-white mt-12 py-6 border-t">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<p className="text-center text-gray-500 text-sm">
CV Engine - Milestone 1 Implementation
</p>
</div>
</footer>
</div>
);
};
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,31 @@
import React from 'react';
import { useCvStore } from '../store/cvStore';
import ATSTemplate from '../templates/ATSTemplate';
interface PreviewPanelProps {
className?: string;
}
const PreviewPanel: React.FC<PreviewPanelProps> = ({ className = '' }) => {
const cv = useCvStore(state => state.cv);
const templateId = useCvStore(state => state.cv.templateId);
return (
<div className={`bg-gray-100 rounded-lg shadow-inner overflow-auto ${className}`}>
<div className="sticky top-0 bg-gray-200 p-3 border-b flex justify-between items-center">
<h2 className="text-lg font-medium text-gray-700">Preview</h2>
<div className="text-sm text-gray-500">
Template: <span className="font-medium">{templateId === 'ats' ? 'ATS-Friendly' : templateId}</span>
</div>
</div>
<div className="p-4">
{/* Render the appropriate template based on templateId */}
{templateId === 'ats' && <ATSTemplate cv={cv} />}
{/* Add more template options here as they are implemented */}
</div>
</div>
);
};
export default PreviewPanel;

View File

@ -0,0 +1,113 @@
import React from 'react';
import { useActiveStep, useCvStore, type EditorStep } from '../store/cvStore';
interface StepperProps {
className?: string;
}
const Stepper: React.FC<StepperProps> = ({ className = '' }) => {
const activeStep = useActiveStep();
const { setActiveStep, validateActiveSection, nextStep, prevStep } = useCvStore();
const steps: { id: EditorStep; label: string }[] = [
{ id: 'personal', label: 'Personal Details' },
{ id: 'work', label: 'Work Experience' },
{ id: 'education', label: 'Education' },
{ id: 'skills', label: 'Skills' },
{ id: 'summary', label: 'Summary' },
{ id: 'finalize', label: 'Finalize' },
];
const handleStepClick = (step: EditorStep) => {
// If moving forward, validate current section first
const currentIndex = steps.findIndex(s => s.id === activeStep);
const targetIndex = steps.findIndex(s => s.id === step);
if (targetIndex > currentIndex) {
const isValid = validateActiveSection();
if (!isValid) return;
}
setActiveStep(step);
};
const handleNext = () => {
if (validateActiveSection()) {
nextStep();
}
};
return (
<div className={`w-full ${className}`}>
<div className="flex items-center justify-between mb-8">
{steps.map((step, index) => {
const isActive = step.id === activeStep;
const isPast = steps.findIndex(s => s.id === activeStep) > index;
return (
<div key={step.id} className="flex flex-col items-center">
<button
onClick={() => handleStepClick(step.id)}
className={`
w-10 h-10 rounded-full flex items-center justify-center text-sm font-medium
${isActive ? 'bg-blue-600 text-white' : ''}
${isPast ? 'bg-green-500 text-white' : ''}
${!isActive && !isPast ? 'bg-gray-200 text-gray-700' : ''}
transition-colors duration-200
`}
aria-current={isActive ? 'step' : undefined}
>
{isPast ? (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
index + 1
)}
</button>
<span className={`mt-2 text-xs font-medium ${isActive ? 'text-blue-600' : 'text-gray-500'}`}>
{step.label}
</span>
{/* Connector line between steps */}
{index < steps.length - 1 && (
<div className="hidden sm:block absolute left-0 w-full">
<div
className={`h-0.5 ${isPast ? 'bg-green-500' : 'bg-gray-200'}`}
style={{ width: `${100 / (steps.length - 1)}%`, marginLeft: `${(index * 100) / (steps.length - 1)}%` }}
/>
</div>
)}
</div>
);
})}
</div>
<div className="flex justify-between mt-8">
<button
onClick={prevStep}
disabled={activeStep === 'personal'}
className={`
px-4 py-2 rounded-md text-sm font-medium
${activeStep === 'personal' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-gray-200 text-gray-700 hover:bg-gray-300'}
`}
>
Previous
</button>
<button
onClick={handleNext}
disabled={activeStep === 'finalize'}
className={`
px-4 py-2 rounded-md text-sm font-medium
${activeStep === 'finalize' ? 'bg-gray-200 text-gray-400 cursor-not-allowed' : 'bg-blue-600 text-white hover:bg-blue-700'}
`}
>
{activeStep === 'finalize' ? 'Complete' : 'Next'}
</button>
</div>
</div>
);
};
export default Stepper;

View File

@ -0,0 +1,347 @@
import React, { useState, useEffect } from 'react';
import { usePersonalData, useCvStore } from '../store/cvStore';
import { normalizeUrl } from '../schema/cvSchema';
const PersonalEditor: React.FC = () => {
const personalData = usePersonalData();
const { updatePersonal } = useCvStore();
const [formData, setFormData] = useState(personalData);
const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => {
setFormData(personalData);
}, [personalData]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
// Clear error when user starts typing
if (errors[name]) {
setErrors(prev => ({ ...prev, [name]: '' }));
}
};
const handleBlur = (e: React.FocusEvent<HTMLInputElement>) => {
const { name, value } = e.target;
// Validate on blur
if (name === 'firstName' && !value.trim()) {
setErrors(prev => ({ ...prev, firstName: 'First name is required' }));
} else if (name === 'lastName' && !value.trim()) {
setErrors(prev => ({ ...prev, lastName: 'Last name is required' }));
} else if (name === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!value.trim()) {
setErrors(prev => ({ ...prev, email: 'Email is required' }));
} else if (!emailRegex.test(value)) {
setErrors(prev => ({ ...prev, email: 'Invalid email format' }));
}
}
// Update store on blur if no errors
if (!Object.values(errors).some(error => error)) {
updatePersonal(formData);
}
};
const handleAddLink = () => {
const newLinks = [...formData.links, { id: Math.random().toString(36).substring(2, 9), label: '', url: '' }];
setFormData(prev => ({ ...prev, links: newLinks }));
};
const handleLinkChange = (id: string, field: 'label' | 'url', value: string) => {
const updatedLinks = formData.links.map(link =>
link.id === id ? { ...link, [field]: value } : link
);
setFormData(prev => ({ ...prev, links: updatedLinks }));
// Clear link errors
if (errors[`link-${id}-${field}`]) {
setErrors(prev => ({ ...prev, [`link-${id}-${field}`]: '' }));
}
};
const handleLinkBlur = (id: string, field: 'label' | 'url', value: string) => {
let hasError = false;
if (field === 'url' && value) {
try {
// Normalize and validate URL
const normalizedUrl = normalizeUrl(value);
const updatedLinks = formData.links.map(link =>
link.id === id ? { ...link, url: normalizedUrl } : link
);
setFormData(prev => ({ ...prev, links: updatedLinks }));
} catch {
setErrors(prev => ({ ...prev, [`link-${id}-url`]: 'Invalid URL format' }));
hasError = true;
}
}
if (!hasError) {
updatePersonal(formData);
}
};
const handleRemoveLink = (id: string) => {
const updatedLinks = formData.links.filter(link => link.id !== id);
const updatedFormData = { ...formData, links: updatedLinks };
setFormData(updatedFormData);
updatePersonal(updatedFormData);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Validate all fields
const newErrors: Record<string, string> = {};
if (!formData.firstName.trim()) newErrors.firstName = 'First name is required';
if (!formData.lastName.trim()) newErrors.lastName = 'Last name is required';
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!formData.email.trim()) {
newErrors.email = 'Email is required';
} else if (!emailRegex.test(formData.email)) {
newErrors.email = 'Invalid email format';
}
// Validate links
formData.links.forEach(link => {
if (link.url && !link.url.startsWith('http')) {
newErrors[`link-${link.id}-url`] = 'URL must start with http:// or https://';
}
});
setErrors(newErrors);
// Update store if no errors
if (Object.keys(newErrors).length === 0) {
updatePersonal(formData);
return true;
}
return false;
};
return (
<div className="max-w-2xl mx-auto p-6 bg-white rounded-lg shadow-sm">
<h2 className="text-2xl font-bold mb-6">Personal Information</h2>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* First Name */}
<div>
<label htmlFor="firstName" className="block text-sm font-medium text-gray-700 mb-1">
First Name *
</label>
<input
type="text"
id="firstName"
name="firstName"
value={formData.firstName}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-md ${errors.firstName ? 'border-red-500' : 'border-gray-300'}`}
aria-describedby={errors.firstName ? "firstName-error" : undefined}
/>
{errors.firstName && (
<p id="firstName-error" className="mt-1 text-sm text-red-600">
{errors.firstName}
</p>
)}
</div>
{/* Last Name */}
<div>
<label htmlFor="lastName" className="block text-sm font-medium text-gray-700 mb-1">
Last Name *
</label>
<input
type="text"
id="lastName"
name="lastName"
value={formData.lastName}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-md ${errors.lastName ? 'border-red-500' : 'border-gray-300'}`}
aria-describedby={errors.lastName ? "lastName-error" : undefined}
/>
{errors.lastName && (
<p id="lastName-error" className="mt-1 text-sm text-red-600">
{errors.lastName}
</p>
)}
</div>
{/* Email */}
<div>
<label htmlFor="email" className="block text-sm font-medium text-gray-700 mb-1">
Email *
</label>
<input
type="email"
id="email"
name="email"
value={formData.email}
onChange={handleChange}
onBlur={handleBlur}
className={`w-full px-3 py-2 border rounded-md ${errors.email ? 'border-red-500' : 'border-gray-300'}`}
aria-describedby={errors.email ? "email-error" : undefined}
/>
{errors.email && (
<p id="email-error" className="mt-1 text-sm text-red-600">
{errors.email}
</p>
)}
</div>
{/* Phone */}
<div>
<label htmlFor="phone" className="block text-sm font-medium text-gray-700 mb-1">
Phone
</label>
<input
type="tel"
id="phone"
name="phone"
value={formData.phone || ''}
onChange={handleChange}
onBlur={handleBlur}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
</div>
{/* Address Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label htmlFor="street" className="block text-sm font-medium text-gray-700 mb-1">
Street Address
</label>
<input
type="text"
id="street"
name="street"
value={formData.street || ''}
onChange={handleChange}
onBlur={handleBlur}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="city" className="block text-sm font-medium text-gray-700 mb-1">
City
</label>
<input
type="text"
id="city"
name="city"
value={formData.city || ''}
onChange={handleChange}
onBlur={handleBlur}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="country" className="block text-sm font-medium text-gray-700 mb-1">
Country
</label>
<input
type="text"
id="country"
name="country"
value={formData.country || ''}
onChange={handleChange}
onBlur={handleBlur}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
<div>
<label htmlFor="postcode" className="block text-sm font-medium text-gray-700 mb-1">
Postal Code
</label>
<input
type="text"
id="postcode"
name="postcode"
value={formData.postcode || ''}
onChange={handleChange}
onBlur={handleBlur}
className="w-full px-3 py-2 border border-gray-300 rounded-md"
/>
</div>
</div>
{/* Links Section */}
<div className="mt-6">
<div className="flex justify-between items-center mb-2">
<h3 className="text-lg font-medium">Links</h3>
<button
type="button"
onClick={handleAddLink}
className="px-3 py-1 bg-blue-50 text-blue-600 rounded-md text-sm hover:bg-blue-100"
>
Add Link
</button>
</div>
{formData.links.map((link) => (
<div key={link.id} className="flex items-start space-x-2 mb-3">
<div className="flex-1">
<input
type="text"
placeholder="Label (e.g. LinkedIn)"
value={link.label}
onChange={(e) => handleLinkChange(link.id, 'label', e.target.value)}
onBlur={(e) => handleLinkBlur(link.id, 'label', e.target.value)}
className={`w-full px-3 py-2 border rounded-md ${
errors[`link-${link.id}-label`] ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors[`link-${link.id}-label`] && (
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-label`]}</p>
)}
</div>
<div className="flex-1">
<input
type="text"
placeholder="URL (e.g. https://linkedin.com/in/...)"
value={link.url}
onChange={(e) => handleLinkChange(link.id, 'url', e.target.value)}
onBlur={(e) => handleLinkBlur(link.id, 'url', e.target.value)}
className={`w-full px-3 py-2 border rounded-md ${
errors[`link-${link.id}-url`] ? 'border-red-500' : 'border-gray-300'
}`}
/>
{errors[`link-${link.id}-url`] && (
<p className="mt-1 text-sm text-red-600">{errors[`link-${link.id}-url`]}</p>
)}
</div>
<button
type="button"
onClick={() => handleRemoveLink(link.id)}
className="mt-2 text-red-500 hover:text-red-700"
aria-label="Remove link"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</div>
))}
</div>
</form>
</div>
);
};
export default PersonalEditor;

11
cv-engine/src/index.css Normal file
View File

@ -0,0 +1,11 @@
@import "tailwindcss";
@layer base {
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Global body styles moved to component classes; avoid @apply here */
}

10
cv-engine/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,135 @@
import { z } from 'zod';
// Helper function to generate unique IDs
export const generateId = () => Math.random().toString(36).substring(2, 9);
// CV Schema using Zod for validation
export const CvSchema = z.object({
personal: z.object({
firstName: z.string().min(1, "First name is required"),
lastName: z.string().min(1, "Last name is required"),
email: z.string().email("Invalid email address"),
phone: z.string().optional(),
street: z.string().optional(),
city: z.string().optional(),
country: z.string().optional(),
postcode: z.string().optional(),
links: z.array(
z.object({
id: z.string().default(() => generateId()),
label: z.string(),
url: z.string().url("Invalid URL format")
})
).optional().default([]),
extras: z.array(z.string()).optional().default([]),
}),
summary: z.string().max(600, "Summary should be less than 600 characters").optional().default(""),
work: z.array(
z.object({
id: z.string().default(() => generateId()),
title: z.string().min(1, "Job title is required"),
company: z.string().min(1, "Company name is required"),
location: z.string().optional(),
startDate: z.string(),
endDate: z.string().optional(),
bullets: z.array(z.string().max(160, "Each bullet point should be less than 160 characters")).max(6, "Maximum 6 bullet points allowed"),
employmentType: z.enum(['full_time', 'part_time', 'contract', 'intern', 'freelance']).optional(),
})
).default([]),
education: z.array(
z.object({
id: z.string().default(() => generateId()),
degree: z.string().min(1, "Degree is required"),
school: z.string().min(1, "School name is required"),
startDate: z.string(),
endDate: z.string().optional(),
notes: z.string().optional(),
})
).default([]),
skills: z.array(
z.object({
id: z.string().default(() => generateId()),
name: z.string(),
level: z.enum(['Beginner', 'Intermediate', 'Advanced']).optional()
})
).default([]),
languages: z.array(
z.object({
id: z.string().default(() => generateId()),
name: z.string(),
level: z.enum(['Basic', 'Conversational', 'Fluent', 'Native'])
})
).optional().default([]),
certifications: z.array(
z.object({
id: z.string().default(() => generateId()),
name: z.string(),
issuer: z.string().optional(),
date: z.string().optional()
})
).optional().default([]),
templateId: z.string().default('ats'),
});
// Type inference from the schema
export type CV = z.infer<typeof CvSchema>;
// Create an empty CV with default values
export const createEmptyCV = (): CV => ({
personal: {
firstName: "",
lastName: "",
email: "",
phone: "",
street: "",
city: "",
country: "",
postcode: "",
links: [],
extras: [],
},
summary: "",
work: [],
education: [],
skills: [],
languages: [],
certifications: [],
templateId: "ats",
});
// Helper functions for validation
export const validateCV = (cv: CV) => {
return CvSchema.safeParse(cv);
};
export const validateSection = <K extends keyof CV>(section: K, data: CV[K]) => {
const sectionSchema = CvSchema.shape[section];
return sectionSchema.safeParse(data);
};
// Sanitization helpers
export const sanitizeHtml = (html: string): string => {
// In a real implementation, we would use DOMPurify here
// This is a simple placeholder
return html
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/<iframe\b[^<]*(?:(?!<\/iframe>)<[^<]*)*<\/iframe>/gi, '')
.replace(/<[^>]*>/g, ''); // Remove all HTML tags for now
};
export const escapeText = (text: string): string => {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
};
// URL validation and normalization
export const normalizeUrl = (url: string): string => {
if (!url.startsWith('http://') && !url.startsWith('https://')) {
return `https://${url}`;
}
return url;
};

View File

@ -0,0 +1,306 @@
import { create } from 'zustand';
import { z } from 'zod';
import { CvSchema, createEmptyCV, validateSection } from '../schema/cvSchema';
// Define the steps for the CV editor
export type EditorStep = 'personal' | 'work' | 'education' | 'skills' | 'summary' | 'finalize';
// Define the store state
interface CvState {
// CV data
cv: z.infer<typeof CvSchema>;
// Editor state
activeStep: EditorStep;
isDirty: boolean;
lastSaved: Date | null;
errors: Record<string, string[]>;
// Actions
setCv: (cv: z.infer<typeof CvSchema>) => void;
updatePersonal: (personal: z.infer<typeof CvSchema>['personal']) => void;
updateWork: (work: z.infer<typeof CvSchema>['work']) => void;
updateEducation: (education: z.infer<typeof CvSchema>['education']) => void;
updateSkills: (skills: z.infer<typeof CvSchema>['skills']) => void;
updateSummary: (summary: string) => void;
updateTemplateId: (templateId: string) => void;
// Work item operations
addWorkItem: () => void;
updateWorkItem: (id: string, item: Partial<z.infer<typeof CvSchema>['work'][0]>) => void;
removeWorkItem: (id: string) => void;
reorderWorkItems: (fromIndex: number, toIndex: number) => void;
// Education item operations
addEducationItem: () => void;
updateEducationItem: (id: string, item: Partial<z.infer<typeof CvSchema>['education'][0]>) => void;
removeEducationItem: (id: string) => void;
reorderEducationItems: (fromIndex: number, toIndex: number) => void;
// Skill operations
addSkill: (name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
updateSkill: (id: string, name: string, level?: z.infer<typeof CvSchema>['skills'][0]['level']) => void;
removeSkill: (id: string) => void;
// Navigation
setActiveStep: (step: EditorStep) => void;
nextStep: () => void;
prevStep: () => void;
// State management
setDirty: (isDirty: boolean) => void;
setLastSaved: (date: Date | null) => void;
validateActiveSection: () => boolean;
resetErrors: () => void;
}
// Create the store
export const useCvStore = create<CvState>((set, get) => ({
// Initial state
cv: createEmptyCV(),
activeStep: 'personal',
isDirty: false,
lastSaved: null,
errors: {},
// Actions
setCv: (cv) => set({ cv, isDirty: true }),
updatePersonal: (personal) => {
const result = validateSection('personal', personal);
if (result.success) {
set((state) => ({
cv: { ...state.cv, personal },
isDirty: true,
errors: { ...state.errors, personal: [] }
}));
} else {
set((state) => ({
errors: {
...state.errors,
personal: result.error.issues.map(i => i.message)
}
}));
}
},
updateWork: (work) => {
set((state) => ({
cv: { ...state.cv, work },
isDirty: true
}));
},
updateEducation: (education) => {
set((state) => ({
cv: { ...state.cv, education },
isDirty: true
}));
},
updateSkills: (skills) => {
set((state) => ({
cv: { ...state.cv, skills },
isDirty: true
}));
},
updateSummary: (summary) => {
set((state) => ({
cv: { ...state.cv, summary },
isDirty: true
}));
},
updateTemplateId: (templateId) => {
set((state) => ({
cv: { ...state.cv, templateId },
isDirty: true
}));
},
// Work item operations
addWorkItem: () => {
const { cv } = get();
const newItem = {
id: Math.random().toString(36).substring(2, 9),
title: '',
company: '',
startDate: '',
bullets: []
};
set({
cv: { ...cv, work: [newItem, ...cv.work] },
isDirty: true
});
},
updateWorkItem: (id, item) => {
const { cv } = get();
const updatedWork = cv.work.map(w =>
w.id === id ? { ...w, ...item } : w
);
set({
cv: { ...cv, work: updatedWork },
isDirty: true
});
},
removeWorkItem: (id) => {
const { cv } = get();
set({
cv: { ...cv, work: cv.work.filter(w => w.id !== id) },
isDirty: true
});
},
reorderWorkItems: (fromIndex, toIndex) => {
const { cv } = get();
const work = [...cv.work];
const [removed] = work.splice(fromIndex, 1);
work.splice(toIndex, 0, removed);
set({
cv: { ...cv, work },
isDirty: true
});
},
// Education item operations
addEducationItem: () => {
const { cv } = get();
const newItem = {
id: Math.random().toString(36).substring(2, 9),
degree: '',
school: '',
startDate: ''
};
set({
cv: { ...cv, education: [newItem, ...cv.education] },
isDirty: true
});
},
updateEducationItem: (id, item) => {
const { cv } = get();
const updatedEducation = cv.education.map(e =>
e.id === id ? { ...e, ...item } : e
);
set({
cv: { ...cv, education: updatedEducation },
isDirty: true
});
},
removeEducationItem: (id) => {
const { cv } = get();
set({
cv: { ...cv, education: cv.education.filter(e => e.id !== id) },
isDirty: true
});
},
reorderEducationItems: (fromIndex, toIndex) => {
const { cv } = get();
const education = [...cv.education];
const [removed] = education.splice(fromIndex, 1);
education.splice(toIndex, 0, removed);
set({
cv: { ...cv, education },
isDirty: true
});
},
// Skill operations
addSkill: (name, level) => {
const { cv } = get();
const newSkill = {
id: Math.random().toString(36).substring(2, 9),
name,
level
};
set({
cv: { ...cv, skills: [...cv.skills, newSkill] },
isDirty: true
});
},
updateSkill: (id, name, level) => {
const { cv } = get();
const updatedSkills = cv.skills.map(s =>
s.id === id ? { ...s, name, level } : s
);
set({
cv: { ...cv, skills: updatedSkills },
isDirty: true
});
},
removeSkill: (id) => {
const { cv } = get();
set({
cv: { ...cv, skills: cv.skills.filter(s => s.id !== id) },
isDirty: true
});
},
// Navigation
setActiveStep: (activeStep) => set({ activeStep }),
nextStep: () => {
const { activeStep } = get();
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const currentIndex = steps.indexOf(activeStep);
if (currentIndex < steps.length - 1) {
set({ activeStep: steps[currentIndex + 1] });
}
},
prevStep: () => {
const { activeStep } = get();
const steps: EditorStep[] = ['personal', 'work', 'education', 'skills', 'summary', 'finalize'];
const currentIndex = steps.indexOf(activeStep);
if (currentIndex > 0) {
set({ activeStep: steps[currentIndex - 1] });
}
},
// State management
setDirty: (isDirty) => set({ isDirty }),
setLastSaved: (lastSaved) => set({ lastSaved }),
validateActiveSection: () => {
const { activeStep, cv } = get();
// Map step to CV section
let isValid = true;
if (activeStep === 'personal') {
const result = validateSection('personal', cv.personal);
isValid = result.success;
if (!result.success) {
set((state) => ({
errors: {
...state.errors,
personal: result.error.issues.map(i => i.message)
}
}));
}
}
// Add validation for other sections as needed
return isValid;
},
resetErrors: () => set({ errors: {} })
}));
// Selector hooks for specific parts of the state
export const usePersonalData = () => useCvStore(state => state.cv.personal);
export const useWorkData = () => useCvStore(state => state.cv.work);
export const useEducationData = () => useCvStore(state => state.cv.education);
export const useSkillsData = () => useCvStore(state => state.cv.skills);
export const useSummaryData = () => useCvStore(state => state.cv.summary);
export const useTemplateId = () => useCvStore(state => state.cv.templateId);
export const useActiveStep = () => useCvStore(state => state.activeStep);
export const useIsDirty = () => useCvStore(state => state.isDirty);
export const useLastSaved = () => useCvStore(state => state.lastSaved);

View File

@ -0,0 +1,220 @@
import React from 'react';
import { z } from 'zod';
import { CvSchema, escapeText, sanitizeHtml } from '../schema/cvSchema';
interface ATSTemplateProps {
cv: z.infer<typeof CvSchema>;
className?: string;
}
// Format date helper
const formatDate = (dateStr: string): string => {
if (!dateStr) return '';
try {
// Handle YYYY-MM format
if (/^\d{4}-\d{2}$/.test(dateStr)) {
const [year, month] = dateStr.split('-');
const date = new Date(parseInt(year), parseInt(month) - 1);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
}
// Handle ISO date
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short' });
} catch {
return dateStr; // Fallback to original string if parsing fails
}
};
// Format location helper
const formatLocation = (location?: string): string => {
return location || '';
};
const ATSTemplate: React.FC<ATSTemplateProps> = ({ cv, className = '' }) => {
const { personal, summary, work, education, skills, languages, certifications } = cv;
return (
<div className={`bg-white text-gray-800 p-8 max-w-4xl mx-auto ${className}`}>
{/* Header */}
<header className="mb-6 border-b pb-6">
<h1 className="text-3xl font-bold mb-1">
{escapeText(personal.firstName)} {escapeText(personal.lastName)}
</h1>
<div className="flex flex-wrap gap-3 text-sm mt-2">
{personal.email && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
<span>{escapeText(personal.email)}</span>
</div>
)}
{personal.phone && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
<span>{escapeText(personal.phone)}</span>
</div>
)}
{(personal.city || personal.country) && (
<div className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
<span>
{personal.city && escapeText(personal.city)}
{personal.city && personal.country && ', '}
{personal.country && escapeText(personal.country)}
</span>
</div>
)}
{personal.links.map((link) => (
<div key={link.id} className="flex items-center">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
</svg>
<a
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="text-blue-600 hover:underline"
>
{escapeText(link.label || link.url)}
</a>
</div>
))}
</div>
</header>
{/* Summary */}
{summary && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-2 text-gray-700 border-b pb-1">Professional Summary</h2>
<p className="text-gray-700" dangerouslySetInnerHTML={{ __html: sanitizeHtml(summary) }} />
</section>
)}
{/* Work Experience */}
{work.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Work Experience</h2>
<div className="space-y-4">
{work.map((job) => (
<div key={job.id} className="mb-4">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{escapeText(job.title)}</h3>
<p className="text-gray-700">{escapeText(job.company)}</p>
</div>
<div className="text-sm text-gray-600">
{formatDate(job.startDate)} - {job.endDate ? formatDate(job.endDate) : 'Present'}
{job.location && <div>{formatLocation(job.location)}</div>}
</div>
</div>
{job.bullets.length > 0 && (
<ul className="list-disc pl-5 mt-2 space-y-1">
{job.bullets.map((bullet, index) => (
<li key={index} className="text-gray-700">{escapeText(bullet)}</li>
))}
</ul>
)}
</div>
))}
</div>
</section>
)}
{/* Education */}
{education.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Education</h2>
<div className="space-y-4">
{education.map((edu) => (
<div key={edu.id} className="mb-4">
<div className="flex justify-between items-start">
<div>
<h3 className="text-lg font-semibold">{escapeText(edu.degree)}</h3>
<p className="text-gray-700">{escapeText(edu.school)}</p>
</div>
<div className="text-sm text-gray-600">
{formatDate(edu.startDate)} - {edu.endDate ? formatDate(edu.endDate) : 'Present'}
</div>
</div>
{edu.notes && <p className="mt-1 text-gray-700">{escapeText(edu.notes)}</p>}
</div>
))}
</div>
</section>
)}
{/* Skills */}
{skills.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Skills</h2>
<div className="flex flex-wrap gap-2">
{skills.map((skill) => (
<span
key={skill.id}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
>
{escapeText(skill.name)}
{skill.level && <span className="ml-1 text-gray-500">({skill.level})</span>}
</span>
))}
</div>
</section>
)}
{/* Languages */}
{languages.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Languages</h2>
<div className="flex flex-wrap gap-2">
{languages.map((language) => (
<span
key={language.id}
className="bg-gray-100 text-gray-800 px-3 py-1 rounded-full text-sm"
>
{escapeText(language.name)}
{language.level && <span className="ml-1 text-gray-500">({language.level})</span>}
</span>
))}
</div>
</section>
)}
{/* Certifications */}
{certifications.length > 0 && (
<section className="mb-6">
<h2 className="text-xl font-bold mb-3 text-gray-700 border-b pb-1">Certifications</h2>
<ul className="list-disc pl-5 space-y-1">
{certifications.map((cert) => (
<li key={cert.id} className="text-gray-700">
{escapeText(cert.name)}
{cert.issuer && <span className="ml-1">- {escapeText(cert.issuer)}</span>}
{cert.date && <span className="ml-1 text-gray-500">({formatDate(cert.date)})</span>}
</li>
))}
</ul>
</section>
)}
</div>
);
};
export default ATSTemplate;

View File

@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
cv-engine/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

7
cv-engine/vite.config.ts Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})