6 minute read

5. CV Automation

Part 5 of the From CV to Site tutorial series.

Prerequisites

  • Completed Tutorial 4
  • An existing Word CV (.docx) with publications, projects, and related sections
  • A code editor for reviewing and adjusting generated files

What this automation does

The CV automation parses your Word CV and generates TypeScript data files that power the site. Editing your CV is the single source of truth; the site updates by running one script.

Input:

  • content/cv.docx

Output:

  • src/data/publications.ts
  • src/data/projects.ts
  • src/data/fellowships.ts
  • src/data/talks.ts
  • src/data/teaching.ts
  • public/cv/publications.bib (BibTeX export)

The starter/ directory in this repository contains the full working implementation. This tutorial explains how it works and how to extend it. If you forked the starter in Tutorial 4, you already have everything below.

Fastest path: use the starter

Run from your project root:

pnpm cv:sample   # writes content/cv.docx with example entries
pnpm cv:update   # parses it and writes src/data/*.ts + public/cv/publications.bib
pnpm dev         # verify pages render the generated data

Expected output:

[publications] wrote 3 item(s).
[projects] wrote 2 item(s).
[fellowships] wrote 2 item(s).
[talks] wrote 3 item(s).
[teaching] wrote 3 item(s).
[bibtex] wrote 3 entries.

All entries parsed successfully.

If this works end to end, skip ahead to Word CV formatting requirements and Handling edge cases. The code sections below explain the internals for readers who want to modify the parser.

Supported CV sections

The parser recognizes sections by heading. Expected section headings (case-insensitive, adjustable in the script):

Section heading Target data file
Publications or Peer-Reviewed Publications publications.ts
Projects or Grants projects.ts
Fellowships or Awards fellowships.ts
Talks or Invited Talks talks.ts
Teaching teaching.ts

Word CV formatting requirements

The parser is sensitive to entry format. Structure each section as a list where every entry follows a predictable pattern.

Publications (APA 7):

Last, F. M., & Second, A. B. (2024). Title of the paper. Journal Name, 12(3), 45-67. https://doi.org/10.xxxx/yyyy

Required elements in order:

  1. Authors in Last, F. M. form, separated by commas, with & before the last author
  2. Year in parentheses followed by a period
  3. Title ending in a period
  4. Italicized journal name followed by a comma
  5. Volume(issue), page range
  6. Optional DOI URL

Projects / grants:

Project title (2023 - 2025). Role: Principal Investigator. Funder: Agency Name. Amount: $X.

Fellowships:

Fellowship Name, Awarding Body, 2024.

Talks:

Talk title. Conference or venue name, Location, Month Year.

Consistency matters more than any specific format. The parser can be adjusted to other conventions if yours is consistent.

Required libraries

Install the following dev dependencies at the project root:

pnpm add -D mammoth tsx zod
  • mammoth converts .docx to HTML or plain text
  • tsx runs TypeScript files directly
  • zod validates parsed entries against a schema

Folder layout

my-site/
├── content/
│   └── cv.docx
├── scripts/
│   ├── parse-cv.ts
│   ├── parsers/
│   │   ├── publications.ts
│   │   ├── projects.ts
│   │   ├── fellowships.ts
│   │   ├── talks.ts
│   │   └── teaching.ts
│   └── writers/
│       ├── typescript.ts
│       └── bibtex.ts
├── src/
│   └── data/                (generated)
└── update-cv.sh

The parse-cv.ts script

The entry point reads the CV, splits it into sections, dispatches each section to its parser, and writes the output files. See the full implementation at starter/scripts/parse-cv.ts. The relevant parts:

import mammoth from "mammoth";
import { splitBySection } from "./parsers/shared";
import { parsePublications } from "./parsers/publications";
// ...other parsers

const { value: html } = await mammoth.convertToHtml({ buffer });
const sections = splitBySection(html);

const { items: publications, unparsed } = parsePublications(
  sections.publications ?? ""
);
// write src/data/publications.ts and public/cv/publications.bib

The splitBySection helper (starter/scripts/parsers/shared.ts) iterates the <h1>–<h6> tags produced by mammoth, normalises each heading, and maps each section name to the HTML between that heading and the next. The normalization strips punctuation and case so that “Peer-Reviewed Publications” and “publications” both map to the same key.

Each parser returns an object with items (successfully parsed entries) and unparsed (lines that did not match). The entry point logs the unparsed count after every run so formatting drift in the CV is visible immediately.

Publication parser

A publication parser using regular expressions against the APA 7 format:

// scripts/parsers/publications.ts
import { z } from "zod";

const PublicationSchema = z.object({
  authors: z.array(z.string()),
  year: z.number(),
  title: z.string(),
  venue: z.string(),
  volume: z.string().optional(),
  issue: z.string().optional(),
  pages: z.string().optional(),
  doi: z.string().optional(),
});

export type Publication = z.infer<typeof PublicationSchema>;

const ENTRY_REGEX =
  /^(?<authors>.+?)\s*\((?<year>\d{4})\)\.\s*(?<title>.+?)\.\s*(?<venue>.+?)(?:,\s*(?<volume>\d+)(?:\((?<issue>\d+)\))?)?(?:,\s*(?<pages>[\d\-–]+))?\.?(?:\s*(?<doi>https?:\/\/doi\.org\/\S+))?/;

export function parsePublications(html: string): Publication[] {
  const text = htmlToEntries(html);
  return text
    .map(parseOne)
    .filter((pub): pub is Publication => pub !== null);
}

