resume-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 3f16b2375723
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 70bace54357d4b57b674108f
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/lib/resume.ts
// Resume's data model, shared by the web app and the public API: the resume as the editor holds it,
// the conversion to and from JSON Resume (https://jsonresume.org/schema), the plain text version and the limits.

export type TemplateId = "classic" | "modern" | "compact";
export type PaperSize = "Letter" | "A4";
export type ColorId = "ink" | "cobalt" | "navy" | "sage" | "brick" | "plum";
export type SectionKey =
  | "summary" | "work" | "education" | "skills" | "projects" | "volunteer"
  | "awards" | "certificates" | "publications" | "languages" | "interests";

export const TEMPLATES: TemplateId[] = ["classic", "modern", "compact"];
export const PAPERS: PaperSize[] = ["Letter", "A4"];
/** Heading and accent colors: dark enough to print and read. */
export const COLORS: Record<ColorId, string> = {
  ink: "#15171b", cobalt: "#2e5ec4", navy: "#22395f", sage: "#3f6b4d", brick: "#9c3f29", plum: "#5f3f73",
};
export const COLOR_IDS = Object.keys(COLORS) as ColorId[];
/** Every section, in the default printed order. */
export const SECTIONS: SectionKey[] = ["summary", "work", "education", "skills", "projects", "volunteer", "awards", "certificates", "publications", "languages", "interests"];
/** Always in the editor; the others are added when a person needs them. */
export const CORE_SECTIONS: SectionKey[] = ["summary", "work", "education", "skills"];
export const DEFAULT_TITLES: Record<SectionKey, string> = {
  summary: "Summary",
  work: "Experience",
  education: "Education",
  skills: "Skills",
  projects: "Projects",
  volunteer: "Volunteering",
  awards: "Awards",
  certificates: "Certifications",
  publications: "Publications",
  languages: "Languages",
  interests: "Interests",
};

export interface Basics { name: string; label: string; email: string; phone: string; location: string; links: string[]; summary: string }
export interface Work { id: string; position: string; name: string; location: string; startDate: string; endDate: string; summary: string; highlights: string[] }
export interface Education { id: string; institution: string; studyType: string; area: string; location: string; startDate: string; endDate: string; score: string; courses: string[] }
/** keywords is the comma-separated text the person types ("React, TypeScript"); JSON Resume gets a list. */
export interface Skill { id: string; name: string; keywords: string }
export interface Project { id: string; name: string; url: string; startDate: string; endDate: string; description: string; highlights: string[] }
export interface Language { id: string; language: string; fluency: string }
export interface Certificate { id: string; name: string; issuer: string; date: string; url: string }
export interface Volunteer { id: string; position: string; organization: string; startDate: string; endDate: string; summary: string; highlights: string[] }
export interface Award { id: string; title: string; awarder: string; date: string; summary: string }
export interface Publication { id: string; name: string; publisher: string; releaseDate: string; url: string; summary: string }
/** keywords works like a skill group's: comma-separated text. */
export interface Interest { id: string; name: string; keywords: string }
/** color is optional: without it each template keeps its own (Modern blue, the others ink). */
export interface Settings { template: TemplateId; paper: PaperSize; color?: ColorId; order: SectionKey[]; titles: Partial<Record<SectionKey, string>> }
export interface Resume {
  basics: Basics;
  work: Work[];
  education: Education[];
  skills: Skill[];
  projects: Project[];
  volunteer: Volunteer[];
  awards: Award[];
  certificates: Certificate[];
  publications: Publication[];
  languages: Language[];
  interests: Interest[];
  settings: Settings;
}
export type ListKey = Exclude<SectionKey, "summary">;
export const LISTS: ListKey[] = SECTIONS.filter((k): k is ListKey => k !== "summary");

/** The stored resume JSON stays under this many characters (the policy enforces the same cap). */
export const MAX_DATA = 100_000;
export const LIMITS = { entries: 40, bullets: 40, links: 8, short: 200, title: 60, text: 4000, bullet: 1000 };

