app/lib/cv/entries.mjs
397 lines and 18 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| slugify | 88 to 97 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| formatWhen | 103 to 107 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| venueOf | 115 to 120 | 6 | 100% | WatchCRAP 6 | No data: no mutants in this code |
| authorRole | 123 to 131 | 4 | 75% | OkCRAP 4.3 | No data: no mutants in this code |
| <anonymous:124> | 124 to 124 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| plainCitation | 140 to 144 | 4 | 100% | OkCRAP 4 | No data: no mutants in this code |
| <anonymous:141> | 141 to 141 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| resolveOne | 151 to 344 | 40 | 100% | CriticalCRAP 40 | No data: no mutants in this code |
| <anonymous:171> | 171 to 171 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:188> | 188 to 188 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:236> | 236 to 236 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| resolveAll | 351 to 373 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:353> | 353 to 353 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:355> | 355 to 372 | 6 | 100% | WatchCRAP 6 | No data: no mutants in this code |
| <anonymous:368> | 368 to 368 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| buildCv | 380 to 390 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| cvFacts | 402 to 419 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:403> | 403 to 418 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
Imports
It imports app/lib/cv/view.mjs, app/lib/identity.generated.mjs, app/lib/identity.mjs, app/lib/publications/authors.mjs, app/lib/publications/entities.mjs, app/lib/publications/paths.mjs. Imported by app/lib/carrel/cv-handler.server.ts, app/lib/cv/compile.mjs, app/lib/cv/markdown.mjs, app/lib/nav.ts, app/routes/cv.charts[.json].ts, app/routes/cv.tsx, app/routes/cv[.md].ts.
Source
/**
* The CV resolved: the files in content/cv/ (assembled by app/lib/cv/compile.mjs) joined to the publication
* records, one flat list every surface renders. The page (app/routes/cv.tsx), the markdown twin
* (app/lib/cv/markdown.mjs, served by app/routes/cv[.md].ts) and the PDF (rendered from the twin by
* app/lib/cv/pdf.server.ts, and offline by scripts/build-cv-pdf.mjs) all resolve it with `buildCv`, so they cannot say different things. Pure and clock-free, so the twin and
* the PDF fingerprint are reproducible.
*/
import { deriveIdentity } from "../identity.mjs";
import { IDENTITY } from "../identity.generated.mjs";
import { canonicalAuthor } from "../publications/authors.mjs";
import { decodeEntities } from "../publications/entities.mjs";
import { paperPath, paperPdfPath } from "../publications/paths.mjs";
import { areaLabel, foldText, formatDollars, roleLabel, typeLabel } from "./view.mjs";
export { formatDollars };
/** @typedef {import("./types.ts").CvType} CvType */
/** @typedef {import("./types.ts").CvArea} CvArea */
/** @typedef {import("./types.ts").CvRole} CvRole */
/** @typedef {import("./types.ts").CvLink} CvLink */
/** @typedef {import("./types.ts").CvSourceEntry} CvSourceEntry */
/** @typedef {import("./types.ts").CvSource} CvSource */
/** @typedef {import("../publications/types.ts").Publication} Publication */
/** @typedef {import("../publications/types.ts").TopicId} TopicId */
/**
* @typedef {object} CvPaper
* @property {string[]} authors as the record lists them, the owner included
* @property {string} venue journal, volume(issue):pages
* @property {string | null} doi
* @property {string | null} pagePath the site's page for the paper, which carries the abstract
* @property {string | null} pdfPath
* @property {string | null} pmid
* @property {string | null} pmcUrl
* @property {boolean} hasAbstract
* @property {string} citation the plain citation a reader copies
* @property {string | null} bibtexPath the site's .bib export, or null when the text is inline
* @property {string | null} risPath
* @property {string | null} bibtex inline BibTeX, for a paper the site has no record for
* @property {string | null} ris
* @property {Publication | null} record the site record, for JSON-LD
*/
/**
* @typedef {object} CvEntry
* @property {string} id a stable anchor, unique across the CV
* @property {CvType} type
* @property {string | null} section the subsection it sits under when grouped by type
* @property {number | null} year when it started
* @property {number | "present" | null} endYear null for a single year
* @property {string} when the dates as printed: "2019", "2014-2020", "2015-present", or ""
* @property {CvArea[]} areas
* @property {CvRole | null} role
* @property {string} title the entry's first line
* @property {string[]} meta the lines after it
* @property {string | null} note
* @property {CvLink[]} links
* @property {CvPaper | null} paper
* @property {number | null} amount a grant's US dollars
* @property {number | null} students how many students a mentoring line counts
* @property {boolean} cohort a mentoring count that belongs to its start year alone
* @property {{ heading: string, items: string[] }[]} duties
* @property {string} search folded text the search box matches
*/
/** The PDF's public path: a Worker route over one R2 object the Worker renders after each CV save (app/routes/cv-pdf.ts). */
export const CV_PDF_PATH = "/dustin-edwards-cv.pdf";
export const CV_PAGE = {
path: "/cv",
title: "Curriculum Vitae",
seoTitle: `${IDENTITY.name} CV: ${IDENTITY.role}, Tarleton`,
description: `${IDENTITY.name}'s CV: ${IDENTITY.role} at ${IDENTITY.affiliation}. Appointments, education, publications, grants, teaching and service.`,
};
/** @type {Record<TopicId, CvArea>} */
const TOPIC_AREA = {
"human-simian-retroviruses": "retroviruses",
"avian-retroviruses": "retroviruses",
bacteriophages: "bacteriophages",
"science-education": "science-education",
};
const OWNER = "Dustin Edwards";
/** @param {string} value */
function slugify(value) {
return value
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 48)
.replace(/-+$/g, "");
}
/**
* @param {number | null} year
* @param {number | "present" | undefined} endYear
*/
function formatWhen(year, endYear) {
if (year === null) return "";
if (endYear === undefined || endYear === year) return String(year);
return `${year}-${endYear}`;
}
/**
* @param {string | null} journal
* @param {string | null | undefined} volume
* @param {string | null | undefined} issue
* @param {string | null | undefined} pages
*/
function venueOf(journal, volume, issue, pages) {
let out = decodeEntities(journal ?? "");
if (volume) out += ` ${volume}${issue ? `(${issue})` : ""}`;
if (pages) out += `${volume ? ":" : ", "}${pages}`;
return out.trim();
}
/** @param {Publication} p @returns {CvRole} */
function authorRole(p) {
const at = p.authors.findIndex((name) => canonicalAuthor(name) === OWNER);
if (at < 0) {
throw new Error(`cv: ${p.doi} does not list ${OWNER} among its authors, so the CV cannot claim it.`);
}
if (at === 0) return "first-author";
if (at === p.authors.length - 1) return "senior-author";
return "co-author";
}
/**
* @param {string[]} authors
* @param {number | null} year
* @param {string} title
* @param {string} venue
* @param {string | null} doi
*/
function plainCitation(authors, year, title, venue, doi) {
const names = authors.map((name) => canonicalAuthor(name)).join(", ");
const end = /[.?!]$/.test(title) ? "" : ".";
return `${names}. ${year ?? "n.d."}. ${title}${end} ${venue}.${doi ? ` https://doi.org/${doi}` : ""}`;
}
/**
* @param {CvSourceEntry} source
* @param {Map<string, Publication>} byDoi the publication records by lower-cased DOI
* @returns {Omit<CvEntry, "id" | "search">}
*/
function resolveOne(source, byDoi) {
const base = {
section: null,
note: null,
links: /** @type {CvLink[]} */ ([]),
paper: null,
amount: null,
students: null,
cohort: false,
duties: /** @type {{ heading: string, items: string[] }[]} */ ([]),
};
if (source.type === "publication" && "doi" in source) {
const p = byDoi.get(source.doi.toLowerCase());
if (!p) {
throw new Error(`cv: no publication record for DOI ${source.doi}; add its file to content/publications/ first.`);
}
const title = decodeEntities(p.title);
const venue = venueOf(p.journal, p.volume, p.issue, p.pages);
const { slug } = p;
const areas = [...new Set(p.topics.map((t) => TOPIC_AREA[t]))];
/** @type {CvLink[]} */
const links = [{ label: "DOI", href: `https://doi.org/${p.doi}` }];
if (p.pmid) links.push({ label: "PubMed", href: `https://pubmed.ncbi.nlm.nih.gov/${p.pmid}/` });
if (p.pmcUrl) links.push({ label: "PMC", href: p.pmcUrl });
return {
...base,
type: "publication",
year: p.year,
endYear: null,
when: String(p.year),
areas,
role: authorRole(p),
title,
meta: [venue],
links,
paper: {
authors: p.authors.map((name) => decodeEntities(name)),
venue,
doi: p.doi,
pagePath: paperPath(slug),
pdfPath: p.access === "self-hosted" && p.pdfPath ? paperPdfPath(slug) : null,
pmid: p.pmid,
pmcUrl: p.pmcUrl,
hasAbstract: Boolean(p.abstract),
citation: plainCitation(p.authors, p.year, title, venue, p.doi),
bibtexPath: `/research/publications/${slug}.bib`,
risPath: `/research/publications/${slug}.ris`,
bibtex: null,
ris: null,
record: p,
},
};
}
const when = formatWhen(source.year, source.endYear);
const common = {
...base,
year: source.year,
endYear: source.endYear ?? null,
when,
areas: source.areas,
role: source.role,
links: source.links ?? [],
};
switch (source.type) {
case "publication": {
const venue = venueOf(source.journal, source.volume, source.issue, source.pages);
const key = `${slugify(source.authors[0]?.split(" ").pop() ?? "cv")}-${source.year}-${slugify(source.title).split("-").slice(0, 3).join("-")}`;
const bibtex = [
`@article{${key},`,
` title = {${source.title}},`,
` author = {${source.authors.join(" and ")}},`,
` journal = {${source.journal}},`,
` year = {${source.year}},`,
...(source.volume ? [` volume = {${source.volume}},`] : []),
...(source.issue ? [` number = {${source.issue}},`] : []),
...(source.pages ? [` pages = {${source.pages.replace("-", "--")}},`] : []),
"}",
].join("\n");
const pages = source.pages?.split("-") ?? [];
const ris = [
"TY - JOUR",
`TI - ${source.title}`,
...source.authors.map((name) => `AU - ${name}`),
`JO - ${source.journal}`,
`PY - ${source.year}`,
...(source.volume ? [`VL - ${source.volume}`] : []),
...(source.issue ? [`IS - ${source.issue}`] : []),
...(pages[0] ? [`SP - ${pages[0]}`] : []),
...(pages[1] ? [`EP - ${pages[1]}`] : []),
"ER - ",
].join("\n");
return {
...common,
type: "publication",
title: source.title,
meta: [venue],
paper: {
authors: source.authors,
venue,
doi: null,
pagePath: null,
pdfPath: null,
pmid: null,
pmcUrl: null,
hasAbstract: false,
citation: plainCitation(source.authors, source.year, source.title, venue, null),
bibtexPath: null,
risPath: null,
bibtex,
ris,
record: null,
},
};
}
case "appointment":
return {
...common,
type: "appointment",
section: source.section,
title: source.title,
meta: [source.org, ...(source.detail ? [source.detail] : [])],
duties: source.duties ?? [],
};
case "education":
return {
...common,
type: "education",
title: source.title,
meta: [source.org, ...(source.detail ? [source.detail] : [])],
};
case "grant":
return {
...common,
type: "grant",
title: source.title,
meta: [source.funder ?? ""].filter(Boolean),
note: source.note ?? null,
amount: source.amount,
};
case "award":
return {
...common,
type: "award",
title: source.title,
meta: [[source.org, source.place].filter(Boolean).join(", ")].filter(Boolean),
};
case "talk":
return {
...common,
type: "talk",
title: source.title,
meta: [source.venue, ...(source.presenters.length > 1 ? [`With ${source.presenters.slice(1).join(", ")}`] : [])],
};
case "course":
return {
...common,
type: "course",
title: `${source.code}: ${source.title}`,
meta: [`${source.org}, ${source.term}`],
};
case "mentoring": {
const counted = `${source.count} ${source.unit === "awards" ? (source.count === 1 ? "award" : "awards") : source.count === 1 ? "student" : "students"}`;
return {
...common,
type: "mentoring",
section: source.section,
title: `${counted}: ${source.level}`,
meta: [source.program, source.org, ...(source.detail ? [source.detail] : [])],
students: source.unit === "students" ? source.count : null,
cohort: source.cohort,
};
}
case "service":
return {
...common,
type: "service",
section: source.section,
title: source.title,
meta: [[source.org, source.place].filter(Boolean).join(", ")].filter(Boolean),
};
case "development":
return {
...common,
type: "development",
title: source.title,
meta: [[source.org, source.place].filter(Boolean).join(", ")].filter(Boolean),
};
default:
throw new Error(`cv: an entry has no known type: ${JSON.stringify(source)}`);
}
}
/**
* @param {CvSourceEntry[]} sources
* @param {Publication[]} publications
* @returns {CvEntry[]}
*/
function resolveAll(sources, publications) {
/** @type {Map<string, Publication>} */
const byDoi = new Map(publications.flatMap((p) => (p.doi ? [[p.doi.toLowerCase(), p]] : [])));
const seen = new Map();
return sources.map((source) => {
const entry = resolveOne(source, byDoi);
const stem = `${entry.type}-${entry.year ?? "undated"}-${slugify(entry.title)}`;
const n = (seen.get(stem) ?? 0) + 1;
seen.set(stem, n);
const id = n === 1 ? stem : `${stem}-${n}`;
const search = foldText(
[
entry.title,
...entry.meta,
entry.note ?? "",
entry.when,
...(entry.paper ? entry.paper.authors : []),
...entry.duties.flatMap((d) => [d.heading, ...d.items]),
].join(" "),
);
return { ...entry, id, search };
});
}
/**
* @param {CvSource} source what the files state (assembleCvSource)
* @param {Publication[]} publications the published records: D1's (app/db/cv.ts) or the build's
* (scripts/lib/publications.mjs)
*/
export function buildCv(source, publications) {
// The header's title is derived from the current appointments and the discipline, never stated in the profile.
const derived = deriveIdentity(source.person, source.entries);
if (!derived.ok) throw new Error(`the CV cannot give a title: ${derived.errors.join("; ")}`);
return {
edition: source.edition,
person: { ...source.person, title: derived.identity.cvTitle },
presentations: source.presentations,
entries: resolveAll(source.entries, publications),
};
}
/** @typedef {ReturnType<typeof buildCv>} Cv */
/**
* What filtering reads from each entry (app/lib/cv/view.mjs). The search text also carries the type,
* role and area labels the page prints beside the entry, so a word a reader can see is a word the
* search finds, on the server and in the browser alike.
*
* @param {CvEntry[]} entries
* @returns {import("./view.mjs").Facts[]}
*/
export function cvFacts(entries) {
return entries.map((e, order) => ({
id: e.id,
type: e.type,
section: e.section,
areas: e.areas,
role: e.role,
year: e.year,
endYear: e.endYear,
students: e.students,
cohort: e.cohort,
amount: e.amount,
order,
search: foldText(
[e.search, typeLabel(e.type), e.role ? roleLabel(e.role) : "", ...e.areas.map(areaLabel)].join(" "),
),
}));
}