Repository: vangelov/devresume Branch: master Commit: a725666bb252 Files: 114 Total size: 123.3 KB Directory structure: gitextract_1fnc526q/ ├── .eslintrc.cjs ├── .gitignore ├── README.md ├── components.json ├── index.html ├── package.json ├── playwright/ │ ├── components/ │ │ ├── controls.ts │ │ ├── editor.ts │ │ ├── index.ts │ │ └── pdf-document.ts │ ├── controls.spec.ts │ ├── sections.spec.ts │ └── yaml.ts ├── playwright.config.ts ├── public/ │ ├── cache-sw.js │ └── site.webmanifest ├── src/ │ ├── App.tsx │ ├── app.css │ ├── controls/ │ │ ├── controls-layout.css │ │ ├── controls-layout.tsx │ │ ├── file-controls.css │ │ ├── file-controls.tsx │ │ ├── index.ts │ │ ├── preview-controls.css │ │ ├── preview-controls.tsx │ │ ├── title-controls.css │ │ └── title-controls.tsx │ ├── documents/ │ │ ├── bar.tsx │ │ ├── document.tsx │ │ ├── events-section.tsx │ │ ├── fonts.ts │ │ ├── grouped-section.tsx │ │ ├── icons/ │ │ │ ├── github-icon.tsx │ │ │ ├── globe-icon.tsx │ │ │ ├── index.ts │ │ │ ├── linkedin-icon.tsx │ │ │ ├── location-icon.tsx │ │ │ ├── mail-icon.tsx │ │ │ ├── map-pin-icon.tsx │ │ │ └── phone-icon.tsx │ │ ├── index.ts │ │ ├── rich-text.tsx │ │ ├── section.tsx │ │ ├── sections/ │ │ │ ├── awards-section.tsx │ │ │ ├── basics-section/ │ │ │ │ ├── contacts.tsx │ │ │ │ ├── index.tsx │ │ │ │ ├── info-item.tsx │ │ │ │ └── location-info-item.tsx │ │ │ ├── certificates-section.tsx │ │ │ ├── education-section.tsx │ │ │ ├── index.ts │ │ │ ├── projects-section.tsx │ │ │ ├── publications-section.tsx │ │ │ ├── skills-section.tsx │ │ │ ├── volunteer-section.tsx │ │ │ └── work-section.tsx │ │ ├── stack.tsx │ │ ├── svg-icon.tsx │ │ ├── theme.ts │ │ ├── use-has-page-break.ts │ │ └── utils/ │ │ ├── format-date.test.ts │ │ ├── format-date.ts │ │ ├── get-sections-order.test.ts │ │ ├── get-sections-order.ts │ │ └── index.ts │ ├── editing/ │ │ ├── index.ts │ │ ├── schema.css │ │ ├── schema.tsx │ │ ├── type-highlighter.tsx │ │ ├── yaml-editor.css │ │ └── yaml-editor.tsx │ ├── icons/ │ │ ├── download-icon.tsx │ │ ├── folder-icon.tsx │ │ ├── index.ts │ │ ├── info-icon.tsx │ │ ├── pdf-icon.tsx │ │ ├── plus-icon.tsx │ │ ├── zoom-in-icon.tsx │ │ └── zoom-out-icon.tsx │ ├── index.css │ ├── main.tsx │ ├── panes-layout.tsx │ ├── parsing/ │ │ ├── index.ts │ │ ├── parse-yaml.ts │ │ ├── resume-schema.json │ │ ├── sample.ts │ │ ├── use-yaml-parsing.test.tsx │ │ ├── use-yaml-parsing.ts │ │ ├── validate-json.ts │ │ └── yaml-to-json.ts │ ├── persistence/ │ │ ├── file-management.ts │ │ ├── index.ts │ │ ├── use-yaml-persistence.test.ts │ │ └── use-yaml-persistence.ts │ ├── rendering/ │ │ ├── debounced-queue.test.ts │ │ ├── debounced-queue.ts │ │ ├── double-buffered.tsx │ │ ├── index.ts │ │ ├── multi-page-document.tsx │ │ ├── pdf.css │ │ ├── pdf.tsx │ │ ├── use-render.tsx │ │ ├── use-scale.test.ts │ │ └── use-scale.ts │ ├── types.ts │ ├── utils/ │ │ ├── clamp.ts │ │ ├── deferred.ts │ │ ├── index.ts │ │ ├── sleep.ts │ │ └── use-debounced-effect.ts │ └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .eslintrc.cjs ================================================ module.exports = { root: true, env: { browser: true, es2020: true }, extends: [ "eslint:recommended", "plugin:@typescript-eslint/recommended", "plugin:react-hooks/recommended", ], ignorePatterns: ["dist", ".eslintrc.cjs"], parser: "@typescript-eslint/parser", plugins: ["react-refresh"], rules: { "react-refresh/only-export-components": [ "warn", { allowConstantExport: true }, ], }, }; ================================================ FILE: .gitignore ================================================ # 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? /test-results/ /playwright-report/ /playwright/.cache/ ================================================ FILE: README.md ================================================

Devices preview

DevResume

Resume creator based on writing YAML with live preview and PDF export.