const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
export function randomId(length = 20): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  let out = "";
  for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
  return out;
}

export const blankWork = (): Work => ({ id: randomId(8), position: "", name: "", location: "", startDate: "", endDate: "", summary: "", highlights: [""] });
export const blankEducation = (): Education => ({ id: randomId(8), institution: "", studyType: "", area: "", location: "", startDate: "", endDate: "", score: "", courses: [] });
export const blankSkill = (): Skill => ({ id: randomId(8), name: "", keywords: "" });
export const blankProject = (): Project => ({ id: randomId(8), name: "", url: "", startDate: "", endDate: "", description: "", highlights: [""] });
export const blankLanguage = (): Language => ({ id: randomId(8), language: "", fluency: "" });
export const blankCertificate = (): Certificate => ({ id: randomId(8), name: "", issuer: "", date: "", url: "" });
export const blankVolunteer = (): Volunteer => ({ id: randomId(8), position: "", organization: "", startDate: "", endDate: "", summary: "", highlights: [""] });
export const blankAward = (): Award => ({ id: randomId(8), title: "", awarder: "", date: "", summary: "" });
export const blankPublication = (): Publication => ({ id: randomId(8), name: "", publisher: "", releaseDate: "", url: "", summary: "" });
export const blankInterest = (): Interest => ({ id: randomId(8), name: "", keywords: "" });

export function blankResume(paper: PaperSize = "Letter"): Resume {
  return {
    basics: { name: "", label: "", email: "", phone: "", location: "", links: [], summary: "" },
    work: [], education: [], skills: [], projects: [], volunteer: [], awards: [], certificates: [], publications: [], languages: [], interests: [],
    settings: { template: "classic", paper, order: [...SECTIONS], titles: {} },
  };
}

export class ResumeError extends Error {}

export function sectionTitle(r: Resume, key: SectionKey): string {
  return r.settings.titles[key]?.trim() || DEFAULT_TITLES[key];
}

const blank = (v: unknown) => (Array.isArray(v) ? v.every((x) => !String(x).trim()) : !String(v ?? "").trim());
/** True when an entry has nothing but its id filled in. */
export function isEmptyEntry(e: object): boolean {
  return Object.entries(e).every(([k, v]) => k === "id" || blank(v));
}

const trimList = (list: string[]) => list.map((s) => s.trim()).filter(Boolean);
const trimAll = <T extends object>(e: T): T =>
  Object.fromEntries(Object.entries(e).map(([k, v]) => [k, Array.isArray(v) ? trimList(v) : typeof v === "string" ? v.trim() : v])) as T;

/** The resume as it is printed, saved and exported: trimmed, with no empty entries, bullets or links. */
export function cleanResume(r: Resume): Resume {
  const list = <T extends object>(items: T[]) => items.map(trimAll).filter((e) => !isEmptyEntry(e));
  const out = { basics: trimAll(r.basics), settings: r.settings } as Resume;
  // A resume stored before a section existed has no list for it.
  for (const k of LISTS) (out[k] as object[]) = list((r[k] as object[] | undefined) ?? []);
  return out;
}

export function hasSection(r: Resume, key: SectionKey): boolean {
  return key === "summary" ? Boolean(r.basics.summary.trim()) : (r[key] as object[]).some((e) => !isEmptyEntry(e));
}

export function isEmptyResume(r: Resume): boolean {
  const b = r.basics;
  return !b.name.trim() && !b.label.trim() && !b.email.trim() && !b.phone.trim() && !b.location.trim() && blank(b.links) && !SECTIONS.some((k) => hasSection(r, k));
}

