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
================================================
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 (
);
}
================================================
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 (
);
}
================================================
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 (
);
}
================================================
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",
},
});