Website: https://devresume.app **Completely free** • **No sign-up** • **Live preview** • **Works offline** • **Unlimited exports**
## Motivation DevResume helps developers and technical people write their resume instead of wrestling with buttons and menus. The configuration is based on a subset of the [JSON resume standard](https://jsonresume.org/). It includes only the actually useful parts of it. The best practices are already taken care of based on the recommendations from the book [The Tech Resume Inside Out](https://thetechresume.com/). You are free to focus on the content. ## Scripts In the project directory, you can run: #### `npm run dev` Runs the app in the development mode.\ Open [http://127.0.0.1:5173/](http://127.0.0.1:5173/) to view it in the browser. #### `npm run build` Builds the app for production in the `dist` folder. #### `npm run test` Runs the unit and integrations tests using Vitest. #### `npm run test:e2e` Runs the E2E tests using Playwright. ================================================ FILE: components.json ================================================ { "$schema": "https://ui.shadcn.com/schema.json", "style": "new-york", "rsc": false, "tsx": true, "tailwind": { "config": "tailwind.config.js", "css": "src/index.css", "baseColor": "slate", "cssVariables": true }, "aliases": { "components": "@/components", "utils": "@/lib/utils" } } ================================================ FILE: index.html ================================================ DevResume
================================================ FILE: package.json ================================================ { "name": "devresume", "private": true, "version": "0.0.0", "type": "module", "scripts": { "dev": "vite --host", "build": "tsc && vite build", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", "preview": "vite preview", "test": "vitest", "test:e2e": "npx playwright test" }, "dependencies": { "@codemirror/language": "^6.6.0", "@codemirror/legacy-modes": "^6.3.1", "@codemirror/lint": "^6.2.0", "@codemirror/stream-parser": "^0.19.9", "@react-hook/resize-observer": "^1.2.6", "@react-pdf/renderer": "3.1.12", "@uiw/codemirror-theme-github": "^4.19.9", "@uiw/codemirror-theme-vscode": "^4.21.18", "@uiw/react-codemirror": "^4.19.9", "markdown-to-jsx": "^7.3.2", "react": "^18.2.0", "react-dom": "^18.2.0", "react-pdf": "7.3.3", "split-pane-react": "^0.1.3", "yaml": "^2.3.2", "z-schema": "^6.0.1" }, "devDependencies": { "@playwright/test": "^1.39.0", "@testing-library/react": "^14.0.0", "@types/codemirror": "^5.60.10", "@types/node": "^20.8.8", "@types/react": "^18.2.15", "@types/react-dom": "^18.2.7", "@typescript-eslint/eslint-plugin": "^6.0.0", "@typescript-eslint/parser": "^6.0.0", "@vitejs/plugin-react": "^4.0.3", "eslint": "^8.45.0", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.3", "jsdom": "^22.1.0", "typescript": "^5.0.2", "vite": "^4.4.5", "vitest": "^0.34.6" } } ================================================ FILE: playwright/components/controls.ts ================================================ import { Page, expect } from "@playwright/test"; export function PreviewControls(page: Page) { const zoomIn = () => page.getByTestId("zoom-in").click(); const zoomOut = () => page.getByTestId("zoom-out").click(); const exportPDF = () => page.getByTestId("export").click(); return { zoomIn, zoomOut, exportPDF, }; } export function TitleControls(page: Page) { const input = page.getByTestId("title"); const setTitle = (value: string) => input.fill(value); return { setTitle, expect: () => ({ toHaveTitle: (title: string) => expect(input).toHaveValue(title), }), }; } export function FileControls(page: Page) { const open = () => page.getByTestId("open").click(); const save = () => page.getByTestId("save").click(); const newResume = () => page.getByTestId("new").click(); return { open, save, newResume, }; } ================================================ FILE: playwright/components/editor.ts ================================================ import { Page, expect } from "@playwright/test"; export function Editor(page: Page) { const self = page.locator(".cm-content"); const type = async (value: string) => { await self.focus(); await page.keyboard.insertText(value); }; const clearAndRefresh = async () => { await page.evaluate(() => { localStorage["yaml"] = ""; }); await page.reload(); }; return { type, clearAndRefresh, expect: () => ({ toHaveEmptyText: async () => { await expect .poll(async () => { const text = await self.innerText(); return text.trim(); }) .toEqual(""); }, }), }; } ================================================ FILE: playwright/components/index.ts ================================================ export * from "./controls"; export * from "./pdf-document"; export * from "./editor"; ================================================ FILE: playwright/components/pdf-document.ts ================================================ import { Page, expect } from "@playwright/test"; export function PDFDocument(page: Page) { // We need to check the data-ready attribute as well // in order to avoid getting the back buffer const self = page.locator('[data-testid="pdf-document"][data-ready="true"]'); const pages = page.locator(".react-pdf__Page"); const waitToAppear = () => self.waitFor({ state: "visible" }); const getScale = async () => { const scale = await self.getAttribute("data-scale"); return Number(scale); }; const waitToZoomIn = async (initialScale: number) => { expect.poll(getScale).toBeGreaterThan(initialScale); }; const waitToZoomOut = async (initialScale: number) => { expect.poll(getScale).toBeLessThan(initialScale); }; return { self, waitToAppear, getScale, waitToZoomIn, waitToZoomOut, expect: () => ({ ...expect(self), async toHaveScreenshotsOfPages() { for (const page of await pages.all()) { await expect(page).toHaveScreenshot({ scale: "device" }); } }, }), }; } ================================================ FILE: playwright/controls.spec.ts ================================================ import { test, expect } from "@playwright/test"; import { PDFDocument } from "./components/pdf-document"; import { Editor, FileControls, PreviewControls, TitleControls, } from "./components"; import { SAMPLE_YAML } from "../src/parsing/sample"; test.describe("preview controls", () => { let pdfDocument; test.beforeEach(async ({ page }) => { await page.goto("/"); pdfDocument = PDFDocument(page); await pdfDocument.waitToAppear(); }); test.describe("zooming", () => { let initialScale; test.beforeEach(async () => { initialScale = await pdfDocument.getScale(); }); test("should zoom-in", async ({ page }) => { await PreviewControls(page).zoomIn(); await pdfDocument.waitToZoomIn(initialScale); await pdfDocument.expect().toHaveScreenshotsOfPages(); }); test("should zoom-out", async ({ page }) => { await PreviewControls(page).zoomOut(); await pdfDocument.waitToZoomOut(initialScale); await pdfDocument.expect().toHaveScreenshotsOfPages(); }); }); test("should export pdf", async ({ page }) => { const title = "TestTitle"; await TitleControls(page).setTitle(title); const downloadPromise = page.waitForEvent("download"); await PreviewControls(page).exportPDF(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe(title + ".pdf"); }); }); test.describe("file controls", () => { test("should open .yml files", async ({ page }) => { await page.goto("/"); await Editor(page).clearAndRefresh(); const fileChooserPromise = page.waitForEvent("filechooser"); await FileControls(page).open(); const fileChooser = await fileChooserPromise; await fileChooser.setFiles({ name: "sample.yml", mimeType: "text/yaml", buffer: Buffer.from(SAMPLE_YAML), }); await TitleControls(page).expect().toHaveTitle("sample"); const pdfDocument = PDFDocument(page); await pdfDocument.waitToAppear(); await pdfDocument.expect().toHaveScreenshotsOfPages(); }); test("should save yaml", async ({ page }) => { await page.goto("/"); const title = "TestTitle"; await TitleControls(page).setTitle(title); const downloadPromise = page.waitForEvent("download"); await FileControls(page).save(); const download = await downloadPromise; expect(download.suggestedFilename()).toBe(title + ".yaml"); }); test("should create new resumes", async ({ page }) => { await page.goto("/"); await FileControls(page).newResume(); await Editor(page).expect().toHaveEmptyText(); await PDFDocument(page).expect().toBeHidden(); }); }); ================================================ FILE: playwright/sections.spec.ts ================================================ import { test } from "@playwright/test"; import { Editor, PDFDocument } from "./components"; import { basicsYAML, workYAML, volunteerYAML, educationYAML, awardsYAML, publicationsYAML, skillsYAML, projectsYAML, } from "./yaml"; const sections = [ { name: "basics", yaml: basicsYAML }, { name: "work", yaml: workYAML }, { name: "volunteer", yaml: volunteerYAML }, { name: "education", yaml: educationYAML }, { name: "awards", yaml: awardsYAML }, { name: "publications", yaml: publicationsYAML }, { name: "skills", yaml: skillsYAML }, { name: "projects", yaml: projectsYAML }, ]; const orders = [ { name: "default", sectionsOrder: [ "basics", "skills", "work", "projects", "education", "awards", "certificates", "publications", "volunteer", ], }, { name: "user-specified", sectionsOrder: [ "basics", "volunteer", "work", "projects", "awards", "certificates", "skills", "publications", "education", ], }, ]; for (const { name, yaml } of sections) { test(`should render section: ${name}`, async ({ page }) => { await page.goto("/"); const editor = Editor(page); await editor.clearAndRefresh(); await editor.type(yaml); const document = PDFDocument(page); await document.waitToAppear(); await document.expect().toHaveScreenshotsOfPages(); }); } for (const { name, sectionsOrder } of orders) { test(`should render sections in the specified order: ${name}`, async ({ page, }) => { await page.goto("/"); const editor = Editor(page); await editor.clearAndRefresh(); for (const { yaml } of sections) { await editor.type(yaml); } await editor.type( `meta:\n sectionsOrder:\n${sectionsOrder .map((sectionName) => ` - ${sectionName}`) .join("\n")}` ); const document = PDFDocument(page); await document.waitToAppear(); await document.expect().toHaveScreenshotsOfPages(); }); } test(`should each section in sectionsPageBreaks on a new page`, async ({ page, }) => { await page.goto("/"); const editor = Editor(page); await editor.clearAndRefresh(); for (const { yaml } of sections) { await editor.type(yaml); } await editor.type( `meta:\n sectionsPageBreaks:\n${sections .map(({ name }) => ` - ${name}`) .join("\n")}` ); const document = PDFDocument(page); await document.waitToAppear(); await document.expect().toHaveScreenshotsOfPages(); }); ================================================ FILE: playwright/yaml.ts ================================================ export const basicsYAML = ` basics: name: Name1 Name2 label: Label email: email@test.com phone: (912) 555-4321 url: http://url.test.com summary: | Summary Line1 Summary Line2 location: city: City countryCode: Country profiles: - network: github url: github.com/test - network: linkedin url: linkedin.com/test `; export const workYAML = ` work: - name: Work1 position: Position1 location: Location1 url: http://url1.example.com summary: Summary1 **summary bold** *symmary italic* [summary link](https://link.test.com). startDate: 2010-12 endDate: 2010-01 highlights: - Highlight 2.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. - name: Work2 position: Position2 location: Location2 url: http://url2.example.com summary: Summary2 **summary bold** *symmary italic* [summary link](https://link.test.com). startDate: 2009-12 endDate: 2009-01 highlights: - Highlight 2.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. `; export const volunteerYAML = ` volunteer: - organization: Organization1 position: Position1 url: http://url1.example.com/ startDate: 2010-12 endDate: 2010-01 highlights: - Highlight 1.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 1.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. - organization: Organization2 position: Position2 url: http://url2.example.com/ startDate: 2009-12 endDate: 2009-01 highlights: - Highlight 2.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. `; export const educationYAML = ` education: - institution: Institution1 url: https://url1.test.com/ area: Area1 score: Score1 **score bold** *score italic* [score link](https://link.test.com). startDate: 2010-12 endDate: 2010-01 courses: - Course 1.1 **course bold** *course italic* [course link](https://link.test.com). - Course 1.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. - institution: Institution2 url: https://url2.test.com/ area: Area2 score: Score2 **score bold** *score italic* [score link](https://link.test.com). startDate: 2009-12 endDate: 2009-01 courses: - Course 2.1 **course bold** *course italic* [course link](https://link.test.com). - Course 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. `; export const awardsYAML = ` awards: - title: Award1 date: 2014-11 awarder: Awarder1 summary: Summary 1 **summary bold** *summary italic* [summary link](https://link.test.com). - title: Award2 date: 2012-02 awarder: Awarder2 summary: Summary 2 **summary bold** *summary italic* [summary link](https://link.test.com). `; export const publicationsYAML = ` publications: - name: Name1 publisher: Publiser1 releaseDate: 2014-12-06 url: http://url1.test.com summary: Summary 1 **summary bold** *summary italic* [summary link](https://link.test.com). - name: Name2 publisher: Publiser2 releaseDate: 2010-10-10 url: http://url2.test.com summary: Summary 2 **summary bold** *summary italic* [summary link](https://link.test.com). `; export const skillsYAML = ` skills: - name: Category1 keywords: ["Skill 1.1", "Skill 1.2"] - name: Category2 keywords: ["Skill 2.1"] `; export const projectsYAML = ` projects: - name: Project1 description: Description 1 **description bold** *description italic* [description link](https://link.test.com). startDate: 2010-12 endDate: 2010-01 url: url1.example.com highlights: - Highlight 2.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. - name: Project2 description: Description 2 **description bold** *description italic* [description link](https://link.test.com). startDate: 2009-12 endDate: 2009-01 url: url1.example.com highlights: - Highlight 2.1 **highlight bold** *highlight italic* [hightlight link](https://link.test.com). - Highlight 2.2 that takes more space to test what happens with the resume in this case. Some more text to force new lines in the pdf. `; ================================================ FILE: playwright.config.ts ================================================ import { defineConfig, devices } from "@playwright/test"; /** * Read environment variables from file. * https://github.com/motdotla/dotenv */ // require('dotenv').config(); /** * See https://playwright.dev/docs/test-configuration. */ const DEVICE_OVERRIDES = { viewport: { width: 1500, height: 1000, }, deviceScaleFactor: 2, }; export default defineConfig({ testDir: "./playwright", /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, retries: 0, workers: 3, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ reporter: "html", /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Base URL to use in actions like `await page.goto('/')`. */ baseURL: "http://127.0.0.1:5173/", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ trace: "on-first-retry", }, /* Configure projects for major browsers */ projects: [ { name: "chromium", use: { ...devices["Desktop Chrome"], ...DEVICE_OVERRIDES, }, }, { name: "firefox", use: { ...devices["Desktop Firefox"], ...DEVICE_OVERRIDES, }, }, { name: "webkit", use: { ...devices["Desktop Safari"], ...DEVICE_OVERRIDES, }, }, ], }); ================================================ FILE: public/cache-sw.js ================================================ self.addEventListener("activate", (event) => { console.log("SW: Activate", event); event.waitUntil(self.clients.claim()); }); self.addEventListener("install", () => { console.log("SW: Install"); }); const putInCache = async (request, response) => { const cache = await caches.open("v1"); await cache.put(request, response); }; const cacheFirst = async (request) => { const responseFromCache = await caches.match(request); if (responseFromCache) { return responseFromCache; } const responseFromNetwork = await fetch(request); putInCache(request, responseFromNetwork.clone()); return responseFromNetwork; }; self.addEventListener("fetch", (event) => { if (event.request.url.includes("pdf.worker")) { event.respondWith(cacheFirst(event.request)); } }); ================================================ FILE: public/site.webmanifest ================================================ { "name": "", "short_name": "", "icons": [ { "src": "/android-chrome-192x192.png", "sizes": "192x192", "type": "image/png" }, { "src": "/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" } ], "theme_color": "#FFFFFF", "background_color": "#FFFFFF", "display": "standalone" } ================================================ FILE: src/App.tsx ================================================ import { RefObject, useCallback, useRef, useState } from "react"; import { PDF, useRender, useScale } from "./rendering"; import { Schema, YAMLEditor } from "./editing"; import "split-pane-react/esm/themes/default.css"; import { ControlsLayout, FileControls, PreviewControls, TitleControls, } from "./controls"; import { PanesLayout } from "./panes-layout"; import "./app.css"; import { useYAMLPersistence, downloadFile } from "./persistence"; import { useYAMLParsing } from "./parsing"; import { ReactCodeMirrorRef } from "@uiw/react-codemirror"; export function App() { const { queue, blob, setBlob } = useRender(); const { zoomIn, zoomOut, scale, maxScaleReached, minScaleReached } = useScale( { minScale: 0.5, maxScale: 2 } ); const [title, setTitle] = useState("Untitled"); const codeMirrorRef: RefObject = useRef(null); // Parsing const onYAMLParsed = useCallback( (yaml: string, json: object | undefined) => { if (json) queue.push(json); else if (!yaml) queue.clear(); }, [queue] ); const { setYAML, yaml } = useYAMLParsing({ onYAMLParsed }); // Persistence const onFileOpened = useCallback( (fileTitle: string, fileContents: string) => { setTitle(fileTitle); setYAML(fileContents); }, [setYAML] ); const { save, open } = useYAMLPersistence({ title, yaml: yaml, onFileOpened, }); // Export const onDownload = useCallback(() => { if (blob) { downloadFile(title, blob); } }, [blob, title]); const onNewResume = useCallback(() => { setTitle("Untitled"); setYAML(""); setBlob(null); if (codeMirrorRef.current && codeMirrorRef.current.view) { codeMirrorRef.current.view.focus(); } }, [setYAML, setBlob]); return (
} center={} right={ } /> } right={} bottom={} />
); } ================================================ FILE: src/app.css ================================================ .App { display: flex; position: relative; width: 100vw; height: 100vh; flex-direction: column; } ================================================ FILE: src/controls/controls-layout.css ================================================ .ControlsLayout { display: flex; background-color: var(--color-gray-100); border-bottom: 1px solid var(--color-gray-400); } ================================================ FILE: src/controls/controls-layout.tsx ================================================ import { ReactElement, cloneElement } from "react"; import "./controls-layout.css"; type Props = { left: ReactElement; center: ReactElement; right: ReactElement; }; export function ControlsLayout({ left, center, right }: Props) { return (
{cloneElement(left, { style: { flex: 1 } })} {cloneElement(center, { style: { flex: 1 } })} {cloneElement(right, { style: { flex: 1 } })}
); } ================================================ FILE: src/controls/file-controls.css ================================================ .FileControls { gap: var(--space-3); display: flex; padding: var(--space-6); color: white; } ================================================ FILE: src/controls/file-controls.tsx ================================================ import { CSSProperties } from "react"; import "./file-controls.css"; import { DownloadIcon, FolderIcon, InfoIcon, PlusIcon } from "../icons"; type Props = { onSave: () => void; onOpen: () => void; onNew: () => void; style?: CSSProperties; }; export function FileControls({ onSave, onOpen, onNew, style }: Props) { return (
); } ================================================ FILE: src/controls/index.ts ================================================ export * from "./controls-layout"; export * from "./preview-controls"; export * from "./title-controls"; export * from "./file-controls"; ================================================ FILE: src/controls/preview-controls.css ================================================ .PreviewControls { display: flex; gap: var(--space-3); justify-content: flex-end; padding: var(--space-6); } ================================================ FILE: src/controls/preview-controls.tsx ================================================ import { CSSProperties } from "react"; import "./preview-controls.css"; import { ZoomOutIcon, PDFIcon, ZoomInIcon } from "../icons"; type Props = { zoomInDisabled: boolean; zoomOutDisabled: boolean; onZoomIn: () => void; onZoomOut: () => void; onDownload: () => void; style?: CSSProperties; }; export function PreviewControls({ zoomInDisabled, zoomOutDisabled, onZoomIn, onZoomOut, onDownload, style, }: Props) { return (
); } ================================================ FILE: src/controls/title-controls.css ================================================ .TitleControls { height: 100%; display: flex; justify-content: center; overflow-x: hidden; align-items: center; } .TitleControls-Input { width: 90%; color: white; font-weight: 600; background-color: transparent; border: none; text-align: center; font-size: var(--font-size-3); } .TitleControls-Input:focus { outline: var(--color-blue-100); outline-style: solid; outline-width: var(--space-1); } ================================================ FILE: src/controls/title-controls.tsx ================================================ import { CSSProperties } from "react"; import "./title-controls.css"; type Props = { title: string; onChange: (value: string) => void; style?: CSSProperties; }; export function TitleControls({ title, onChange, style }: Props) { return (
onChange(event.target.value)} onBlur={(event) => { if (!event.target.value) { onChange("Untitled"); } }} />
); } ================================================ FILE: src/documents/bar.tsx ================================================ import { View, StyleSheet } from "@react-pdf/renderer"; import { Theme } from "./theme"; import { useMemo } from "react"; type Props = { theme: Theme; }; function createStyles(theme: Theme) { return StyleSheet.create({ root: { backgroundColor: theme.color.accent, height: theme.space[4], position: "absolute", left: 0, right: 0, top: 0, }, }); } export function Bar({ theme }: Props) { const styles = useMemo(() => createStyles(theme), [theme]); return ; } ================================================ FILE: src/documents/document.tsx ================================================ import { Resume } from "../types"; import { Page, Document, StyleSheet } from "@react-pdf/renderer"; import { BasicsSection } from "./sections/basics-section"; import { AwardsSection, CertificatesSection, EducationSection, ProjectsSection, PublicationsSection, SkillsSection, VolunteerSection, WorkSection, } from "./sections"; import { Theme, createTheme } from "./theme"; import { Bar } from "./bar"; import { useMemo } from "react"; import { getSectionsOrder } from "./utils"; import { SectionProps } from "./section"; import { useHasPageBreak } from "./use-has-page-break"; type Props = { resume: Resume; }; function createStyles(theme: Theme) { return StyleSheet.create({ page: { backgroundColor: "white", fontFamily: "Roboto", paddingVertical: theme.space[10], paddingHorizontal: theme.space[12], fontSize: theme.fontSize[0], lineHeight: theme.lineHeight, color: theme.color.text, }, }); } export function ResumeDocument({ resume }: Props) { const { basics, work, skills, projects, education, awards, certificates, publications, volunteer, meta, } = resume; const accentColor = meta && meta.accentColor; const baseFontSize = meta && meta.baseFontSize; const sectionsOrder = useMemo(() => getSectionsOrder(meta), [meta]); const theme = useMemo( () => createTheme(accentColor, baseFontSize), [accentColor, baseFontSize] ); const styles = createStyles(theme); const hasPageBreak = useHasPageBreak(meta); return ( {sectionsOrder.map((sectionName) => { const commonProps: SectionProps = { theme, hasPageBreak: hasPageBreak(sectionName), }; if (sectionName === "basics" && basics) { return ( ); } if (sectionName === "skills" && Array.isArray(skills)) { return ( ); } if (sectionName === "work" && Array.isArray(work)) { return ( ); } if (sectionName === "projects" && Array.isArray(projects)) { return ( ); } if (sectionName === "education" && Array.isArray(education)) { return ( ); } if (sectionName === "awards" && Array.isArray(awards)) { return ( ); } if (sectionName === "certificates" && Array.isArray(certificates)) { return ( ); } if (sectionName === "publications" && Array.isArray(publications)) { return ( ); } if (sectionName === "volunteer" && Array.isArray(volunteer)) { return ( ); } return null; })} ); } ================================================ FILE: src/documents/events-section.tsx ================================================ import { Text, Link } from "@react-pdf/renderer"; import { Section, SectionProps } from "./section"; import { HStack, VStack } from "./stack"; import { RichText } from "./rich-text"; import { Fragment, ReactElement, ReactNode } from "react"; import { formatDate } from "./utils"; import { Theme } from "./theme"; type EventHighlightItemProps = { children: string; }; export function EventHighlightItem({ children }: EventHighlightItemProps) { return ( {children} ); } // type EventItemProps = { title?: string; url?: string; titleDetails?: Array; description?: string; children?: ReactNode; startDate?: string | number; endDate?: string | number; date?: string | number; theme: Theme; }; export function EventItem({ title, url, children, description, titleDetails, startDate, endDate, date, theme, }: EventItemProps) { return ( {url ? ( {title} ) : ( {title} )} {titleDetails && titleDetails.map((titleDetail, index) => ( {titleDetail} ))} {date ? ( formatDate(date) ) : startDate || endDate ? ( <> {startDate && formatDate(startDate)} {endDate ? ` - ${formatDate(endDate)}` : " - Present"} ) : null} {description && {description}} {children && {children}} ); } // export type EventsSectionProps = SectionProps; export function EventsSection({ title, children, theme, ...rest }: EventsSectionProps) { return (
{children}
); } ================================================ FILE: src/documents/fonts.ts ================================================ import { Font } from "@react-pdf/renderer"; Font.register({ family: "Roboto", fonts: [ { src: "RobotoRegular.ttf", fontWeight: "normal", }, { src: "RobotoMedium.ttf", fontWeight: "medium", }, { src: "RobotoItalic.ttf", fontStyle: "italic", }, ], }); ================================================ FILE: src/documents/grouped-section.tsx ================================================ import { Text, View } from "@react-pdf/renderer"; import { Section } from "./section"; import { HStack, VStack } from "./stack"; import { RichText } from "./rich-text"; import { EventsSectionProps } from "./events-section"; type GroupItemProps = { title?: string; description?: string; }; export function GroupItem({ title, description }: GroupItemProps) { return ( {title && ( {title} )} {description ? ( {description} ) : null} ); } // export type GroupedSectionProps = EventsSectionProps; export function GroupedSection({ title, children, theme, ...rest }: GroupedSectionProps) { return (
{children}
); } ================================================ FILE: src/documents/icons/github-icon.tsx ================================================ import { SvgIcon, SvgIconProps } from "../svg-icon"; import { Path } from "@react-pdf/renderer"; export function GitHubIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/globe-icon.tsx ================================================ import { Circle, Line, Path } from "@react-pdf/renderer"; import { SvgIcon, SvgIconProps } from "../svg-icon"; export function GlobeIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/index.ts ================================================ export * from "./mail-icon"; export * from "./location-icon"; export * from "./github-icon"; export * from "./globe-icon"; export * from "./phone-icon"; export * from "./linkedin-icon"; export * from "./map-pin-icon"; ================================================ FILE: src/documents/icons/linkedin-icon.tsx ================================================ import { Circle, Path, Rect } from "@react-pdf/renderer"; import { SvgIcon, SvgIconProps } from "../svg-icon"; export function LinkedInIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/location-icon.tsx ================================================ import { Path, Circle } from "@react-pdf/renderer"; import { SvgIcon, SvgIconProps } from "../svg-icon"; export function LocationIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/mail-icon.tsx ================================================ import { Path, Polyline } from "@react-pdf/renderer"; import { SvgIconProps } from "../svg-icon"; import { SvgIcon } from "../svg-icon"; export function MailIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/map-pin-icon.tsx ================================================ import { Circle, Path } from "@react-pdf/renderer"; import { SvgIcon, SvgIconProps } from "../svg-icon"; export function MapPinIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/icons/phone-icon.tsx ================================================ import { SvgIcon, SvgIconProps } from "../svg-icon"; import { Path } from "@react-pdf/renderer"; export function PhoneIcon({ color, ...rest }: SvgIconProps) { return ( ); } ================================================ FILE: src/documents/index.ts ================================================ export * from "./document"; export * from "./fonts"; ================================================ FILE: src/documents/rich-text.tsx ================================================ import { Text, Link } from "@react-pdf/renderer"; import Markdown from "markdown-to-jsx"; import { ReactNode } from "react"; type Props = { children?: ReactNode; }; const TEXT_TYPES: Record = { em: true, p: true, span: true, strong: true, }; const STYLE_FOR_TEXT: Record = { em: { fontStyle: "italic" }, strong: { fontWeight: "medium" }, }; export function RichText({ children }: Props) { return ( {children} ); } else if (type === "a" && "href" in props) { return ( {children} ); } return {children}; }, }} /> ); } ================================================ FILE: src/documents/section.tsx ================================================ import { ReactNode, useMemo } from "react"; import { Text, View, StyleSheet } from "@react-pdf/renderer"; import { Theme } from "./theme"; export type SectionProps = { title?: string; children?: ReactNode; theme: Theme; hasPageBreak?: boolean; }; function createStyles(theme: Theme) { return StyleSheet.create({ title: { fontSize: theme.fontSize[1], color: theme.color.accent, marginBottom: theme.space[5], fontWeight: "medium", }, root: { marginBottom: theme.space[10], }, }); } export function Section({ title, children, theme, hasPageBreak, }: SectionProps) { const styles = useMemo(() => createStyles(theme), [theme]); return ( {title && {title}} {children} ); } ================================================ FILE: src/documents/sections/awards-section.tsx ================================================ import { Award } from "../../types"; import { Text } from "@react-pdf/renderer"; import { EventItem, EventsSection, EventsSectionProps, } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; export type AwardItemProps = { award: Award; theme: Theme; }; export function AwardItem({ award, theme }: AwardItemProps) { const titleDetails: Array = []; if (award.awarder) { titleDetails.push({award.awarder}); } return ( ); } // export type AwardsSectionProps = { awards: Array; } & EventsSectionProps; export function AwardsSection({ awards, theme, ...rest }: AwardsSectionProps) { return ( {awards.map( (award, index) => award && )} ); } ================================================ FILE: src/documents/sections/basics-section/contacts.tsx ================================================ import { InfoItem } from "./info-item"; import { GitHubIcon, GlobeIcon, LinkedInIcon, MailIcon, PhoneIcon, } from "../../icons"; import { VStack } from "../../stack"; import { Basics, Profile } from "../../../types"; import { Style } from "@react-pdf/types"; import { Theme } from "../../theme"; import { LocationInfoItem } from "./location-info-item"; export type Props = { basics: Basics; style: Style; theme: Theme; }; function getProfiles(basics: Basics) { let linkedinProfile: Profile | null = null; let githubProfile: Profile | null = null; if (basics.profiles) { for (const profile of basics.profiles) { if (profile.network === "github") githubProfile = profile; else if (profile.network === "linkedin") linkedinProfile = profile; } } return { linkedinProfile, githubProfile }; } export function Contacts({ basics, style, theme }: Props) { const { linkedinProfile, githubProfile } = getProfiles(basics); return ( {basics.phone && ( } value={basics.phone} /> )} {basics.email && ( } value={basics.email} /> )} {basics.url && ( } value={basics.url} /> )} {githubProfile && githubProfile.url && ( } value={githubProfile.url} href={githubProfile.url} /> )} {linkedinProfile && linkedinProfile.url && ( } value={linkedinProfile.url} href={linkedinProfile.url} /> )} {basics.location && ( )} ); } ================================================ FILE: src/documents/sections/basics-section/index.tsx ================================================ import { Text, View, StyleSheet } from "@react-pdf/renderer"; import { Section, SectionProps } from "../../section"; import { HStack, VStack } from "../../stack"; import { Basics } from "../../../types"; import { RichText } from "../../rich-text"; import { Contacts } from "./contacts"; import { Theme } from "../../theme"; import { useMemo } from "react"; export type Props = { basics: Basics; } & SectionProps; function createStyles(theme: Theme) { return StyleSheet.create({ name: { fontSize: theme.fontSize[2], fontWeight: "medium" }, label: { color: theme.color.lightText, fontSize: theme.fontSize[1], }, }); } export function BasicsSection({ basics, theme, ...rest }: Props) { const styles = useMemo(() => createStyles(theme), [theme]); return (
{basics.name && {basics.name}} {basics.label && ( • {basics.label} )} {basics.summary && {basics.summary}}
); } ================================================ FILE: src/documents/sections/basics-section/info-item.tsx ================================================ import { Text, Link, StyleSheet } from "@react-pdf/renderer"; import { ReactElement, cloneElement, useMemo } from "react"; import { HStack } from "../../stack"; import { Theme } from "../../theme"; type Props = { icon: ReactElement; value: string; href?: string; theme: Theme; }; function createStyles(theme: Theme) { return StyleSheet.create({ icon: { size: theme.fontSize[0], color: theme.color.lightText, style: { marginRight: theme.space[2] }, }, link: { color: theme.color.lightText, textDecoration: "none" }, }); } export function InfoItem({ icon, href, value, theme }: Props) { const styles = useMemo(() => createStyles(theme), [theme]); return ( {cloneElement(icon, styles.icon)} {href ? ( {value} ) : ( {value} )} ); } ================================================ FILE: src/documents/sections/basics-section/location-info-item.tsx ================================================ import { Location } from "../../../types"; import { MapPinIcon } from "../../icons"; import { Theme } from "../../theme"; import { InfoItem } from "./info-item"; type Props = { location: Location; theme: Theme; }; export function LocationInfoItem({ location, theme }: Props) { const parts = []; const { city, countryCode } = location; if (city) parts.push(city); if (countryCode) parts.push(countryCode); const value = parts.join(", "); return } value={value} />; } ================================================ FILE: src/documents/sections/certificates-section.tsx ================================================ import { Certificate } from "../../types"; import { Text } from "@react-pdf/renderer"; import { EventItem, EventsSection } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; import { SectionProps } from "../section"; export type CertificateItemProps = { certificate: Certificate; theme: Theme; }; export function CerticateItem({ certificate, theme }: CertificateItemProps) { const titleDetails: Array = []; if (certificate.issuer) { titleDetails.push({certificate.issuer}); } return ( ); } // type CertificatesSection = { certificates: Array; } & SectionProps; export function CertificatesSection({ theme, certificates, ...rest }: CertificatesSection) { return ( {certificates.map( (certificate, index) => certificate && ( ) )} ); } ================================================ FILE: src/documents/sections/education-section.tsx ================================================ import { EducationPlace } from "../../types"; import { Link } from "@react-pdf/renderer"; import { EventHighlightItem, EventItem, EventsSection, EventsSectionProps, } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; export type EducationPlaceItemProps = { educationPlace: EducationPlace; theme: Theme; }; export function EducationPlaceItem({ educationPlace, theme, }: EducationPlaceItemProps) { const titleDetails: Array = []; if (educationPlace.institution) { titleDetails.push( {educationPlace.institution} ); } return ( {educationPlace.courses && Array.isArray(educationPlace.courses) && educationPlace.courses.map( (course) => course && ( {course} ) )} ); } // type EducationSectionProps = { education: Array; } & EventsSectionProps; export function EducationSection({ education, theme, ...rest }: EducationSectionProps) { return ( {education.map( (educationPlace, index) => educationPlace && ( ) )} ); } ================================================ FILE: src/documents/sections/index.ts ================================================ export * from "./basics-section"; export * from "./work-section"; export * from "./education-section"; export * from "./skills-section"; export * from "./projects-section"; export * from "./awards-section"; export * from "./certificates-section"; export * from "./publications-section"; export * from "./volunteer-section"; ================================================ FILE: src/documents/sections/projects-section.tsx ================================================ import { Project } from "../../types"; import { EventHighlightItem, EventItem, EventsSection, EventsSectionProps, } from "../events-section"; import { Theme } from "../theme"; export type ProjectItemProps = { project: Project; theme: Theme; }; export function ProjectItem({ project, theme }: ProjectItemProps) { return ( {project.highlights && Array.isArray(project.highlights) && project.highlights.map( (hightlight) => hightlight && ( {hightlight} ) )} ); } // type ProjectsSectionProps = { projects: Array; } & EventsSectionProps; export function ProjectsSection({ projects, theme, ...rest }: ProjectsSectionProps) { return ( {projects.map( (project, index) => project && )} ); } ================================================ FILE: src/documents/sections/publications-section.tsx ================================================ import { Publication } from "../../types"; import { Text } from "@react-pdf/renderer"; import { EventItem, EventsSection, EventsSectionProps, } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; export type PublicationItemProps = { publication: Publication; theme: Theme; }; export function PublicationItem({ publication, theme }: PublicationItemProps) { const titleDetails: Array = []; const { name, url, publisher, releaseDate, summary } = publication; if (publisher) titleDetails.push({publisher}); return ( ); } // type PublicationsSectionProps = { publications: Array; } & EventsSectionProps; export function PublicationsSection({ publications, theme, ...rest }: PublicationsSectionProps) { return ( {publications.map( (publication, index) => publication && ( ) )} ); } ================================================ FILE: src/documents/sections/skills-section.tsx ================================================ import { Skill } from "../../types"; import { GroupItem, GroupedSection, GroupedSectionProps, } from "../grouped-section"; type SkillItemProps = { skill: Skill; }; export function SkillItem({ skill }: SkillItemProps) { const { keywords } = skill; const description = keywords && Array.isArray(keywords) ? keywords.join(", ") : ""; return ; } // type SkillsSectionProps = { skills: Array; } & GroupedSectionProps; export function SkillsSection({ skills, theme, ...rest }: SkillsSectionProps) { return ( {skills.map( (skill, index) => skill && )} ); } ================================================ FILE: src/documents/sections/volunteer-section.tsx ================================================ import { Voluteering } from "../../types"; import { Link } from "@react-pdf/renderer"; import { EventHighlightItem, EventItem, EventsSectionProps, EventsSection, } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; export type VolunteeringItemProps = { volunteering: Voluteering; theme: Theme; }; export function VolunteeringItem({ volunteering, theme, }: VolunteeringItemProps) { const titleDetails: Array = []; if (volunteering.organization) { titleDetails.push( {volunteering.organization} ); } return ( {volunteering.highlights && Array.isArray(volunteering.highlights) && volunteering.highlights.map( (highlight) => highlight && ( {highlight} ) )} ); } // type VolunteerSectionProps = { volunteer: Array; } & EventsSectionProps; export function VolunteerSection({ volunteer, theme }: VolunteerSectionProps) { return ( {volunteer.map( (volunteering, index) => volunteering && ( ) )} ); } ================================================ FILE: src/documents/sections/work-section.tsx ================================================ import { Job } from "../../types"; import { Link, Text } from "@react-pdf/renderer"; import { EventHighlightItem, EventItem, EventsSectionProps, EventsSection, } from "../events-section"; import { ReactElement } from "react"; import { Theme } from "../theme"; export type JobItemProps = { job: Job; theme: Theme; }; export function JobItem({ job, theme }: JobItemProps) { const titleDetails: Array = []; if (job.name) { titleDetails.push( {job.name} ); } if (job.location) { titleDetails.push( {job.location} ); } return ( {job.highlights && Array.isArray(job.highlights) && job.highlights.map( (hightlight) => hightlight && ( {hightlight} ) )} ); } // type WorkSectionProps = { work: Array; } & EventsSectionProps; export function WorkSection({ work, theme, ...rest }: WorkSectionProps) { return ( {work.map( (job, index) => job && )} ); } ================================================ FILE: src/documents/stack.tsx ================================================ import { ReactNode } from "react"; import { View } from "@react-pdf/renderer"; import { Style, ViewProps } from "@react-pdf/types"; type Props = ViewProps & { gap?: number; children: ReactNode; flexWrap?: Style["flexWrap"]; style?: Style; }; export function HStack({ gap, children, flexWrap, style, ...rest }: Props) { const updatedStyle: Style = { display: "flex", flexDirection: "row", gap, flexWrap, alignItems: "center", ...style, }; return ( {children} ); } export function VStack({ gap, children, flexWrap, style, ...rest }: Props) { const updatedStyle: Style = { display: "flex", flexDirection: "column", gap, flexWrap, ...style, }; return ( {children} ); } ================================================ FILE: src/documents/svg-icon.tsx ================================================ import { Svg } from "@react-pdf/renderer"; import { Style } from "@react-pdf/types"; import { ReactNode } from "react"; export type SvgIconProps = { size?: number; color?: string; style?: Style; children?: ReactNode; }; export function SvgIcon({ size = 24, style, children }: SvgIconProps) { return ( {children} ); } ================================================ FILE: src/documents/theme.ts ================================================ export type Theme = { lineHeight: number; space: Array; fontSize: Array; color: { text: string; lightText: string; accent: string; }; }; export function createTheme(accentColor = "#2B5DD6", baseFontSize = 10) { const clampedBaseFontSize = Math.min(Math.max(10, baseFontSize), 16); const result: Theme = { // 0 1 2 3 4 5 6 7 8 9 10 11 12 space: [2, 4, 6, 8, 10, 12, 14, 16, 20, 22, 24, 28, 32], lineHeight: 1.4, fontSize: [ clampedBaseFontSize, 1.4 * clampedBaseFontSize, 1.8 * clampedBaseFontSize, ], color: { text: "black", lightText: "#6b7280", accent: accentColor, }, }; return result; } ================================================ FILE: src/documents/use-has-page-break.ts ================================================ import { useCallback, useMemo } from "react"; import { Meta, ResumeSectionName } from "../types"; export function useHasPageBreak(meta?: Meta | null) { const sectionsPageBreaksSet = useMemo( () => meta && Array.isArray(meta.sectionsPageBreaks) ? new Set(meta.sectionsPageBreaks) : null, [meta] ); const hasPageBreak = useCallback( (sectionName: ResumeSectionName) => { return sectionsPageBreaksSet ? sectionsPageBreaksSet.has(sectionName) : false; }, [sectionsPageBreaksSet] ); return hasPageBreak; } ================================================ FILE: src/documents/utils/format-date.test.ts ================================================ import { expect, test } from "vitest"; import { formatDate } from "."; const tests = [ [2021, "2021"], ["2021", "2021"], ["2021-2", "Feb 2021"], ["2021-02", "Feb 2021"], ["2021-02-01", "Feb 2021"], ["2021-", "2021"], ["2021-15", "Invalid Date 2021"], ["2021-0", "Invalid Date 2021"], ]; test("returns the right format", () => { for (const [input, expectation] of tests) { expect(formatDate(input)).toEqual(expectation); } }); ================================================ FILE: src/documents/utils/format-date.ts ================================================ export function formatDate(stringOrNumber: string | number): string | null { const dateString = stringOrNumber.toString(); const parts = dateString.split("-").filter((part) => part !== ""); const partsCount = parts.length; if (partsCount === 0) return null; const year = parts[0]; if (partsCount === 1) return year; const date = new Date(dateString); const month = date.toLocaleString("en-US", { month: "short" }); return `${month} ${year}`; } ================================================ FILE: src/documents/utils/get-sections-order.test.ts ================================================ import { expect, test } from "vitest"; import { defaultSectionsOrder, getSectionsOrder } from "./get-sections-order"; import { ResumeSectionName } from "../../types"; test("returns the default order if no metadata", () => { const actual = getSectionsOrder(); expect(actual).toEqual(defaultSectionsOrder); }); test("returns the sections in the specified order ", () => { const sectionsOrder: ResumeSectionName[] = [ "basics", "awards", "education", "volunteer", "skills", "work", "certificates", "projects", "publications", ]; const actual = getSectionsOrder({ sectionsOrder, }); expect(actual).toEqual(sectionsOrder); }); test("returns the specified sections in their order and the others in the default one", () => { const sectionsOrder: ResumeSectionName[] = ["basics", "work", "education"]; const actual = getSectionsOrder({ sectionsOrder, }); expect(actual).toEqual([ ...sectionsOrder, "skills", "projects", "awards", "certificates", "publications", "volunteer", ]); }); ================================================ FILE: src/documents/utils/get-sections-order.ts ================================================ import { Meta, ResumeSectionName } from "../../types"; export const defaultSectionsOrder: ResumeSectionName[] = [ "basics", "skills", "work", "projects", "education", "awards", "certificates", "publications", "volunteer", ]; export function getSectionsOrder(meta?: Meta | null) { if (!meta || !Array.isArray(meta.sectionsOrder)) { return defaultSectionsOrder; } const { sectionsOrder } = meta; const finalSectionsOrder = [...sectionsOrder]; const leftOutSections = defaultSectionsOrder.filter( (sectionName) => !sectionsOrder.includes(sectionName) ); finalSectionsOrder.push(...leftOutSections); return finalSectionsOrder; } ================================================ FILE: src/documents/utils/index.ts ================================================ export * from "./format-date"; export * from "./get-sections-order"; ================================================ FILE: src/editing/index.ts ================================================ export * from "./yaml-editor"; export * from "./schema"; ================================================ FILE: src/editing/schema.css ================================================ .Schema { background-color: var(--color-gray-100); border-top: 1px solid var(--color-gray-400); border-right: 1px solid var(--color-gray-400); width: 100%; height: 100%; color: var(--color-white); padding: var(--space-6); overflow: scroll; } .Schema-Keyword { color: var(--color-blue-300); } .Schema-Type { color: var(--color-green-100); } .Schema-Plain { color: var(--color-white); } .Schema-Field { color: var(--color-blue-400); } .Schema-Comment { color: var(--color-gray-600); } ================================================ FILE: src/editing/schema.tsx ================================================ import { createTypeHighlighter } from "./type-highlighter"; import "./schema.css"; import { memo, useMemo } from "react"; function createHighlightedElements() { const highligher = createTypeHighlighter(); highligher.pushType("Resume"); highligher.pushObject("basics"); highligher.addStringField("name"); highligher.addStringField("label"); highligher.addStringField("email"); highligher.addStringField("summary", { markdown: true }); highligher.pushArrayOfObjects("profiles"); highligher.addEnumField("network", ["github", "network"]); highligher.pop(); highligher.pop(); highligher.pushArrayOfObjects("work"); highligher.addStringField("name"); highligher.addStringField("location"); highligher.addStringField("position"); highligher.addStringField("url"); highligher.addStringField("summary"); highligher.addStringField("startDate", { date: true }); highligher.addStringField("endDate", { date: true }); highligher.addArrayOfStringsField("highlights", { markdown: true }); highligher.pop(); highligher.pushArrayOfObjects("skills"); highligher.addStringField("name"); highligher.addArrayOfStringsField("keywords"); highligher.pop(); highligher.pushArrayOfObjects("work"); highligher.addStringField("name"); highligher.addStringField("description", { markdown: true }); highligher.addStringField("url"); highligher.addStringField("startDate", { date: true }); highligher.addStringField("endDate", { date: true }); highligher.addArrayOfStringsField("highlights", { markdown: true }); highligher.pop(); highligher.pushArrayOfObjects("education"); highligher.addStringField("institution"); highligher.addStringField("url"); highligher.addStringField("area"); highligher.addStringField("score", { markdown: true }); highligher.addStringField("startDate", { date: true }); highligher.addStringField("endDate", { date: true }); highligher.addArrayOfStringsField("courses"); highligher.pop(); highligher.pushArrayOfObjects("awards"); highligher.addStringField("title"); highligher.addStringField("awarder"); highligher.addStringField("date", { date: true }); highligher.addStringField("summary", { markdown: true }); highligher.pop(); highligher.pushArrayOfObjects("certificates"); highligher.addStringField("name"); highligher.addStringField("url"); highligher.addStringField("date", { date: true }); highligher.addStringField("issuer"); highligher.pop(); highligher.pushArrayOfObjects("publications"); highligher.addStringField("name"); highligher.addStringField("publisher"); highligher.addStringField("releaseDate", { date: true }); highligher.addStringField("url"); highligher.addStringField("summary", { markdown: true }); highligher.pop(); highligher.pushArrayOfObjects("volunteer"); highligher.addStringField("organization"); highligher.addStringField("position"); highligher.addStringField("url"); highligher.addStringField("summary", { markdown: true }); highligher.addStringField("startDate", { date: true }); highligher.addStringField("endDate", { date: true }); highligher.addArrayOfStringsField("highlights", { markdown: true }); highligher.pop(); highligher.pushObject("meta"); highligher.addStringField("accentColor"); highligher.addNumberField("baseFontSize"); highligher.addArrayOfStringsField("sectionsOrder", { comment: `how are sections ordered, e.g., ["basics", "work", "education", ...]`, }); highligher.addArrayOfStringsField("sectionsPageBreaks", { comment: `which sections should start on a new page, e.g., ["basics", "work", "education", ...]`, }); highligher.pop(); highligher.pop(); return highligher.result; } export const Schema = memo(function () { const highlightedElements = useMemo(createHighlightedElements, []); return (
        
          // Use this as a guide. It shows the supported fields from the JSON
          resume schema
          