function parseOne(line: string): Publication | null {
  const match = ENTRY_REGEX.exec(line);
  if (!match?.groups) return null;
  const { authors, year, title, venue, volume, issue, pages, doi } = match.groups;
  const parsed = {
    authors: authors.split(/,\s*&?\s*|\s+&\s+/).map((a) => a.trim()),
    year: Number(year),
    title: title.trim(),
    venue: venue.trim(),
    volume,
    issue,
    pages,
    doi,
  };
  const result = PublicationSchema.safeParse(parsed);
  return result.success ? result.data : null;
}

function htmlToEntries(html: string): string[] {
  return html
    .replace(/<li[^>]*>/g, "\n")
    .replace(/<[^>]+>/g, "")
    .split("\n")
    .map((line) => line.trim())
    .filter(Boolean);
}

This parser extracts authors, year, title, venue, volume, issue, pages, and DOI. Entries that fail the regex are returned as null and excluded from the output.

BibTeX generation

Generate a .bib file from parsed publications.

// scripts/writers/bibtex.ts
import type { Publication } from "../parsers/publications";

export function toBibTeX(publications: Publication[]): string {
  return publications.map(entryToBibTeX).join("\n\n");
}

function entryToBibTeX(pub: Publication): string {
  const firstAuthor = pub.authors[0]?.split(",")[0]?.toLowerCase() ?? "anon";
  const key = `${firstAuthor}${pub.year}`;
  const fields = [
    `  author = {${pub.authors.join(" and ")}}`,
    `  title = {${pub.title}}`,
    `  journal = {${pub.venue}}`,
    `  year = {${pub.year}}`,
    pub.volume && `  volume = {${pub.volume}}`,
    pub.issue && `  number = {${pub.issue}}`,
    pub.pages && `  pages = {${pub.pages}}`,
    pub.doi && `  doi = {${pub.doi}}`,
  ].filter(Boolean).join(",\n");
  return `@article{${key},\n${fields}\n}`;
}

TypeScript writer

Serialize parsed entries as a typed TypeScript module that the site imports. See starter/scripts/writers/typescript.ts for the full file. The core:

export function toTypeScript<T>(options: {
  exportName: string;
  typeName: string;
  items: T[];
}): string {
  const { exportName, typeName, items } = options;
  const body = JSON.stringify(items, null, 2);
  return (
    HEADER +
    `import type { ${typeName} } from "./types";\n\n` +
    `export const ${exportName}: ${typeName}[] = ${body};\n`
  );
}

The options object is used because each section is written with a different export name and type. The generated module imports its type from src/data/types.ts so the site keeps a single source of truth for the shape of every entry.

The update-cv.sh wrapper

The starter ships with a working update-cv.sh at the project root. If you forked the starter, no further action is needed. For reference, it contains:

#!/usr/bin/env bash
set -euo pipefail

cd "$(dirname "$0")"

if [ ! -f content/cv.docx ]; then
  echo "Missing content/cv.docx"
  echo "Place your Word CV at content/cv.docx, or run:"
  echo "  pnpm tsx scripts/generate-sample-cv.ts"
  exit 1
fi

pnpm tsx scripts/parse-cv.ts
echo ""
echo "CV data updated. Review src/data/ and public/cv/ and commit changes."

Make sure the file is executable:

chmod +x update-cv.sh

Run it:

./update-cv.sh

Consuming generated data in pages

Import the generated modules in your page components. The starter’s research page groups publications by year using a Map; Object.groupBy is intentionally avoided because it is only available on Node.js 21 and newer. See starter/src/app/research/page.tsx for the full file.

// src/app/research/page.tsx
import { publications } from "@/data/publications";

export default function Research() {
  const byYear = new Map<number, typeof publications>();
  for (const pub of publications) {
    const list = byYear.get(pub.year) ?? [];
    list.push(pub);
    byYear.set(pub.year, list);
  }
  const years = Array.from(byYear.keys()).sort((a, b) => b - a);

  return (
    <main className="mx-auto max-w-3xl px-6 py-16">
      <h1 className="text-3xl font-serif mb-8">Research</h1>
      {years.map((year) => (
        <section key={year} className="mb-8">
          <h2 className="text-sm uppercase tracking-wider text-neutral-500 mb-3">
            {year}
          </h2>
          <ul className="space-y-4">
            {byYear.get(year)!.map((p, i) => (
              <li key={i}>
                {p.authors.join(", ")} ({p.year}). {p.title}. <em>{p.venue}</em>.
              </li>
            ))}
          </ul>
        </section>
      ))}
    </main>
  );
}

Testing with sample data

Create content/cv.docx with two or three entries per section and run ./update-cv.sh. Confirm that:

  • src/data/publications.ts contains the expected array
  • public/cv/publications.bib contains valid BibTeX
  • The research page renders entries from the generated module

Handling edge cases

Non-standard entries. Book chapters, conference proceedings, and preprints often follow different patterns. Extend ENTRY_REGEX or add conditional branches in parseOne for each variant.

Missing DOIs. Older publications may lack a DOI. The regex allows the DOI group to be absent.

Unicode characters. Names with accents or non-Latin characters pass through unchanged. Verify lang="en" in the root layout covers your content, or adjust per page.

Multi-line entries. If Word wraps long entries across lines, ensure htmlToEntries splits on list items rather than newlines.

Entries that fail to parse. Log them to the console. Fix the CV formatting or extend the parser until the log is empty.

Maintenance workflow

  1. Edit content/cv.docx
  2. Run ./update-cv.sh
  3. Review generated files in src/data/
  4. Commit changes with Git

Next tutorial

Tutorial 6: Deploying


Questions or feedback: open an issue on the repository or email hannahlee@virginia.edu.

Back to README