app/lib/content-pages.mjs
275 lines and 26 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.
Functions
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| publishedContentPagePaths | 94 to 96 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:95> | 95 to 95 | 2 | 100% | OkCRAP 2 | Ok100% |
| draftContentPagePaths | 105 to 108 | 1 | 100% | OkCRAP 1 | Critical0% |
| <anonymous:107> | 107 to 107 | 1 | 100% | OkCRAP 1 | Critical0% |
| contentPageFile | 137 to 139 | 1 | 100% | OkCRAP 1 | Ok100% |
| contentPageMarkdownPath | 146 to 148 | 1 | 100% | OkCRAP 1 | Ok100% |
| contentPageMarkdownBody | 158 to 165 | 5 | 100% | OkCRAP 5 | Ok85% |
| hasPageCard | 177 to 179 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:178> | 178 to 178 | 2 | 100% | OkCRAP 2 | Ok100% |
| contentPageCardInput | 188 to 195 | 1 | 100% | OkCRAP 1 | Watch66.7% |
| contentPageCardPath | 204 to 206 | 2 | 100% | OkCRAP 2 | Ok100% |
| markdownTableFacts | 216 to 240 | 11 | 100% | WatchCRAP 11 | Watch64.7% |
| <anonymous:219> | 219 to 219 | 3 | 100% | OkCRAP 3 | Critical58.3% |
| cells | 221 to 226 | 1 | 100% | OkCRAP 1 | Ok83.3% |
| <anonymous:226> | 226 to 226 | 1 | 100% | OkCRAP 1 | Ok100% |
| <anonymous:231> | 231 to 231 | 1 | 100% | OkCRAP 1 | Critical33.3% |
| <anonymous:235> | 235 to 235 | 1 | 100% | OkCRAP 1 | Critical44.4% |
| <anonymous:235> | 235 to 235 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| contentPageTrail | 261 to 270 | 4 | 100% | OkCRAP 4 | Ok93.8% |
| <anonymous:261> | 261 to 261 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:262> | 262 to 262 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| contentPageSearchUid | 273 to 275 | 1 | 100% | OkCRAP 1 | Critical0% |
| contentPageSearchInputs | 285 to 298 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:285> | 285 to 285 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
| <anonymous:286> | 286 to 297 | 2 | 100% | OkCRAP 2 | No data: changed since the last mutation run |
| <anonymous:295> | 295 to 295 | 1 | 100% | OkCRAP 1 | No data: changed since the last mutation run |
Imports
It imports app/lib/content/og-card-text.mjs, app/lib/dictionary-entries.mjs, app/lib/long-date.mjs, app/lib/search/records.mjs. Imported by app/lib/cv/markdown.mjs, app/lib/cv/save.server.ts, app/lib/dictionary/compile.mjs, app/lib/llms/save.server.ts, app/lib/nav.ts, app/lib/pages/compile.mjs, app/lib/pages/links.mjs, app/lib/pages/page-json-ld.ts, app/lib/pages/save.server.ts, app/lib/site-urls.server.ts, app/routes.ts, app/routes/content-page.tsx, app/routes/cv.tsx, app/routes/phages.tsx, app/routes/protocols.tsx.
Source
/**
* The Research, Teaching and Software pages: markdown in content/pages/, rendered at build time into
* content/generated/pages.json like the About page, and served by one route module. This list is the
* one place their paths are named: app/routes.ts registers a route per entry, the sitemap lists each,
* and the build refuses a markdown file whose path is not here or an entry with no file.
*
* The 2026-09-27 cutover wrote them from the old WordPress site, its knowledge base and the lab's
* notebooks (cutover.md, "Pages to build before the switch").
*/
import { ogImageKey } from "./content/og-card-text.mjs";
import { dictionaryEntryMarkdown, dictionaryEntryText } from "./dictionary-entries.mjs";
import { longDateUTC } from "./long-date.mjs";
import { splitSections } from "./search/records.mjs";
export const CONTENT_PAGE_PATHS = /** @type {const} */ ([
"/research",
"/research/retroviruses",
"/research/retroviruses/human",
"/research/retroviruses/avian",
"/research/bacteriophages",
"/research/science-education",
"/research/phages",
// Not in cutover.md's page list, but its redirect rows land old knowledge-base addresses here.
"/research/protocols",
// The protocols themselves are procedures (content/procedures/, docs/PROCEDURES.md), drawn from D1.
// The phage lab calculators (job_c2b88d76b3c1). Their addresses are fixed: the citable version
// (Zenodo DOI, JMBE) replaces them in place, so none of these may move.
"/research/tools",
"/research/tools/titer",
"/research/tools/dilution",
"/research/tools/webbed-plate",
"/research/tools/moi",
"/research/tools/eop",
"/research/tools/lysate-volume",
"/teaching",
"/teaching/phage-discovery",
"/teaching/virus-isolation",
"/teaching/virus-isolation/faq",
"/teaching/phage-bioinformatics",
"/teaching/central-dogma",
"/teaching/study-skills",
"/software",
"/software/foxhound",
"/software/foxing",
"/software/foxing-edu",
"/software/germomics",
"/software/capsid",
"/software/enarratio",
"/software/carrel",
"/software/capsomer",
// The CV. Its entries are the files in content/cv/ (docs/CV.md), not a markdown page kept in content/pages/.
"/cv",
// The legal pages, in Carrel with the rest (job_044f96426efa). /terms is also the terms the site's data is
// published under: the `license` of every Dataset (app/lib/license.mjs).
"/privacy",
"/terms",
// The accessibility statement (job_fea2f94832b3): what the gates check, in Carrel with the legal pages but
// not one of them. It ships as a draft until Dustin approves its text, and the footer links it once published.
"/accessibility",
]);
/**
* The legal pages: the only ones whose front matter may state a `banner` (the one word "Draft") and a
* `last_updated` date, which the layout draws for every reader and the markdown twin repeats. Carrel writes
* both (carrel design-legal-pages.md).
*
* @type {readonly string[]}
*/
export const LEGAL_PAGE_PATHS = ["/privacy", "/terms"];
/** The one word a legal page's `banner` may say. */
export const LEGAL_BANNER = "Draft";
/**
* The listed pages whose markdown is GENERATED from structured data rather than read from
* content/pages/: build:content renders it from app/lib/cv/markdown.mjs, so the twin, sitemap, search
* records and llms.txt treat /cv like every other page. Their HTML comes from their own route
* (app/routes/cv.tsx) and their twin from another (app/routes/cv[.md].ts), both drawn from the cv table, so
* pages.json and the pages table leave them out.
*
* @type {readonly string[]}
*/
export const CONTENT_PAGES_FROM_DATA = ["/cv"];
/**
* The listed pages a reader can open: every one whose page is published, and the ones generated from data.
* llms.txt must list each of these. `published` is the published paths from whichever source the caller
* judges against: the page files (readPublishedPages()) in CI and the gates, the D1 rows in the llms.txt save.
*
* @param {readonly string[]} published
* @returns {string[]}
*/
export function publishedContentPagePaths(published) {
return CONTENT_PAGE_PATHS.filter((path) => CONTENT_PAGES_FROM_DATA.includes(path) || published.includes(path));
}
/**
* The listed pages that are drafts: llms.txt may name them (so a page's line can land before or after it is
* published, in either order) but need not.
*
* @param {readonly string[]} published as for publishedContentPagePaths
* @returns {string[]}
*/
export function draftContentPagePaths(published) {
const open = publishedContentPagePaths(published);
return CONTENT_PAGE_PATHS.filter((path) => !open.includes(path));
}
/**
* The pages that have a file in content/pages/, a row in the pages table and the page save, but whose
* route, layout and structured data are their own (app/routes/about.tsx, a ProfilePage with its photo). They
* are not in CONTENT_PAGE_PATHS, so the research splat, a markdown twin, a search record and the llms.txt
* rules never see them: moving About onto D1 changed where its prose is kept and nothing a visitor reads.
*
* @type {readonly string[]}
*/
export const CONTENT_PAGES_OWN_ROUTE = ["/about"];
/** Every path a file in content/pages/ may name: the listed pages and the ones with a route of their own. */
export const PAGE_FILE_PATHS = /** @type {readonly string[]} */ ([...CONTENT_PAGE_PATHS, ...CONTENT_PAGES_OWN_ROUTE]);
/**
* The headings on these pages that the header menus link to by anchor, as `path#id`. A menu shows a
* section link only once it is listed here, and the build refuses an entry whose page has no heading
* with that id, so no menu link lands on the top of a page for want of its heading.
*
* @type {readonly string[]}
*/
export const CONTENT_PAGE_SECTIONS = ["/teaching#join-the-lab", "/teaching#teaching-philosophy"];
/**
* The markdown file a path is written in: slashes to hyphens, `/research/phages` in research-phages.md.
*
* @param {string} path
*/
export function contentPageFile(path) {
return `${path.slice(1).replaceAll("/", "-")}.md`;
}
/**
* The markdown twin's URL: the page path plus `.md`. Not `contentPageFile`, which is the repo filename.
*
* @param {string} path
*/
export function contentPageMarkdownPath(path) {
return `${path}.md`;
}
/**
* The twin a machine reads. The HTML page's h1 is not in the source body, so the title is the first line,
* followed by the page's dictionary entry where it has one, where the page shows it. The entry is the
* caller's to give: the build reads it from content/dictionary, the Worker from D1 (docs/DICTIONARY.md).
*
* @param {{ path?: string, title: string, markdown: string, banner?: string, lastUpdated?: string }} page
* @param {import("./dictionary-entries.mjs").DictionaryEntry} [entry] the page's published entry, if any
*/
export function contentPageMarkdownBody(page, entry) {
const body = String(page.markdown ?? "").replace(/^\n+/, "");
const lead = entry ? `${dictionaryEntryMarkdown(entry)}\n` : "";
// A legal page's banner and date are facts a reader sees under the title, so the twin states them too.
const stated = [page.banner, page.lastUpdated ? `Last updated: ${longDateUTC(page.lastUpdated)}` : ""].filter(Boolean);
const legal = stated.length > 0 ? `${stated.join("\n\n")}\n\n` : "";
return `# ${page.title}\n\n${legal}${lead}${body}`;
}
/**
* The pages build:og draws a social card of their own, by the root they sit under: the root and every
* page below it, so a calculator added under /research/tools is carded with no edit here. Every other
* page keeps the site card. Adding a root cards its pages on the next build:og.
*
* @type {readonly string[]}
*/
export const CARDED_PAGE_ROOTS = ["/software", "/research/tools", "/cv"];
/** @param {string} path */
export function hasPageCard(path) {
return CARDED_PAGE_ROOTS.some((root) => path === root || path.startsWith(`${root}/`));
}
/**
* What build:og draws for a page, in the shape ogImageKey hashes: the page's og:title (its SERP title)
* and its description, with no date line, since these pages are not dated. The `page-` prefix keeps a
* page's key apart from a post's, and build:og refuses two cards with one key.
*
* @param {{ path: string, seoTitle: string, description: string }} page
*/
export function contentPageCardInput(page) {
return {
slug: `page-${page.path.slice(1).replaceAll("/", "-")}`,
title: page.seoTitle,
description: page.description,
publishAt: null,
};
}
/**
* The /media path of a page's card, which its og:image names, or null for a page on the site card. The
* key hashes what the card draws, so a retitled page names a new card, which build:og then draws.
*
* @param {{ path: string, seoTitle: string, description: string }} page
* @returns {string | null}
*/
export function contentPageCardPath(page) {
return hasPageCard(page.path) ? `/media/${ogImageKey(contentPageCardInput(page))}` : null;
}
/**
* What a Dataset node says about a page's first markdown table, read from the markdown the page renders:
* its column headings, as the variables, and the span of its Year column, as an ISO 8601 interval. Null
* when the page has no table.
*
* @param {string} markdown
* @returns {{ variableMeasured: string[], temporalCoverage: string | null, rows: number } | null}
*/
export function markdownTableFacts(markdown) {
const lines = String(markdown ?? "").split("\n");
const rule = /^\|(\s*:?-+:?\s*\|)+\s*$/;
const at = lines.findIndex((line, i) => /^\|.*\|\s*$/.test(line) && rule.test(lines[i + 1] ?? ""));
if (at < 0) return null;
const cells = (/** @type {string} */ line) =>
line
.trim()
.replace(/^\||\|$/g, "")
.split("|")
.map((cell) => cell.trim());
const headings = cells(lines[at] ?? "");
/** @type {string[][]} */
const rows = [];
for (let i = at + 2; i < lines.length && (lines[i] ?? "").startsWith("|"); i += 1) rows.push(cells(lines[i] ?? ""));
const yearColumn = headings.findIndex((heading) => /^year$/i.test(heading));
const years =
yearColumn < 0
? []
: rows.map((row) => Number(row[yearColumn])).filter((year) => Number.isInteger(year) && year > 0);
const first = Math.min(...years);
const last = Math.max(...years);
const temporalCoverage = years.length === 0 ? null : first === last ? String(first) : `${first}/${last}`;
return { variableMeasured: headings, temporalCoverage, rows: rows.length };
}
/** Google clips near these; the build refuses longer ones rather than ship a clipped result. */
export const SEO_TITLE_MAX = 60;
export const DESCRIPTION_MAX = 155;
/** The hubs, and the trail label each gives the pages under it. */
const HUBS = /** @type {const} */ ([
["/research", "Research"],
["/teaching", "Teaching"],
["/software", "Software"],
]);
/**
* The trail above a page: its hub, the page it sits under when it is three deep (the course above its
* FAQ), then the page itself. A hub stands alone.
*
* @param {{ path: string, title: string }} page
* @param {(path: string) => string | undefined} [titleOf] the title of another listed page
* @returns {Array<[string, string]>}
*/
export function contentPageTrail(page, titleOf = () => undefined) {
const hub = HUBS.find(([path]) => page.path.startsWith(`${path}/`));
if (!hub) return [[page.title, page.path]];
const trail = /** @type {Array<[string, string]>} */ ([[hub[1], hub[0]]]);
const parent = page.path.slice(0, page.path.lastIndexOf("/"));
const parentTitle = parent !== hub[0] ? titleOf(parent) : undefined;
if (parentTitle) trail.push([parentTitle, parent]);
trail.push([page.title, page.path]);
return trail;
}
/** The search uid of a page path: a removal needs it to find the page's records. */
export function contentPageSearchUid(/** @type {string} */ path) {
return `page:${path.slice(1).replaceAll("/", ":")}`;
}
/**
* Search inputs in the shape `recordsForPages` takes: one record for the page, one per heading, each
* deep-linking to the heading's own anchor.
*
* @param {Array<{ path: string, title: string, description: string, markdown: string, toc: Array<{ depth: number, id: string, text: string }> }>} pages
* @param {(path: string) => import("./dictionary-entries.mjs").DictionaryEntry | undefined} [entryFor] the
* published dictionary entry of a page path, which leads that page's record
*/
export function contentPageSearchInputs(pages, entryFor = () => undefined) {
return pages.map((page) => {
const { intro, sections } = splitSections(page.markdown, page.toc);
const entry = entryFor(page.path);
return {
url: page.path,
uid: contentPageSearchUid(page.path),
title: page.title,
description: page.description,
intro: entry ? `${dictionaryEntryText(entry)} ${intro}` : intro,
sections: sections.map(({ anchor, title, body }) => ({ anchor, title, body })),
};
});
}