Skip to the content

Capsid menu

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

The functions of content-pages.mjs
FunctionLinesComplexityCoveredCRAPMutation
publishedContentPagePaths94 to 961100%OkCRAP 1Ok100%
<anonymous:95>95 to 952100%OkCRAP 2Ok100%
draftContentPagePaths105 to 1081100%OkCRAP 1Critical0%
<anonymous:107>107 to 1071100%OkCRAP 1Critical0%
contentPageFile137 to 1391100%OkCRAP 1Ok100%
contentPageMarkdownPath146 to 1481100%OkCRAP 1Ok100%
contentPageMarkdownBody158 to 1655100%OkCRAP 5Ok85%
hasPageCard177 to 1791100%OkCRAP 1Ok100%
<anonymous:178>178 to 1782100%OkCRAP 2Ok100%
contentPageCardInput188 to 1951100%OkCRAP 1Watch66.7%
contentPageCardPath204 to 2062100%OkCRAP 2Ok100%
markdownTableFacts216 to 24011100%WatchCRAP 11Watch64.7%
<anonymous:219>219 to 2193100%OkCRAP 3Critical58.3%
cells221 to 2261100%OkCRAP 1Ok83.3%
<anonymous:226>226 to 2261100%OkCRAP 1Ok100%
<anonymous:231>231 to 2311100%OkCRAP 1Critical33.3%
<anonymous:235>235 to 2351100%OkCRAP 1Critical44.4%
<anonymous:235>235 to 2352100%OkCRAP 2No data: no mutants in this code
contentPageTrail261 to 2704100%OkCRAP 4Ok93.8%
<anonymous:261>261 to 2611100%OkCRAP 1No data: changed since the last mutation run
<anonymous:262>262 to 2621100%OkCRAP 1No data: changed since the last mutation run
contentPageSearchUid273 to 2751100%OkCRAP 1Critical0%
contentPageSearchInputs285 to 2981100%OkCRAP 1No data: no mutants in this code
<anonymous:285>285 to 2851100%OkCRAP 1No data: changed since the last mutation run
<anonymous:286>286 to 2972100%OkCRAP 2No data: changed since the last mutation run
<anonymous:295>295 to 2951100%OkCRAP 1No 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 })),
    };
  });
}