- CV Automation
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.tssrc/data/projects.tssrc/data/fellowships.tssrc/data/talks.tssrc/data/teaching.tspublic/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:
- Authors in
Last, F. M.form, separated by commas, with&before the last author - Year in parentheses followed by a period
- Title ending in a period
- Italicized journal name followed by a comma
- Volume(issue), page range
- 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
mammothconverts.docxto HTML or plain texttsxruns TypeScript files directlyzodvalidates 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.tscontains the expected arraypublic/cv/publications.bibcontains 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
- Edit
content/cv.docx - Run
./update-cv.sh - Review generated files in
src/data/ - Commit changes with Git
Next tutorial
Questions or feedback: open an issue on the repository or email hannahlee@virginia.edu.