// (jsonresume.org/schema) expressed in TypeScript so they're easier to read.
// -------------------------------------------------------------------
// Where noted you can use the following Markdown subset:
// *bold*, **italics**, [label](link).

{highlightedElements}
); }); ================================================ FILE: src/editing/type-highlighter.tsx ================================================ import { Fragment, ReactElement } from "react"; // eslint-disable-next-line react-refresh/only-export-components const IDENT = " "; export function createTypeHighlighter() { const result: ReactElement[] = []; let space = ""; let lastKey = 0; function pushType(name: string) { result.push( type{" "} {name}{" "} ={" "} {"{"}
); space += IDENT; } function pushObject(name: string) { result.push( {space} {name} ?: {"{"}
); space += IDENT; } function pushArrayOfObjects(name: string) { result.push( {space} {name} ?: Array < {"{"}
); space += IDENT; } function pop() { space = space.slice(0, space.length - IDENT.length); result.push( {space} {"}"} ;
); } function renderFieldName(name: string) { return ( {space} {name} ); } function renderFieldEnd({ markdown = false, date = false, comment, }: { markdown?: boolean; date?: boolean; comment?: string } = {}) { return ( <> ; {markdown && ( // supports Markdown subset )} {date && // YYYY or YYYY-MM} {comment && // {comment}}
); } function addStringField( name: string, { markdown = false, date = false, }: { markdown?: boolean; date?: boolean } = {} ) { result.push( {renderFieldName(name)} ?: string {renderFieldEnd({ markdown, date })} ); } function addNumberField(name: string) { result.push( {renderFieldName(name)} ?: number {renderFieldEnd()} ); } function addEnumField(name: string, values: Array) { result.push( {renderFieldName(name)} ?:{" "} {values.map((value) => `"${value}"`).join(" | ")} {renderFieldEnd()} ); } function addArrayOfStringsField( name: string, { markdown = false, comment }: { markdown?: boolean; comment?: string } = {} ) { result.push( {renderFieldName(name)} ?: Array < string > {renderFieldEnd({ markdown, comment })} ); } return { pushObject, pushArrayOfObjects, addStringField, addArrayOfStringsField, pop, pushType, addEnumField, addNumberField, result, }; } ================================================ FILE: src/editing/yaml-editor.css ================================================ .YAMLEditor { font-size: var(--font-size-1); overflow-y: hidden; height: 100%; border-right: 1px solid var(--color-gray-400); } ================================================ FILE: src/editing/yaml-editor.tsx ================================================ import CodeMirror, { ReactCodeMirrorRef } from "@uiw/react-codemirror"; import * as yamlMode from "@codemirror/legacy-modes/mode/yaml"; import { StreamLanguage } from "@codemirror/language"; import { vscodeDarkInit } from "@uiw/codemirror-theme-vscode"; import { RefObject, memo } from "react"; import "./yaml-editor.css"; const yaml = StreamLanguage.define(yamlMode.yaml); type Props = { value: string; onChange: (value: string) => void; codeMirrorRef: RefObject; }; export const YAMLEditor = memo(function ({ value, onChange, codeMirrorRef, }: Props) { return ( ); }); ================================================ FILE: src/icons/download-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function DownloadIcon({ size = 24, style }: Props) { return ( ); } ================================================ FILE: src/icons/folder-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function FolderIcon({ size = 24, style }: Props) { return ( ); } ================================================ FILE: src/icons/index.ts ================================================ export * from "./folder-icon"; export * from "./download-icon"; export * from "./pdf-icon"; export * from "./zoom-in-icon"; export * from "./zoom-out-icon"; export * from "./info-icon"; export * from "./plus-icon"; ================================================ FILE: src/icons/info-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function InfoIcon({ size, style }: Props) { return ( ); } ================================================ FILE: src/icons/pdf-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function PDFIcon({ size, style }: Props) { return ( ); } ================================================ FILE: src/icons/plus-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function PlusIcon({ size, style }: Props) { return ( ); } ================================================ FILE: src/icons/zoom-in-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function ZoomInIcon({ size, style }: Props) { return ( ); } ================================================ FILE: src/icons/zoom-out-icon.tsx ================================================ import { CSSProperties } from "react"; type Props = { size?: number; style?: CSSProperties; }; export function ZoomOutIcon({ size, style }: Props) { return ( ); } ================================================ FILE: src/index.css ================================================ body { margin: 0; } body, button, input { font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; } :root { --color-gray-100: #1e1e1e; --color-gray-200: #2a2a2a; --color-gray-300: #363636; --color-gray-400: #454545; --color-gray-500: #555555; --color-gray-600: #777777; --color-blue-100: #2b5dd6; --color-blue-200: #3b82f6; --color-blue-300: #569cd6; --color-blue-400: #9cdcfe; --color-green-100: #4ec9b0; --color-white: #ffffff; --color-black: #000000; --space-1: 0.125rem; --space-2: 0.375rem; --space-3: 0.5rem; --space-4: 0.625rem; --space-5: 0.875rem; --space-6: 1rem; --space-7: 1.25rem; --space-8: 1.375rem; --font-size-1: 0.875rem; --font-size-2: 1rem; --font-size-3: 1.25rem; } *, *::before, *::after { box-sizing: border-box; } .cm-editor { height: 100%; } ::-webkit-scrollbar { width: var(--space-4); height: var(--space-4); } ::-webkit-scrollbar-thumb { background: var(--color-gray-500); } ::-webkit-scrollbar-thumb:hover { background: var(--color-gray-600); } ::-webkit-scrollbar-thumb:active { background: var(--color-white); } ::-webkit-scrollbar-corner { background: var(--color-gray-100); } button, a { border: none; padding: var(--space-2) var(--space-8) var(--space-2); background-color: var(--color-gray-300); color: var(--color-white); cursor: pointer; font-size: var(--font-size-1); } button:hover, a:hover { background-color: var(--color-gray-500); } button.primary { background-color: var(--color-blue-100); } button.primary:hover { background-color: var(--color-blue-200); } button:active, button.primary:active, a:active { color: var(--color-black); background-color: var(--color-white); } button:disabled, a:disabled { opacity: 0.5; pointer-events: none; } ================================================ FILE: src/main.tsx ================================================ import React from "react"; import ReactDOM from "react-dom/client"; import { App } from "./App"; import "./index.css"; import { pdfjs } from "react-pdf"; pdfjs.GlobalWorkerOptions.workerSrc = new URL( "pdfjs-dist/build/pdf.worker.min.js", import.meta.url ).toString(); const registerServiceWorker = async () => { if ("serviceWorker" in navigator) { try { const registration = await navigator.serviceWorker.register( "/cache-sw.js", { scope: "/", } ); if (registration.active && !navigator.serviceWorker.controller) { window.location.reload(); } } catch (error) { console.error(`Registration failed with ${error}`); } } }; registerServiceWorker(); ReactDOM.createRoot(document.getElementById("root")!).render( ); ================================================ FILE: src/panes-layout.tsx ================================================ import { ReactElement, useState } from "react"; import { Pane, SashContent } from "split-pane-react"; import SplitPane from "split-pane-react/esm/SplitPane"; type Props = { left: ReactElement; bottom: ReactElement; right: ReactElement; }; function sashRender(_: number, active: boolean) { return ; } export function PanesLayout({ left, bottom, right }: Props) { const [horizontalSizes, setHorizontalSizes] = useState< Array >(["80%"]); const [verticalSizes, setVerticalSizes] = useState>([ "50%", ]); return ( {left} {bottom} {right} ); } ================================================ FILE: src/parsing/index.ts ================================================ export * from "./use-yaml-parsing"; ================================================ FILE: src/parsing/parse-yaml.ts ================================================ import YAML from "yaml"; type ParseResult = { json?: object; errorMessage?: string; }; export function parseYAML(yaml: string): ParseResult { try { const json = YAML.parse(yaml, { prettyErrors: true }); return { json }; } catch (error) { return { errorMessage: (error as Error).message }; } } ================================================ FILE: src/parsing/resume-schema.json ================================================ { "$schema": "http://json-schema.org/draft-04/schema#", "additionalProperties": false, "definitions": { "iso8601": { "type": "string", "description": "Similar to the standard date type, but each section after the year is optional. e.g. 2014-06-29 or 2023-04", "pattern": "^([1-2][0-9]{3}-[0-1][0-9]-[0-3][0-9]|[1-2][0-9]{3}-[0-1][0-9]|[1-2][0-9]{3})$" } }, "properties": { "$schema": { "type": "string", "description": "link to the version of the schema that can validate the resume", "format": "uri" }, "basics": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string" }, "label": { "type": "string", "description": "e.g. Web Developer" }, "image": { "type": "string", "description": "URL (as per RFC 3986) to a image in JPEG or PNG format" }, "email": { "type": "string", "description": "e.g. thomas@gmail.com", "format": "email" }, "phone": { "type": "string", "description": "Phone numbers are stored as strings so use any format you like, e.g. 712-117-2923" }, "url": { "type": "string", "description": "URL (as per RFC 3986) to your website, e.g. personal homepage", "format": "uri" }, "summary": { "type": "string", "description": "Write a short 2-3 sentence biography about yourself" }, "location": { "type": "object", "additionalProperties": true, "properties": { "address": { "type": "string", "description": "To add multiple address lines, use \n. For example, 1234 Glücklichkeit Straße\nHinterhaus 5. Etage li." }, "postalCode": { "type": "string" }, "city": { "type": "string" }, "countryCode": { "type": "string", "description": "code as per ISO-3166-1 ALPHA-2, e.g. US, AU, IN" }, "region": { "type": "string", "description": "The general region where you live. Can be a US state, or a province, for instance." } } }, "profiles": { "type": "array", "description": "Specify any number of social networks that you participate in", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "network": { "type": "string", "description": "e.g. Facebook or Twitter" }, "username": { "type": "string", "description": "e.g. neutralthoughts" }, "url": { "type": "string", "description": "e.g. http://twitter.example.com/neutralthoughts", "format": "uri" } } } } } }, "work": { "type": "array", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. Facebook" }, "location": { "type": "string", "description": "e.g. Menlo Park, CA" }, "description": { "type": "string", "description": "e.g. Social Media Company" }, "position": { "type": "string", "description": "e.g. Software Engineer" }, "url": { "type": "string", "description": "e.g. http://facebook.example.com", "format": "uri" }, "startDate": { "$ref": "#/definitions/iso8601" }, "endDate": { "$ref": "#/definitions/iso8601" }, "summary": { "type": "string", "description": "Give an overview of your responsibilities at the company" }, "highlights": { "type": "array", "description": "Specify multiple accomplishments", "additionalItems": false, "items": { "type": "string", "description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising" } } } } }, "volunteer": { "type": "array", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "organization": { "type": "string", "description": "e.g. Facebook" }, "position": { "type": "string", "description": "e.g. Software Engineer" }, "url": { "type": "string", "description": "e.g. http://facebook.example.com", "format": "uri" }, "startDate": { "$ref": "#/definitions/iso8601" }, "endDate": { "$ref": "#/definitions/iso8601" }, "summary": { "type": "string", "description": "Give an overview of your responsibilities at the company" }, "highlights": { "type": "array", "description": "Specify accomplishments and achievements", "additionalItems": false, "items": { "type": "string", "description": "e.g. Increased profits by 20% from 2011-2012 through viral advertising" } } } } }, "education": { "type": "array", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "institution": { "type": "string", "description": "e.g. Massachusetts Institute of Technology" }, "url": { "type": "string", "description": "e.g. http://facebook.example.com", "format": "uri" }, "area": { "type": "string", "description": "e.g. Arts" }, "studyType": { "type": "string", "description": "e.g. Bachelor" }, "startDate": { "$ref": "#/definitions/iso8601" }, "endDate": { "$ref": "#/definitions/iso8601" }, "score": { "type": "string", "description": "grade point average, e.g. 3.67/4.0" }, "courses": { "type": "array", "description": "List notable courses/subjects", "additionalItems": false, "items": { "type": "string", "description": "e.g. H1302 - Introduction to American history" } } } } }, "awards": { "type": "array", "description": "Specify any awards you have received throughout your professional career", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "title": { "type": "string", "description": "e.g. One of the 100 greatest minds of the century" }, "date": { "$ref": "#/definitions/iso8601" }, "awarder": { "type": "string", "description": "e.g. Time Magazine" }, "summary": { "type": "string", "description": "e.g. Received for my work with Quantum Physics" } } } }, "certificates": { "type": "array", "description": "Specify any certificates you have received throughout your professional career", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. Certified Kubernetes Administrator" }, "date": { "$ref": "#/definitions/iso8601" }, "url": { "type": "string", "description": "e.g. http://example.com", "format": "uri" }, "issuer": { "type": "string", "description": "e.g. CNCF" } } } }, "publications": { "type": "array", "description": "Specify your publications through your career", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. The World Wide Web" }, "publisher": { "type": "string", "description": "e.g. IEEE, Computer Magazine" }, "releaseDate": { "$ref": "#/definitions/iso8601" }, "url": { "type": "string", "description": "e.g. http://www.computer.org.example.com/csdl/mags/co/1996/10/rx069-abs.html", "format": "uri" }, "summary": { "type": "string", "description": "Short summary of publication. e.g. Discussion of the World Wide Web, HTTP, HTML." } } } }, "skills": { "type": "array", "description": "List out your professional skill-set", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. Web Development" }, "level": { "type": "string", "description": "e.g. Master" }, "keywords": { "type": "array", "description": "List some keywords pertaining to this skill", "additionalItems": false, "items": { "type": "string", "description": "e.g. HTML" } } } } }, "languages": { "type": "array", "description": "List any other languages you speak", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "language": { "type": "string", "description": "e.g. English, Spanish" }, "fluency": { "type": "string", "description": "e.g. Fluent, Beginner" } } } }, "interests": { "type": "array", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. Philosophy" }, "keywords": { "type": "array", "additionalItems": false, "items": { "type": "string", "description": "e.g. Friedrich Nietzsche" } } } } }, "references": { "type": "array", "description": "List references you have received", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. Timothy Cook" }, "reference": { "type": "string", "description": "e.g. Joe blogs was a great employee, who turned up to work at least once a week. He exceeded my expectations when it came to doing nothing." } } } }, "projects": { "type": "array", "description": "Specify career projects", "additionalItems": false, "items": { "type": "object", "additionalProperties": true, "properties": { "name": { "type": "string", "description": "e.g. The World Wide Web" }, "description": { "type": "string", "description": "Short summary of project. e.g. Collated works of 2017." }, "highlights": { "type": "array", "description": "Specify multiple features", "additionalItems": false, "items": { "type": "string", "description": "e.g. Directs you close but not quite there" } }, "keywords": { "type": "array", "description": "Specify special elements involved", "additionalItems": false, "items": { "type": "string", "description": "e.g. AngularJS" } }, "startDate": { "$ref": "#/definitions/iso8601" }, "endDate": { "$ref": "#/definitions/iso8601" }, "url": { "type": "string", "format": "uri", "description": "e.g. http://www.computer.org/csdl/mags/co/1996/10/rx069-abs.html" }, "roles": { "type": "array", "description": "Specify your role on this project or in company", "additionalItems": false, "items": { "type": "string", "description": "e.g. Team Lead, Speaker, Writer" } }, "entity": { "type": "string", "description": "Specify the relevant company/entity affiliations e.g. 'greenpeace', 'corporationXYZ'" }, "type": { "type": "string", "description": " e.g. 'volunteering', 'presentation', 'talk', 'application', 'conference'" } } } }, "meta": { "type": "object", "description": "The schema version and any other tooling configuration lives here", "additionalProperties": true, "properties": { "canonical": { "type": "string", "description": "URL (as per RFC 3986) to latest version of this document", "format": "uri" }, "version": { "type": "string", "description": "A version field which follows semver - e.g. v1.0.0" }, "lastModified": { "type": "string", "description": "Using ISO 8601 with YYYY-MM-DDThh:mm:ss" } } } }, "title": "Resume Schema", "type": "object" } ================================================ FILE: src/parsing/sample.ts ================================================ export const SAMPLE_YAML = ` # Demo resume adapted from https://github.com/jsonresume/resume-schema/blob/master/sample.resume.json basics: name: Richard Hendriks label: Programmer email: richard.hendriks@mail.com phone: (912) 555-4321 url: http://richardhendricks.example.com summary: Richard hails from Tulsa. He has earned degrees from the University of Oklahoma and Stanford. (Go Sooners and Cardinal!) Before starting Pied Piper, he worked for Hooli as a part time software developer. While his work focuses on applied information theory, mostly optimizing lossless compression schema of both the length-limited and adaptive variants, his non-work interests range widely, everything from quantum computing to chaos theory. He could tell you about it, but THAT would NOT be a “length-limited” conversation! location: city: San Francisco countryCode: US profiles: - network: github url: github.com/richardhendricks - network: linkedin url: linkedin.com/richardhendricks work: - name: Pied Piper position: CEO/President location: Palo Alto, CA url: http://piedpiper.example.com summary: Pied Piper is a multi-platform technology based on a proprietary universal compression algorithm that has consistently fielded high Weisman Scores™ that are not merely competitive, but approach the theoretical limit of lossless compression. startDate: 2013-12 endDate: 2014-12 highlights: - Build an algorithm for artist to detect if their music was violating copy right infringement laws - Successfully won Techcrunch Disrupt - Optimized an algorithm that holds the current world record for Weisman Scores volunteer: - organization: CoderDojo position: Teacher url: http://coderdojo.example.com/ startDate: 2012-01 endDate: 2013-01 highlights: - Awarded 'Teacher of the Month' education: - institution: University of Oklahoma url: https://www.ou.edu/ area: Information Technology score: 'Score: 4.0' startDate: 2011-06 endDate: 2014-01 courses: - DB1101 - Basic SQL - CS2011 - Java Introduction awards: - title: Digital Compression Pioneer Award date: 2014-11 awarder: Techcrunch summary: There is no spoon. publications: - name: Video compression for 3d media publisher: Hooli releaseDate: 2014-10 url: http://en.wikipedia.org/wiki/Silicon_Valley_(TV_series) summary: Innovative middle-out compression algorithm that changes the way we store data. skills: - name: Web Development keywords: ["HTML", "CSS", "Javascript"] - name: Compression keywords: ["Mpeg", "MP4", "GIF"] projects: - name: Miss Direction description: A mapping engine that misguides you startDate: 2016-08 endDate: 2016-08 url: missdirection.example.com highlights: - Won award at AIHacks 2016 - Built by all women team of newbie programmers - Using modern technologies such as GoogleMaps, Chrome Extension and Javascript `; ================================================ FILE: src/parsing/use-yaml-parsing.test.tsx ================================================ import { describe, expect, test } from "vitest"; import { act, renderHook } from "@testing-library/react"; import { useYAMLParsing } from "."; import { SAMPLE_YAML } from "./sample"; import { createDeferred } from "../utils"; const yaml = ` basics: name: Test `; const invalidYAML = ` basics name: test `; const json = { basics: { name: "Test" } }; // test("returns the sample YAML if never used before", () => { const { result } = renderHook(() => useYAMLParsing({ onYAMLParsed: () => {} }) ); expect(result.current.yaml).toEqual(SAMPLE_YAML); }); test("updates the value in localStorage", () => { const yamlDeferred = createDeferred(); const { result } = renderHook(() => useYAMLParsing({ onYAMLParsed: (yaml) => { yamlDeferred.resolve(yaml); }, }) ); act(() => { result.current.setYAML(yaml); }); expect(yamlDeferred.promise).resolves.toBe(yaml); }); describe("callback", () => { test("calls with the YAML and the parsed json if valid", async () => { const yamlDeferred = createDeferred(); const jsonDeferred = createDeferred(); const { result } = renderHook(() => useYAMLParsing({ onYAMLParsed: (yaml, json) => { yamlDeferred.resolve(yaml); jsonDeferred.resolve(json); }, }) ); act(() => { result.current.setYAML(yaml); }); expect(yamlDeferred.promise).resolves.toBe(yaml); expect(jsonDeferred.promise).resolves.toStrictEqual(json); }); test("calls with the YAML and null if invalid", async () => { const yamlDeferred = createDeferred(); const jsonDeferred = createDeferred(); const { result } = renderHook(() => useYAMLParsing({ onYAMLParsed: (yaml, json) => { yamlDeferred.resolve(yaml); jsonDeferred.resolve(json); }, }) ); act(() => { result.current.setYAML(invalidYAML); }); expect(yamlDeferred.promise).resolves.toBe(invalidYAML); expect(jsonDeferred.promise).resolves.toBe(undefined); }); }); ================================================ FILE: src/parsing/use-yaml-parsing.ts ================================================ import { useCallback, useState } from "react"; import { useDebouncedEffect } from "../utils"; import { yamlToJSON } from "./yaml-to-json"; import { SAMPLE_YAML } from "./sample"; type Props = { onYAMLParsed: (yaml: string, json: object | undefined) => void; }; export const STORAGE_KEY = "yaml"; export function useYAMLParsing({ onYAMLParsed }: Props) { const [yaml, setYAML] = useState(() => { const stored = localStorage.getItem(STORAGE_KEY); if (!stored && stored !== "") { return SAMPLE_YAML; } return stored || ""; }); const onCodeUpdate = useCallback(() => { localStorage.setItem(STORAGE_KEY, yaml); const { json, errors } = yamlToJSON(yaml); if (process.env.NODE_ENV !== "test") { if (json) console.log("JSON:", json); if (errors) console.error("Errors:", errors); } onYAMLParsed(yaml, json); }, [yaml, onYAMLParsed]); useDebouncedEffect(onCodeUpdate); return { setYAML, yaml, }; } ================================================ FILE: src/parsing/validate-json.ts ================================================ // Validaiton import Validator from "z-schema"; import resumeSchema from "./resume-schema.json"; const validator = new Validator({}); type ValidationResult = { errorMessages?: string[]; }; export function validateJSON(json: object): ValidationResult { validator.validate(json, resumeSchema); const errors = validator.getLastErrors(); if (errors) { const errorMessages = errors.map( (error) => `${error.path}: ${error.message}` ); return { errorMessages }; } return {}; } ================================================ FILE: src/parsing/yaml-to-json.ts ================================================ import { parseYAML } from "./parse-yaml"; import { validateJSON } from "./validate-json"; type Result = { json?: object; errors?: { type: "parsing" | "validation"; messages: string[]; }; }; function resultForParseError(errorMessage: string): Result { return { errors: { type: "parsing", messages: [errorMessage], }, }; } function resultFromValidationErrors(errorsMessages: string[]): Result { return { errors: { type: "validation", messages: errorsMessages, }, }; } export function yamlToJSON(yaml: string): Result { const parseResult = parseYAML(yaml); if (parseResult.errorMessage) return resultForParseError(parseResult.errorMessage); if (parseResult.json && typeof parseResult.json === "object") { const validationResult = validateJSON(parseResult.json); if (validationResult.errorMessages) return resultFromValidationErrors(validationResult.errorMessages); return { json: parseResult.json, }; } return {}; } ================================================ FILE: src/persistence/file-management.ts ================================================ export function downloadFile(fileName: string, blob: Blob) { const a: HTMLAnchorElement = document.createElement("a"); a.style.display = "none"; document.body.appendChild(a); const url = window.URL.createObjectURL(blob); a.href = url; a.download = fileName; a.click(); a.remove(); window.URL.revokeObjectURL(url); } export function readFile(file: File) { return new Promise((resolve, reject) => { const fileReader = new FileReader(); fileReader.onload = () => { const { result } = fileReader; resolve(result as string); }; fileReader.onerror = () => { const { error } = fileReader; fileReader.abort(); reject(error); }; fileReader.readAsText(file); }); } function createFileInput(accept: string) { const input = document.createElement("input"); input.type = "file"; input.multiple = false; input.accept = accept; return input; } export function selectFile(accept: string) { return new Promise((resolve) => { const input = createFileInput(accept); input.addEventListener("change", function onChange(this: HTMLInputElement) { const { files } = this; if (files && files.length > 0) { resolve(files[0]); } }); input.dispatchEvent(new MouseEvent("click")); }); } ================================================ FILE: src/persistence/index.ts ================================================ export * from "./use-yaml-persistence"; export * from "./file-management"; ================================================ FILE: src/persistence/use-yaml-persistence.test.ts ================================================ import { test, expect, describe, afterEach } from "vitest"; import { renderHook } from "@testing-library/react"; import { useYAMLPersistence } from "."; import * as fileManagement from "./file-management"; import { vi } from "vitest"; import { createDeferred } from "../utils"; describe("use-yaml-persistence", () => { afterEach(() => { vi.restoreAllMocks(); }); test.only("saves the file with the right title", () => { const downloadFileSpy = vi .spyOn(fileManagement, "downloadFile") .mockImplementation(() => undefined); const { result } = renderHook(() => useYAMLPersistence({ title: "title", yaml: "", onFileOpened: () => {} }) ); result.current.save(); expect(downloadFileSpy.mock.calls[0][0]).toBe("title.yaml"); }); test("saves the file with the right title", () => { const fileName = "file.yaml"; const fileContents = "contents"; vi.spyOn(fileManagement, "selectFile").mockImplementation(() => Promise.resolve({ name: fileName } as File) ); vi.spyOn(fileManagement, "readFile").mockImplementation(() => Promise.resolve("contents") ); const fileTitleDeferred = createDeferred(); const fileContentsDeferred = createDeferred(); const { result } = renderHook(() => useYAMLPersistence({ title: "title", yaml: "", onFileOpened: (fileTitle, fileContents) => { fileTitleDeferred.resolve(fileTitle); fileContentsDeferred.resolve(fileContents); }, }) ); result.current.open(); expect(fileTitleDeferred.promise).resolves.toBe("file"); expect(fileContentsDeferred.promise).resolves.toBe(fileContents); }); }); ================================================ FILE: src/persistence/use-yaml-persistence.ts ================================================ import { useCallback } from "react"; import { downloadFile, readFile, selectFile } from "./file-management"; type Props = { title: string; yaml: string; onFileOpened: (fileTitle: string, fileContents: string) => void; }; export function useYAMLPersistence({ title, yaml: contents, onFileOpened, }: Props) { const save = useCallback(() => { const blob = new Blob([contents], { type: "text/yaml" }); downloadFile(title + ".yaml", blob); }, [title, contents]); const open = useCallback(async () => { const file = await selectFile("text/yaml"); try { const fileContents = await readFile(file); const extStartIndex = file.name.lastIndexOf("."); const fileTitle = file.name.slice(0, extStartIndex); onFileOpened(fileTitle, fileContents); } catch (e) { console.error("Cannot read file: ", file.name, e); } }, [onFileOpened]); return { open, save, }; } ================================================ FILE: src/rendering/debounced-queue.test.ts ================================================ import { expect, test } from "vitest"; import { createDebouncedQueue } from "./debounced-queue"; import { createDeferred } from "../utils"; const ITEM1 = "1"; const ITEM2 = "2"; const ITEM3 = "3"; const DELAY = 200; test("calls the callback with only the items pushed before the delay", async () => { const delay = 200; const deferred = createDeferred>(); const queue = createDebouncedQueue((items) => { deferred.resolve(items); return Promise.resolve(); }, DELAY); queue.push(ITEM1); // Add more items befire the delay setTimeout(() => { queue.push(ITEM2); }, delay / 2); // Add more items but after the delay setTimeout(() => { queue.push(ITEM3); }, 2 * delay); expect(deferred.promise).resolves.toStrictEqual([ITEM1, ITEM2]); }); test("calls the callback a one more time with the items pushed during the first callback", async () => { const deferred1 = createDeferred>(); const deferred2 = createDeferred>(); let deferred = deferred1; const queue = createDebouncedQueue((items) => { deferred.resolve(items); deferred = deferred2; queue.push(ITEM2); queue.push(ITEM3); return Promise.resolve(); }, DELAY); queue.push(ITEM1); expect(deferred.promise).resolves.toStrictEqual([ITEM1]); expect(deferred2.promise).resolves.toStrictEqual([ITEM2, ITEM3]); }); ================================================ FILE: src/rendering/debounced-queue.ts ================================================ import { sleep } from "../utils"; export function createDebouncedQueue( callback: (items: Array) => Promise, delay = 1000 ) { let items: Array = []; let started = false; function push(item: T) { items.push(item); if (!started) start(); } async function start() { started = true; while (items.length) { await sleep(delay); const chunk = [...items]; items = []; await callback(chunk); } started = false; } return { push }; } ================================================ FILE: src/rendering/double-buffered.tsx ================================================ import { ReactElement, cloneElement, useEffect, useRef, useState } from "react"; type BufferElement = ReactElement | null; type Props = { render: (onSuccess: () => void) => BufferElement; }; export function DoubleBuffered({ render }: Props) { const backElementRef = useRef(null); const frontElementRef = useRef(null); const [elements, setElements] = useState>(null); const lastKeyRef = useRef(0); // setElements and frontElementRef are stable, so no need for useCallback const onRenderSuccess = () => { frontElementRef.current = backElementRef.current ? cloneElement(backElementRef.current, { "data-ready": "true", }) : null; setElements([frontElementRef.current]); }; useEffect(() => { const renderedElement = render(onRenderSuccess); if (renderedElement) { const backElement = cloneElement(renderedElement, { key: lastKeyRef.current, "data-ready": "false", }); backElementRef.current = backElement; lastKeyRef.current++; setElements([backElementRef.current, frontElementRef.current]); } else { backElementRef.current = null; onRenderSuccess(); } }, [render]); return
{elements}
; } ================================================ FILE: src/rendering/index.ts ================================================ export * from "./pdf"; export * from "./use-render"; export * from "./use-scale"; ================================================ FILE: src/rendering/multi-page-document.tsx ================================================ import { HTMLAttributes, useCallback, useMemo, useRef, useState } from "react"; import { Document, Page } from "react-pdf"; type Props = { blob: Blob; onAllPagesRenderSuccess: () => void; scale?: number; } & HTMLAttributes; export function MultiPageDocument({ blob, scale = 1, style, onAllPagesRenderSuccess, ...rest }: Props) { const [pagesCount, setPagesCount] = useState(0); const renderedPagesCountRef = useRef(0); function onDocumentLoadSuccess({ numPages }: { numPages: number }) { setPagesCount(numPages); } const onPageRenderSuccess = useCallback(() => { renderedPagesCountRef.current++; if (renderedPagesCountRef.current >= pagesCount) { onAllPagesRenderSuccess(); } }, [onAllPagesRenderSuccess, pagesCount]); const pageElements = useMemo(() => { const result = []; for (let i = 1; i <= pagesCount; i++) { result.push(
,
); } return result; }, [pagesCount, onPageRenderSuccess, scale]); return (
{pageElements}
); } ================================================ FILE: src/rendering/pdf.css ================================================ .PDF { overflow-y: auto; width: 100%; height: 100%; background-color: var(--color-gray-300); } ================================================ FILE: src/rendering/pdf.tsx ================================================ import { memo, useCallback, useEffect, useRef } from "react"; import { DoubleBuffered } from "./double-buffered"; import useResizeObserver from "@react-hook/resize-observer"; import "./pdf.css"; import { MultiPageDocument } from "./multi-page-document"; type Props = { blob: Blob | null; scale: number; }; export const PDF = memo(function ({ blob, scale }: Props) { const ref = useRef(null); const widthRef = useRef(0); const update = useCallback(() => { if (!ref.current) return; const viewportWidth = widthRef.current; const pageWidth = scale * 595; const d = Math.abs((pageWidth - viewportWidth) / 2); const viewportNode = ref.current; if (pageWidth > viewportWidth) { viewportNode.style.setProperty("--left", "0px"); viewportNode.scroll(d, 0); } else { viewportNode.style.setProperty("--left", `${d}px`); } }, [scale]); useEffect(() => { update(); }, [update]); useResizeObserver(ref, (entry) => { if (ref.current) { widthRef.current = entry.contentRect.width; update(); } }); const render = useCallback( (onSuccess: () => void) => blob && ( ), [scale, blob] ); return (
); }); ================================================ FILE: src/rendering/use-render.tsx ================================================ import { useCallback, useMemo, useRef, useState } from "react"; import { pdf } from "@react-pdf/renderer"; import { ResumeDocument } from "../documents"; import { Resume } from "../types"; import { sleep } from "../utils"; function createDebouncedQueue( callback: (items: Array) => Promise, delay = 1000 ) { let items: Array = []; let started = false; function push(item: T) { items.push(item); if (!started) start(); } async function start() { started = true; while (items.length) { await sleep(delay); const chunk = [...items]; items = []; await callback(chunk); } started = false; } return { push }; } export function useRender() { const [blob, setBlob] = useState(null); const queueRef = useRef( createDebouncedQueue(async (jsons: Array) => { const lastJSON = jsons[jsons.length - 1]; if (!lastJSON) { setBlob(null); } else { try { const newBlob = await pdf( ).toBlob(); setBlob(newBlob); } catch (e) { console.error("Cannot create PDF"); } } }, 200) ); const push = useCallback((json: Resume | null) => { queueRef.current.push(json); }, []); const clear = useCallback(() => { push(null); }, [push]); const queue = useMemo(() => ({ push, clear }), [push, clear]); return { queue, blob, setBlob }; } ================================================ FILE: src/rendering/use-scale.test.ts ================================================ import { expect, test } from "vitest"; import { renderHook } from "@testing-library/react"; import { useScale, STORAGE_KEY, ABS_DELTA } from "."; import { act } from "react-dom/test-utils"; test("returns the initial scale if nothing is saved", () => { localStorage.removeItem(STORAGE_KEY); const { result } = renderHook(() => useScale({ minScale: 0.5, maxScale: 2 })); expect(result.current.scale).toBe(1); }); test("returns the value from the localStorage if present", () => { const scale = 1.2; localStorage.setItem(STORAGE_KEY, scale.toString()); const { result } = renderHook(() => useScale({ minScale: 0.5, maxScale: 2 })); expect(result.current.scale).toBe(1.2); }); test("increases the scale with the right delta and stores the value", () => { const { result } = renderHook(() => useScale({ minScale: 0.5, maxScale: 2 })); const initialScale = result.current.scale; act(() => { result.current.zoomIn(); }); const newScale = initialScale + ABS_DELTA; expect(result.current.scale).toBe(newScale); expect(localStorage.getItem(STORAGE_KEY)).toEqual(newScale.toString()); }); test("decreases the scale with the right delta and stores the value", () => { const { result } = renderHook(() => useScale({ minScale: 0.5, maxScale: 2 })); const initialScale = result.current.scale; act(() => { result.current.zoomOut(); }); const newScale = initialScale - ABS_DELTA; expect(result.current.scale).toBe(newScale); expect(localStorage.getItem(STORAGE_KEY)).toEqual(newScale.toString()); }); test("increases the scale no more than maxScale", () => { const maxScale = 1.2; const initialScale = 1; localStorage.setItem("scale", initialScale.toString()); const { result } = renderHook(() => useScale({ minScale: 1, maxScale, absDelta: 0.1 }) ); act(() => { result.current.zoomIn(); result.current.zoomIn(); result.current.zoomIn(); result.current.zoomIn(); }); expect(result.current.scale).toBe(maxScale); expect(result.current.maxScaleReached).toBe(true); expect(result.current.minScaleReached).toBe(false); }); test("decreases the scale no less than minScale", () => { const minScale = 1; const initialScale = 1; localStorage.setItem("scale", initialScale.toString()); const { result } = renderHook(() => useScale({ minScale, maxScale: 1.2, absDelta: 0.1 }) ); act(() => { result.current.zoomOut(); result.current.zoomOut(); result.current.zoomOut(); result.current.zoomOut(); }); expect(result.current.scale).toBe(minScale); expect(result.current.minScaleReached).toBe(true); expect(result.current.maxScaleReached).toBe(false); }); ================================================ FILE: src/rendering/use-scale.ts ================================================ import { useCallback, useState } from "react"; import { clamp } from "../utils/clamp"; type Props = { absDelta?: number; minScale: number; maxScale: number; }; const EPSILON = 0.00001; export const ABS_DELTA = 0.1; export const INITIAL_SCALE = 1; export const STORAGE_KEY = "scale"; export function useScale({ minScale, maxScale, absDelta = ABS_DELTA }: Props) { const [scale, setScale] = useState( () => Number(localStorage.getItem(STORAGE_KEY)) || INITIAL_SCALE ); const updateScale = useCallback( (delta: number) => { setScale((scale) => { const newScale = clamp(minScale, maxScale, scale + delta); localStorage.setItem(STORAGE_KEY, newScale.toString()); return newScale; }); }, [minScale, maxScale] ); const zoomIn = useCallback(() => { updateScale(absDelta); }, [updateScale, absDelta]); const zoomOut = useCallback(() => { updateScale(-absDelta); }, [updateScale, absDelta]); const maxScaleReached = Math.abs(scale - maxScale) < EPSILON; const minScaleReached = Math.abs(scale - minScale) < EPSILON; return { scale, zoomIn, zoomOut, maxScaleReached, minScaleReached, }; } ================================================ FILE: src/types.ts ================================================ export type Job = { name?: string; location?: string; position?: string; url?: string; summary?: string; startDate?: string | number; endDate?: string | number; highlights?: Array; }; export type Profile = { network?: "github" | "linkedin"; url?: string; }; export type Location = { city?: string; countryCode?: string }; export type Basics = { name?: string; label?: string; phone?: string; email?: string; url?: string; summary?: string; location?: Location; profiles?: Array; }; export type Skill = { name?: string; keywords?: Array; }; export type Project = { name?: string; description?: string; highlights?: Array; startDate?: string | number; endDate?: string | number; url?: string; }; export type EducationPlace = { institution?: string; url?: string; area?: string; score?: string; courses?: Array; startDate?: string | number; endDate?: string | number; }; export type Award = { title?: string; date?: string | number; awarder?: string; summary?: string; }; export type Certificate = { name?: string; date?: string | number; url?: string; issuer?: string; }; export type Publication = { name?: string; publisher?: string; releaseDate?: string | number; url?: string; summary?: string; }; export type Voluteering = { organization?: string; url?: string; position?: string; summary?: string; highlights?: Array; startDate?: string | number; endDate?: string | number; }; export type Meta = { accentColor?: string; baseFontSize?: number; sectionsOrder?: ResumeSectionName[]; sectionsPageBreaks?: ResumeSectionName[]; }; export type Resume = { basics?: Basics; work?: Array; skills?: Array; projects?: Array; education?: Array; awards?: Array; certificates?: Array; publications?: Array; volunteer?: Array; meta?: Meta; }; export type ResumeSectionName = Exclude; ================================================ FILE: src/utils/clamp.ts ================================================ export function clamp(min: number, max: number, value: number) { return Math.min(Math.max(min, value), max); } ================================================ FILE: src/utils/deferred.ts ================================================ export function createDeferred() { let resolve: (value: T) => void = () => {}; let reject: (reason?: unknown) => void = () => {}; // The Promise is guarantted to call the passed callback synchronously const promise = new Promise((res, rej) => { resolve = res; reject = rej; }); return { promise, resolve, reject }; } ================================================ FILE: src/utils/index.ts ================================================ export * from "./clamp"; export * from "./use-debounced-effect"; export * from "./sleep"; export * from "./deferred"; ================================================ FILE: src/utils/sleep.ts ================================================ export function sleep(delay: number = 1000) { return new Promise((resolve) => setTimeout(resolve, delay)); } ================================================ FILE: src/utils/use-debounced-effect.ts ================================================ import { useEffect } from "react"; export function useDebouncedEffect(effect: () => void) { useEffect(() => { const intervalId = setTimeout(effect, 200); return () => { clearTimeout(intervalId); }; }, [effect]); } ================================================ FILE: src/vite-env.d.ts ================================================ /// ================================================ FILE: tsconfig.json ================================================ { "compilerOptions": { "target": "ES2020", "useDefineForClassFields": true, "lib": ["ES2020", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx", /* Linting */ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, "include": ["src"], "references": [{ "path": "./tsconfig.node.json" }] } ================================================ FILE: tsconfig.node.json ================================================ { "compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "bundler", "allowSyntheticDefaultImports": true }, "include": ["vite.config.ts"] } ================================================ FILE: vite.config.ts ================================================ /// import { defineConfig } from "vite"; import react from "@vitejs/plugin-react"; // https://vitejs.dev/config/ export default defineConfig({ plugins: [react()], test: { globals: true, include: ["./src/**/*.test.*"], environment: "jsdom", }, });