const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
/** "2021-03" or "2021-03-15" reads "Mar 2021"; anything else prints as typed. */
export function fmtDate(s: string): string {
  const m = s.trim().match(/^(\d{4})-(\d{2})(?:-\d{2})?$/);
  return m && Number(m[2]) >= 1 && Number(m[2]) <= 12 ? `${MONTHS[Number(m[2]) - 1]} ${m[1]}` : s.trim();
}
/** A start with no end is current, as in JSON Resume. */
export function dateRange(start: string, end: string): string {
  const a = fmtDate(start);
  const b = fmtDate(end);
  if (a && b) return a === b ? a : `${a} – ${b}`;
  if (a) return `${a} – Present`;
  return b;
}

/** The heading and accent color a resume prints with. */
export const colorOf = (s: Settings): ColorId => s.color ?? (s.template === "modern" ? "cobalt" : "ink");

export const degreeOf = (e: Education) => [e.studyType, e.area].filter((s) => s.trim()).join(", ");
export const keywordList = (s: string) => s.split(/\s*[,;\n]\s*/).map((k) => k.trim()).filter(Boolean);

/** A link as it prints: no https://, no www., no trailing slash. */
export function displayUrl(s: string): string {
  return s.trim().replace(/^https?:\/\//i, "").replace(/^www\./i, "").replace(/\/$/, "");
}
/** Where a printed link points, or null for text that is not a web address or an email. */
export function hrefOf(s: string): string | null {
  const t = s.trim();
  if (/^https?:\/\/\S+$/i.test(t)) return t;
  if (/^mailto:\S+$/i.test(t)) return t;
  if (/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test(t)) return `mailto:${t}`;
  if (/^(www\.)?[a-z0-9-]+(\.[a-z0-9-]+)*\.[a-z]{2,}(\/\S*)?$/i.test(t)) return `https://${t}`;
  return null;
}

const NETWORKS: Record<string, string> = {
  "linkedin.com": "LinkedIn", "github.com": "GitHub", "gitlab.com": "GitLab", "x.com": "X", "twitter.com": "X",
  "behance.net": "Behance", "dribbble.com": "Dribbble", "medium.com": "Medium", "stackoverflow.com": "Stack Overflow",
  "instagram.com": "Instagram", "youtube.com": "YouTube", "bsky.app": "Bluesky", "kaggle.com": "Kaggle",
};
function networkOf(link: string): string {
  const host = displayUrl(link).split("/")[0].toLowerCase();
  return NETWORKS[host] || (host.includes(".") ? host : "");
}

// ---- JSON Resume in ----

type Obj = Record<string, unknown>;
function obj(v: unknown, path: string): Obj {
  if (v == null) return {};
  if (typeof v === "object" && !Array.isArray(v)) return v as Obj;
  throw new ResumeError(`${path} must be an object.`);
}
function list(v: unknown, path: string, max = LIMITS.entries): unknown[] {
  if (v == null) return [];
  if (!Array.isArray(v)) throw new ResumeError(`${path} must be a list.`);
  if (v.length > max) throw new ResumeError(`${path} can have at most ${max} items.`);
  return v;
}
function str(v: unknown, path: string, max = LIMITS.short): string {
  if (v == null) return "";
  if (typeof v !== "string" && typeof v !== "number" && typeof v !== "boolean") throw new ResumeError(`${path} must be text.`);
  const s = String(v).trim();
  if (s.length > max) throw new ResumeError(`${path} is longer than ${max} characters.`);
  return s;
}
function strs(v: unknown, path: string, max = LIMITS.bullet): string[] {
  return list(v, path, LIMITS.bullets).map((x, i) => str(x, `${path}[${i}]`, max)).filter(Boolean);
}
function place(v: unknown, path: string): string {
  if (typeof v === "string" || v == null) return str(v, path);
  const o = obj(v, path);
  const parts = ["city", "region", "countryCode"].map((k) => str(o[k], `${path}.${k}`)).filter(Boolean);
  return (parts.length ? parts.join(", ") : str(o.address, `${path}.address`)).slice(0, LIMITS.short);
}

/**
 * Reads a JSON Resume into a Resume: basics, work, education, skills, projects, volunteer, awards,
 * certificates, publications, languages and interests. Unknown fields are ignored; wrong types or text over
 * the limits throw a ResumeError a person can read.
 * meta.template, meta.paper, meta.color, meta.order and meta.titles (what Resume exports) are read back too.
 */
export function fromJsonResume(input: unknown, defaultPaper: PaperSize = "Letter"): Resume {
  const root = obj(input, "The resume");
  const b = obj(root.basics, "basics");
  const links = [str(b.url, "basics.url", 300)];
  list(b.profiles, "basics.profiles", LIMITS.links).forEach((p, i) => {
    const o = obj(p, `basics.profiles[${i}]`);
    const url = str(o.url, `basics.profiles[${i}].url`, 300);
    const network = str(o.network, `basics.profiles[${i}].network`);
    const username = str(o.username, `basics.profiles[${i}].username`);
    links.push(url || (network && username ? `${network}: ${username}` : username));
  });
  const basics: Basics = {
    name: str(b.name, "basics.name", 120),
    label: str(b.label, "basics.label"),
    email: str(b.email, "basics.email"),
    phone: str(b.phone, "basics.phone", 60),
    location: place(b.location, "basics.location"),
    links: [...new Set(links.filter(Boolean))].slice(0, LIMITS.links),
    summary: str(b.summary, "basics.summary", LIMITS.text),
  };
  const entries = <T>(key: string, read: (o: Obj, p: string) => T): T[] =>
    list(root[key], key).map((e, i) => read(obj(e, `${key}[${i}]`), `${key}[${i}]`));

  const meta = obj(root.meta, "meta");
  const template = String(root.template ?? meta.template ?? "classic") as TemplateId;
  const paper = String(root.paper ?? meta.paper ?? defaultPaper) as PaperSize;
  const color = (root.color ?? meta.color) == null ? undefined : (String(root.color ?? meta.color) as ColorId);
  if (!TEMPLATES.includes(template)) throw new ResumeError(`template must be one of ${TEMPLATES.join(", ")}.`);
  if (!PAPERS.includes(paper)) throw new ResumeError(`paper must be Letter or A4.`);
  if (color && !COLOR_IDS.includes(color)) throw new ResumeError(`color must be one of ${COLOR_IDS.join(", ")}.`);
  const order = [...new Set(list(meta.order, "meta.order").map(String))].filter((k): k is SectionKey => SECTIONS.includes(k as SectionKey));
  const titlesIn = obj(meta.titles, "meta.titles");
  const titles: Settings["titles"] = {};
  for (const k of SECTIONS) {
    const t = str(titlesIn[k], `meta.titles.${k}`, LIMITS.title);
    if (t) titles[k] = t;
  }

  return {
    basics,
    work: entries("work", (o, p) => ({
      id: randomId(8),
      position: str(o.position, `${p}.position`),
      name: str(o.name ?? o.company, `${p}.name`),
      location: place(o.location, `${p}.location`),
      startDate: str(o.startDate, `${p}.startDate`, 40),
      endDate: str(o.endDate, `${p}.endDate`, 40),
      summary: str(o.summary, `${p}.summary`, LIMITS.text),
      highlights: strs(o.highlights, `${p}.highlights`),
    })),
    education: entries("education", (o, p) => ({
      id: randomId(8),
      institution: str(o.institution, `${p}.institution`),
      studyType: str(o.studyType, `${p}.studyType`),
      area: str(o.area, `${p}.area`),
      location: place(o.location, `${p}.location`),
      startDate: str(o.startDate, `${p}.startDate`, 40),
      endDate: str(o.endDate, `${p}.endDate`, 40),
      score: str(o.score ?? o.gpa, `${p}.score`, 60),
      courses: strs(o.courses, `${p}.courses`),
    })),
    skills: entries("skills", (o, p) => ({
      id: randomId(8),
      name: str(o.name, `${p}.name`),
      keywords: Array.isArray(o.keywords) ? strs(o.keywords, `${p}.keywords`, LIMITS.short).join(", ") : str(o.keywords, `${p}.keywords`, LIMITS.bullet),
    })),
    projects: entries("projects", (o, p) => ({
      id: randomId(8),
      name: str(o.name, `${p}.name`),
      url: str(o.url, `${p}.url`, 300),
      startDate: str(o.startDate, `${p}.startDate`, 40),
      endDate: str(o.endDate, `${p}.endDate`, 40),
      description: str(o.description ?? o.summary, `${p}.description`, LIMITS.text),
      highlights: strs(o.highlights, `${p}.highlights`),
    })),
    volunteer: entries("volunteer", (o, p) => ({
      id: randomId(8),
      position: str(o.position, `${p}.position`),
      organization: str(o.organization ?? o.name, `${p}.organization`),
      startDate: str(o.startDate, `${p}.startDate`, 40),
      endDate: str(o.endDate, `${p}.endDate`, 40),
      summary: str(o.summary, `${p}.summary`, LIMITS.text),
      highlights: strs(o.highlights, `${p}.highlights`),
    })),
    awards: entries("awards", (o, p) => ({
      id: randomId(8),
      title: str(o.title ?? o.name, `${p}.title`),
      awarder: str(o.awarder, `${p}.awarder`),
      date: str(o.date, `${p}.date`, 40),
      summary: str(o.summary, `${p}.summary`, LIMITS.text),
    })),
    certificates: entries("certificates", (o, p) => ({
      id: randomId(8),
      name: str(o.name ?? o.title, `${p}.name`),
      issuer: str(o.issuer ?? o.awarder, `${p}.issuer`),
      date: str(o.date, `${p}.date`, 40),
      url: str(o.url, `${p}.url`, 300),
    })),
    publications: entries("publications", (o, p) => ({
      id: randomId(8),
      name: str(o.name ?? o.title, `${p}.name`),
      publisher: str(o.publisher, `${p}.publisher`),
      releaseDate: str(o.releaseDate ?? o.date, `${p}.releaseDate`, 40),
      url: str(o.url, `${p}.url`, 300),
      summary: str(o.summary, `${p}.summary`, LIMITS.text),
    })),
    languages: entries("languages", (o, p) => ({ id: randomId(8), language: str(o.language, `${p}.language`), fluency: str(o.fluency, `${p}.fluency`) })),
    interests: entries("interests", (o, p) => ({
      id: randomId(8),
      name: str(o.name, `${p}.name`),
      keywords: Array.isArray(o.keywords) ? strs(o.keywords, `${p}.keywords`, LIMITS.short).join(", ") : str(o.keywords, `${p}.keywords`, LIMITS.bullet),
    })),
    settings: { template, paper, ...(color ? { color } : {}), order: [...order, ...SECTIONS.filter((k) => !order.includes(k))], titles },
  };
}

// ---- JSON Resume out ----

/** Drops empty values so the export reads cleanly. */
function compact<T extends Obj>(o: T): Partial<T> {
  return Object.fromEntries(Object.entries(o).filter(([, v]) => (Array.isArray(v) ? v.length > 0 : typeof v === "object" && v ? Object.keys(v).length > 0 : v !== "" && v != null))) as Partial<T>;
}

/** The resume as JSON Resume, with Resume's own settings in meta so an import brings back the same resume. */
export function toJsonResume(input: Resume) {
  const r = cleanResume(input);
  const b = r.basics;
  const [city, ...rest] = b.location.split(",");
  return compact({
    $schema: "https://raw.githubusercontent.com/jsonresume/resume-schema/v1.0.0/schema.json",
    basics: compact({
      name: b.name, label: b.label, email: b.email, phone: b.phone, summary: b.summary,
      location: compact({ city: city.trim(), region: rest.join(",").trim() }),
      profiles: b.links.map((url) => compact({ network: networkOf(url), url })),
    }),
    work: r.work.map(({ id: _, ...w }) => compact(w)),
    education: r.education.map(({ id: _, ...e }) => compact(e)),
    skills: r.skills.map((s) => compact({ name: s.name, keywords: keywordList(s.keywords) })),
    projects: r.projects.map(({ id: _, ...p }) => compact(p)),
    volunteer: r.volunteer.map(({ id: _, ...v }) => compact(v)),
    awards: r.awards.map(({ id: _, ...a }) => compact(a)),
    certificates: r.certificates.map(({ id: _, ...c }) => compact(c)),
    publications: r.publications.map(({ id: _, ...p }) => compact(p)),
    languages: r.languages.map(({ id: _, ...l }) => compact(l)),
    interests: r.interests.map((i) => compact({ name: i.name, keywords: keywordList(i.keywords) })),
    meta: compact({ template: r.settings.template, paper: r.settings.paper, color: r.settings.color, order: r.settings.order, titles: r.settings.titles }),
  });
}

// ---- Plain text out ----

/** The resume as plain text, in the printed order: for pasting into application forms. */
export function toText(input: Resume): string {
  const r = cleanResume(input);
  const b = r.basics;
  const out: string[] = [];
  const line = (...parts: string[]) => { const s = parts.filter(Boolean).join(" | "); if (s) out.push(s); };
  const bullets = (items: string[]) => items.forEach((h) => out.push(`- ${h}`));
  line(b.name);
  line(b.label);
  line(b.email, b.phone, b.location, ...b.links.map(displayUrl));
  for (const key of r.settings.order) {
    if (!hasSection(r, key)) continue;
    out.push("", sectionTitle(r, key).toUpperCase());
    const gap = () => out.push("");
    if (key === "summary") out.push(b.summary);
    if (key === "work") r.work.forEach((w) => { gap(); line([w.position, w.name].filter(Boolean).join(", ")); line(w.location, dateRange(w.startDate, w.endDate)); if (w.summary) out.push(w.summary); bullets(w.highlights); });
    if (key === "education") r.education.forEach((e) => { gap(); line(e.institution); line(degreeOf(e), e.score); line(e.location, dateRange(e.startDate, e.endDate)); bullets(e.courses); });
    if (key === "skills") r.skills.forEach((s) => out.push(s.name && s.keywords ? `${s.name}: ${s.keywords}` : s.name || s.keywords));
    if (key === "projects") r.projects.forEach((p) => { gap(); line(p.name); line(displayUrl(p.url), dateRange(p.startDate, p.endDate)); if (p.description) out.push(p.description); bullets(p.highlights); });
    if (key === "volunteer") r.volunteer.forEach((v) => { gap(); line([v.position, v.organization].filter(Boolean).join(", ")); line(dateRange(v.startDate, v.endDate)); if (v.summary) out.push(v.summary); bullets(v.highlights); });
    if (key === "awards") r.awards.forEach((a) => { line([a.title, a.awarder].filter(Boolean).join(", "), fmtDate(a.date)); if (a.summary) out.push(a.summary); });
    if (key === "languages") r.languages.forEach((l) => out.push(l.fluency ? `${l.language} (${l.fluency})` : l.language));
    if (key === "certificates") r.certificates.forEach((c) => line([c.name, c.issuer].filter(Boolean).join(", "), fmtDate(c.date), displayUrl(c.url)));
    if (key === "publications") r.publications.forEach((p) => { line([p.name, p.publisher].filter(Boolean).join(", "), fmtDate(p.releaseDate), displayUrl(p.url)); if (p.summary) out.push(p.summary); });
    if (key === "interests") r.interests.forEach((i) => out.push(i.name && i.keywords ? `${i.name}: ${i.keywords}` : i.name || i.keywords));
  }
  return out.join("\n").replace(/\n{3,}/g, "\n\n").trim() + "\n";
}

Change history

Quality pass: several resumes, cover letters, phone-readable shared pages
3f16b23757 · 6 hours ago
Bounded source sync
b7eb5b26b5 · 1 day ago
Bounded source sync
adae97536e · 1 day ago
Bounded source sync
3b0cf36b39 · 1 day ago