Skip to the content

Capsid menu

app/kb/registry/catalog.mjs

742 lines and 131 functions in DrDustinEdwards/dustinedwards-info, at commit 1590ba4 on 2026-10-24. This file on GitHub.

Functions

The functions of catalog.mjs
FunctionLinesComplexityCoveredCRAPMutation
kindPath23 to 252100%OkCRAP 2No data: no mutants in this code
itemPath28 to 301100%OkCRAP 1No data: no mutants in this code
kindFromSegment38 to 402100%OkCRAP 2No data: no mutants in this code
<anonymous:39>39 to 391100%OkCRAP 1No data: no mutants in this code
registryTabs50 to 561100%OkCRAP 1No data: no mutants in this code
<anonymous:51>51 to 553100%OkCRAP 3No data: no mutants in this code
<anonymous:52>52 to 521100%OkCRAP 1No data: no mutants in this code
primerRow67 to 1038100%WatchCRAP 8No data: no mutants in this code
primerRows113 to 1161100%OkCRAP 1No data: no mutants in this code
<anonymous:114>114 to 1141100%OkCRAP 1No data: no mutants in this code
<anonymous:115>115 to 1151100%OkCRAP 1No data: no mutants in this code
productText128 to 133425%WatchCRAP 10.8No data: no mutants in this code
<anonymous:130>130 to 13010%OkCRAP 2No data: no mutants in this code
<anonymous:131>131 to 13110%OkCRAP 2No data: no mutants in this code
itemKey141 to 1411100%OkCRAP 1No data: no mutants in this code
value145 to 1451100%OkCRAP 1No data: no mutants in this code
value146 to 1462100%OkCRAP 2No data: no mutants in this code
value150 to 1501100%OkCRAP 1No data: no mutants in this code
label151 to 1512100%OkCRAP 2No data: no mutants in this code
value154 to 1541100%OkCRAP 1No data: no mutants in this code
value155 to 1551100%OkCRAP 1No data: no mutants in this code
value156 to 1561100%OkCRAP 1No data: no mutants in this code
value157 to 1571100%OkCRAP 1No data: no mutants in this code
value158 to 1581100%OkCRAP 1No data: no mutants in this code
mateOf170 to 1733100%OkCRAP 3No data: no mutants in this code
<anonymous:172>172 to 1723100%OkCRAP 3No data: no mutants in this code
pairProduct183 to 19012100%WatchCRAP 12No data: no mutants in this code
protocolsUsing199 to 2031100%OkCRAP 1No data: no mutants in this code
<anonymous:201>201 to 2011100%OkCRAP 1No data: no mutants in this code
<anonymous:201>201 to 2011100%OkCRAP 1No data: no mutants in this code
<anonymous:202>202 to 2021100%OkCRAP 1No data: no mutants in this code
strainRow215 to 2363100%OkCRAP 3No data: no mutants in this code
<anonymous:217>217 to 2171100%OkCRAP 1No data: no mutants in this code
<anonymous:217>217 to 2172100%OkCRAP 2No data: no mutants in this code
<anonymous:218>218 to 2181100%OkCRAP 1No data: no mutants in this code
<anonymous:231>231 to 2311100%OkCRAP 1No data: no mutants in this code
strainRows241 to 2431100%OkCRAP 1No data: no mutants in this code
<anonymous:242>242 to 2421100%OkCRAP 1No data: no mutants in this code
<anonymous:242>242 to 2421100%OkCRAP 1No data: no mutants in this code
itemKey251 to 2511100%OkCRAP 1No data: no mutants in this code
value255 to 2551100%OkCRAP 1No data: no mutants in this code
value256 to 2561100%OkCRAP 1No data: no mutants in this code
value257 to 2571100%OkCRAP 1No data: no mutants in this code
value258 to 2581100%OkCRAP 1No data: no mutants in this code
value259 to 2591100%OkCRAP 1No data: no mutants in this code
protocolsUsingStrain271 to 2751100%OkCRAP 1No data: no mutants in this code
<anonymous:273>273 to 2732100%OkCRAP 2No data: no mutants in this code
<anonymous:273>273 to 2731100%OkCRAP 1No data: no mutants in this code
<anonymous:274>274 to 2741100%OkCRAP 1No data: no mutants in this code
phageYearsText278 to 2813100%OkCRAP 3No data: no mutants in this code
strainsMarkdown284 to 2931100%OkCRAP 1No data: no mutants in this code
<anonymous:286>286 to 2861100%OkCRAP 1No data: no mutants in this code
strainMarkdown302 to 32210100%WatchCRAP 10No data: no mutants in this code
<anonymous:317>317 to 3171100%OkCRAP 1No data: no mutants in this code
<anonymous:319>319 to 3191100%OkCRAP 1No data: no mutants in this code
hasItemPage333 to 3351100%OkCRAP 1No data: no mutants in this code
recipePath342 to 34410%OkCRAP 2No data: no mutants in this code
reagentUses354 to 3581100%OkCRAP 1No data: no mutants in this code
<anonymous:356>356 to 3562100%OkCRAP 2No data: no mutants in this code
<anonymous:356>356 to 3561100%OkCRAP 1No data: no mutants in this code
<anonymous:357>357 to 3571100%OkCRAP 1No data: no mutants in this code
reagentRow368 to 3902100%OkCRAP 2No data: no mutants in this code
reagentRows395 to 3971100%OkCRAP 1No data: no mutants in this code
<anonymous:396>396 to 3961100%OkCRAP 1No data: no mutants in this code
<anonymous:396>396 to 3961100%OkCRAP 1No data: no mutants in this code
reagentLabel400 to 4032100%OkCRAP 2No data: no mutants in this code
reagentSource406 to 4082100%OkCRAP 2No data: no mutants in this code
itemKey416 to 4161100%OkCRAP 1No data: no mutants in this code
value420 to 4201100%OkCRAP 1No data: no mutants in this code
value421 to 4211100%OkCRAP 1No data: no mutants in this code
value422 to 4221100%OkCRAP 1No data: no mutants in this code
value423 to 4232100%OkCRAP 2No data: no mutants in this code
value424 to 4241100%OkCRAP 1No data: no mutants in this code
<anonymous:424>424 to 4241100%OkCRAP 1No data: no mutants in this code
reagentGapText430 to 43610100%WatchCRAP 10No data: no mutants in this code
reagentsMarkdown445 to 4562100%OkCRAP 2No data: no mutants in this code
link446 to 4463100%OkCRAP 3No data: no mutants in this code
uses447 to 4471100%OkCRAP 1No data: no mutants in this code
<anonymous:447>447 to 4471100%OkCRAP 1No data: no mutants in this code
<anonymous:448>448 to 4481100%OkCRAP 1No data: no mutants in this code
<anonymous:449>449 to 4491100%OkCRAP 1No data: no mutants in this code
<anonymous:449>449 to 4491100%OkCRAP 1No data: no mutants in this code
reagentItemMarkdown464 to 477128%CriticalCRAP 125.2No data: no mutants in this code
<anonymous:474>474 to 47410%OkCRAP 2No data: no mutants in this code
equipmentUses488 to 4921100%OkCRAP 1No data: no mutants in this code
<anonymous:490>490 to 4902100%OkCRAP 2No data: no mutants in this code
<anonymous:490>490 to 4901100%OkCRAP 1No data: no mutants in this code
<anonymous:491>491 to 4911100%OkCRAP 1No data: no mutants in this code
equipmentRow501 to 5141100%OkCRAP 1No data: no mutants in this code
equipmentRows519 to 5211100%OkCRAP 1No data: no mutants in this code
<anonymous:520>520 to 5201100%OkCRAP 1No data: no mutants in this code
<anonymous:520>520 to 5201100%OkCRAP 1No data: no mutants in this code
itemKey529 to 5291100%OkCRAP 1No data: no mutants in this code
value533 to 5331100%OkCRAP 1No data: no mutants in this code
value534 to 5341100%OkCRAP 1No data: no mutants in this code
value535 to 5351100%OkCRAP 1No data: no mutants in this code
value536 to 5361100%OkCRAP 1No data: no mutants in this code
<anonymous:536>536 to 5361100%OkCRAP 1No data: no mutants in this code
equipmentGapText542 to 5475100%OkCRAP 5No data: no mutants in this code
equipmentMarkdown556 to 5662100%OkCRAP 2No data: no mutants in this code
uses557 to 5571100%OkCRAP 1No data: no mutants in this code
<anonymous:557>557 to 5571100%OkCRAP 1No data: no mutants in this code
<anonymous:558>558 to 5581100%OkCRAP 1No data: no mutants in this code
<anonymous:559>559 to 5591100%OkCRAP 1No data: no mutants in this code
<anonymous:559>559 to 5591100%OkCRAP 1No data: no mutants in this code
equipmentItemMarkdown575 to 583513%WatchCRAP 21.8No data: no mutants in this code
<anonymous:580>580 to 58010%OkCRAP 2No data: no mutants in this code
hostWords588 to 5913100%OkCRAP 3No data: no mutants in this code
kindLabel594 to 5983100%OkCRAP 3No data: no mutants in this code
summaryOf605 to 6241195%WatchCRAP 11No data: no mutants in this code
inventoryRows633 to 6531100%OkCRAP 1No data: no mutants in this code
<anonymous:635>635 to 6432100%OkCRAP 2No data: no mutants in this code
<anonymous:644>644 to 6511100%OkCRAP 1No data: no mutants in this code
kindRank658 to 6612100%OkCRAP 2No data: no mutants in this code
itemKey669 to 6691100%OkCRAP 1No data: no mutants in this code
value673 to 6731100%OkCRAP 1No data: no mutants in this code
value677 to 6771100%OkCRAP 1No data: no mutants in this code
compare682 to 6822100%OkCRAP 2No data: no mutants in this code
value685 to 6851100%OkCRAP 1No data: no mutants in this code
cell692 to 6922100%OkCRAP 2No data: no mutants in this code
inventoryMarkdown700 to 703133%OkCRAP 1.3No data: no mutants in this code
<anonymous:701>701 to 70110%OkCRAP 2No data: no mutants in this code
siteText711 to 718413%WatchCRAP 14.7No data: no mutants in this code
productSpanText725 to 727133%OkCRAP 1.3No data: no mutants in this code
primersMarkdown735 to 745111%OkCRAP 1.7No data: no mutants in this code
<anonymous:737>737 to 73820%WatchCRAP 6No data: no mutants in this code
tmNote751 to 753133%OkCRAP 1.3No data: no mutants in this code
primerMarkdown764 to 808282%CriticalCRAP 756.9No data: no mutants in this code
<anonymous:802>802 to 80210%OkCRAP 2No data: no mutants in this code
<anonymous:803>803 to 80320%WatchCRAP 6No data: no mutants in this code
<anonymous:805>805 to 80510%OkCRAP 2No data: no mutants in this code

Imports

It imports app/kb/registry/align.mjs, app/kb/registry/kinds.mjs, app/kb/registry/primer.mjs, app/kb/registry/strain.mjs, app/kb/registry/tm.mjs, app/lib/phages/compile.mjs, app/lib/primers.mjs. Imported by app/components/lab.tsx, app/components/procedure-materials.tsx, app/kb/admin.mjs, app/kb/editor.server.ts, app/kb/procedures/compile.mjs, app/kb/procedures/render.mjs, app/kb/registry/search-inputs.mjs, app/kb/search-bases.mjs, app/lib/site-urls.server.ts, app/routes/lab.kind.tsx, app/routes/lab.tsx, app/routes/lab[.md].ts, app/routes/protocols.tsx.

Source

// How the registry reads on the site (docs/REGISTRY.md): its addresses, the master inventory over every kind and the
// phages, and each kind's own catalog, declared once for Capsomer's catalog so the page, its facets and counts, its
// sort menu and the markdown twin all read one declaration. Pure: the routes hand it the rows they read from D1.
//
// Nothing is typed again here. A primer's length, GC content, reverse complement and Tm come from its sequence, the
// protocols that use it are found by that sequence, and a phage in the inventory is read from the phages table, never
// copied into the registry.

import { defineCatalog } from "capsomer/behaviour/catalog";

import { HOSTS } from "../../lib/phages/compile.mjs";
import { reverseComplement } from "../../lib/primers.mjs";
import { KINDS } from "./kinds.mjs";
import { MAX_MISMATCHES, pairProducts, placePrimer } from "./align.mjs";
import { missingReason, primerFacts, stated } from "./primer.mjs";
import { designationOf } from "./strain.mjs";
import { NEB_TM_CALCULATOR, TM_CONDITIONS, annealingSentence, tmStatement } from "./tm.mjs";

/** The master inventory, and the root every kind's page sits under. */
export const LAB_PATH = "/research/lab";

/** The kind's page, from its plural: /research/lab/primers. @param {string} kind @param {Record<string, import("./kinds.mjs").KindSpec>} [kinds] */
export function kindPath(kind, kinds = KINDS) {
  return `${LAB_PATH}/${kinds[kind]?.plural ?? kind}`;
}

/** An item's page: /research/lab/primers/lco1490. @param {string} kind @param {string} id @param {Record<string, import("./kinds.mjs").KindSpec>} [kinds] */
export function itemPath(kind, id, kinds = KINDS) {
  return `${kindPath(kind, kinds)}/${id}`;
}

/**
 * The kind a URL segment names (its plural), or null.
 *
 * @param {string} segment
 * @param {Readonly<Record<string, import("./kinds.mjs").KindSpec>>} [kinds]
 */
export function kindFromSegment(segment, kinds = KINDS) {
  return Object.keys(kinds).find((kind) => kinds[kind]?.plural === segment) ?? null;
}


/**
 * The library's tabs for the registry: one for each kind that has a published item, linking to the kind's page, in the
 * order the kinds are defined. A kind with no item has no tab, so none points at an empty page.
 *
 * @param {import("./kinds.mjs").RegistryItem[]} items
 * @param {Readonly<Record<string, import("./kinds.mjs").KindSpec>>} [kinds]
 */
export function registryTabs(items, kinds = KINDS) {
  return Object.keys(kinds).flatMap((kind) => {
    const count = items.filter((item) => item.kind === kind).length;
    const plural = kinds[kind]?.plural ?? kind;
    return count === 0 ? [] : [{ id: plural, label: plural.charAt(0).toUpperCase() + plural.slice(1), count, href: kindPath(kind, kinds) }];
  });
}

/* ------------------------------------------------------------------------------------------------ primers */

/**
 * A primer as the pages read it: the record's own fields, the literature as read (and what could not be), and what is
 * computed: from its sequence, its length, GC content, Tm and reverse complement; from its reference, its placement; and by
 * comparison, whether its sequence matches the one the paper prints. Nothing computed is stored.
 *
 * @param {import("./kinds.mjs").RegistryItem} item
 */
export function primerRow(item) {
  const f = /** @type {Record<string, unknown>} */ (item.fields);
  const sequence = stated(f.sequence);
  const reference = stated(f.reference);
  const facts = sequence ? primerFacts(sequence) : { length: null, gcPercent: null, tm: null };
  const printed = stated(f.published_sequence);
  return {
    id: item.id,
    path: itemPath("primer", item.id),
    name: item.name,
    sequence,
    reverseComplement: sequence && /^[ACGT]+$/.test(sequence) ? reverseComplement(sequence) : null,
    direction: stated(f.direction),
    target: stated(f.target),
    set: stated(f.set),
    reference,
    // Where it binds the reference, computed: null when it has no reference or binds nowhere within the mismatch limit.
    placement: sequence && reference ? placePrimer(sequence, reference) : null,
    // What the literature says, as read. Each `...Missing` is the reason it could not be found, shown so it is not hunted for.
    publishedIn: stated(f.published_in),
    publishedInMissing: missingReason(f.published_in),
    publishedDoi: stated(f.published_doi),
    publishedUrl: stated(f.published_url),
    publishedName: stated(f.published_name),
    publishedNameMissing: missingReason(f.published_name),
    publishedSequence: printed,
    publishedSequenceMissing: missingReason(f.published_sequence),
    publishedProduct: stated(f.published_product),
    publishedProductMissing: missingReason(f.published_product),
    // A comparison, never a field: null when the paper's sequence was not read, true when the stored one equals it.
    matchesPublished: printed && sequence ? printed === sequence : null,
    length: facts.length,
    gcPercent: facts.gcPercent,
    tm: facts.tm,
    draft: item.status === "draft",
  };
}

/** @typedef {ReturnType<typeof primerRow>} PrimerRow */

/**
 * The primers as rows with their pair's product computed, which a row alone cannot say: the product needs both primers of a
 * set, placed on their one reference.
 *
 * @param {import("./kinds.mjs").RegistryItem[]} items
 */
export function primerRows(items) {
  const rows = items.filter((item) => item.kind === "primer").map(primerRow);
  return rows.map((row) => ({ ...row, product: pairProduct(row, rows) }));
}

/** @typedef {ReturnType<typeof primerRows>[number]} PrimerListRow */

const DIRECTION_LABELS = /** @type {Record<string, string>} */ ({ forward: "Forward", reverse: "Reverse", probe: "Probe" });

/**
 * One line of a pair's product as the catalog and the twin show it: "281 bp", and "at 2 sites" when the pair binds twice (the
 * two LTRs of a provirus), or each size when the sites differ.
 *
 * @param {{ products: Array<{ length: number }> } | null} pair
 */
export function productText(pair) {
  if (!pair) return null;
  const sizes = [...new Set(pair.products.map((p) => p.length))];
  const text = sizes.map((s) => `${s} bp`).join(" or ");
  return pair.products.length > 1 && sizes.length === 1 ? `${text} at ${pair.products.length} sites` : text;
}

/** The primers catalog: the columns a primer is read by, which are its own and not a protocol's. */
export const PRIMERS = defineCatalog(
  /** @type {import("capsomer/behaviour/catalog").CatalogDefinition<PrimerListRow>} */ ({
    id: "primers",
    noun: ["primer", "primers"],
    basePath: kindPath("primer"),
    itemKey: (p) => p.id,
    defaultSort: "name",
    pageSize: 25,
    fields: [
      { key: "name", label: "Primer", value: (p) => p.name, search: 3, column: { header: "Primer" }, sort: true },
      { key: "sequence", label: "Sequence (5′ to 3′)", value: (p) => p.sequence ?? "", search: 2, column: { header: "Sequence (5′ to 3′)", drop: 1 } },
      {
        key: "direction",
        label: "Direction",
        value: (p) => p.direction,
        facet: { kind: "many", general: true, label: (value) => DIRECTION_LABELS[value] ?? value },
        column: { header: "Direction", drop: 2 },
      },
      { key: "target", label: "Target", value: (p) => p.target, facet: { kind: "many", order: "alpha" }, search: 2, column: { header: "Target", drop: 1 } },
      { key: "set", label: "Set", value: (p) => p.set, search: 1 },
      { key: "length", label: "Length (nt)", value: (p) => p.length, type: "number", sort: true, column: { header: "Length (nt)", align: "end", drop: 2 } },
      { key: "tm", label: "Tm estimate (°C)", value: (p) => p.tm, type: "number", sort: true, column: { header: "Tm estimate (°C)", align: "end", drop: 2 } },
      { key: "product", label: "Product (computed)", value: (p) => productText(p.product), column: { header: "Product (computed)", drop: 2 } },
    ],
  }),
);

/**
 * The primer in the same set that pairs with this one: the other direction, in the same set. Null when it has no set or
 * the set holds no mate.
 *
 * @param {PrimerRow} primer
 * @param {PrimerRow[]} primers
 */
export function mateOf(primer, primers) {
  if (!primer.set) return null;
  return primers.find((p) => p.id !== primer.id && p.set === primer.set && p.direction !== primer.direction) ?? null;
}

/**
 * The product the pair of a primer makes on its reference, computed from the two sequences and the reference they share
 * (align.mjs), so it is stored nowhere and shown on both primers. Null when the primer has no mate, the pair shares no
 * reference, or either primer does not bind it.
 *
 * @param {PrimerRow} primer
 * @param {PrimerRow[]} primers
 */
export function pairProduct(primer, primers) {
  const mate = mateOf(primer, primers);
  if (!mate || !primer.sequence || !mate.sequence || !primer.reference || primer.reference !== mate.reference) return null;
  const forward = primer.direction === "forward" ? primer : mate;
  const reverse = primer.direction === "forward" ? mate : primer;
  if (forward.direction !== "forward" || reverse.direction !== "reverse" || !forward.sequence || !reverse.sequence) return null;
  return pairProducts(forward.sequence, reverse.sequence, primer.reference);
}

/**
 * The protocols that use a primer: each published protocol names its primers by id (and carries the registry's facts for
 * them), so the use is read from the protocols and never listed on the primer.
 *
 * @param {PrimerRow} primer
 * @param {Array<{ path: string, title: string, primers: Array<{ id: string }> }>} protocols
 */
export function protocolsUsing(primer, protocols) {
  return protocols
    .filter((protocol) => protocol.primers.some((p) => p.id === primer.id))
    .map((protocol) => ({ path: protocol.path, title: protocol.title }));
}

/* ------------------------------------------------------------------------------------------------ strains */

/**
 * A strain as the pages read it: the record's own fields, and what is computed from elsewhere: its designation (from its
 * fields), and the phages isolated on it, read from the phages table by the host key that is the strain's own id. Nothing
 * computed is stored, so a phage added to the table is on the strain's page with no edit to the strain.
 *
 * @param {import("./kinds.mjs").RegistryItem} item
 * @param {Array<{ name: string, year: number, host: string | null }>} [phages] every phage, from the phages table
 */
export function strainRow(item, phages = []) {
  const f = /** @type {Record<string, unknown>} */ (item.fields);
  const isolated = phages.filter((phage) => phage.host === item.id).sort((a, b) => a.year - b.year || a.name.localeCompare(b.name, "en", { numeric: true }));
  const years = isolated.map((phage) => phage.year);
  return {
    id: item.id,
    path: itemPath("strain", item.id),
    name: item.name,
    organism: stated(f.organism),
    strain: stated(f.strain),
    designation: designationOf(f),
    collection: stated(f.collection),
    collectionNumber: stated(f.collection_number),
    guideUrl: stated(f.guide_url),
    biosafetyLevel: stated(f.biosafety_level),
    biosafetyLevelMissing: missingReason(f.biosafety_level),
    phages: isolated.map((phage) => ({ name: phage.name, year: phage.year, path: `/research/phages#${phage.name.toLowerCase()}` })),
    firstYear: years.length ? Math.min(...years) : null,
    lastYear: years.length ? Math.max(...years) : null,
    draft: item.status === "draft",
  };
}

/** @typedef {ReturnType<typeof strainRow>} StrainRow */

/** The strains as rows, each with the phages isolated on it. @param {import("./kinds.mjs").RegistryItem[]} items @param {Array<{ name: string, year: number, host: string | null }>} phages */
export function strainRows(items, phages) {
  return items.filter((item) => item.kind === "strain").map((item) => strainRow(item, phages));
}

/** The strains catalog: the columns a strain is read by. */
export const STRAINS = defineCatalog(
  /** @type {import("capsomer/behaviour/catalog").CatalogDefinition<StrainRow>} */ ({
    id: "strains",
    noun: ["strain", "strains"],
    basePath: kindPath("strain"),
    itemKey: (s) => s.id,
    defaultSort: "name",
    pageSize: 25,
    fields: [
      { key: "name", label: "Strain", value: (s) => s.name, search: 3, column: { header: "Strain" }, sort: true },
      { key: "organism", label: "Organism", value: (s) => s.organism, facet: { kind: "many", order: "alpha" }, search: 2, column: { header: "Organism", drop: 1 } },
      { key: "collection", label: "Collection", value: (s) => s.collection, facet: { kind: "many", order: "alpha" }, search: 1, column: { header: "Collection", drop: 2 } },
      { key: "number", label: "Collection number", value: (s) => s.collectionNumber, search: 2, column: { header: "Collection number", drop: 2 } },
      { key: "phages", label: "Phages isolated", value: (s) => s.phages.length, type: "number", sort: true, column: { header: "Phages isolated", align: "end", drop: 1 } },
    ],
  }),
);

/**
 * The protocols that use a strain: each published protocol names its host strains by id, so the use is read from the
 * protocols and never listed on the strain.
 *
 * @param {{ id: string }} strain
 * @param {Array<{ path: string, title: string, hostStrains?: Array<{ id: string }> }>} protocols
 */
export function protocolsUsingStrain(strain, protocols) {
  return protocols
    .filter((protocol) => (protocol.hostStrains ?? []).some((s) => s.id === strain.id))
    .map((protocol) => ({ path: protocol.path, title: protocol.title }));
}

/** The span of years the phages on a strain were isolated in, in words: "2018", or "2018 to 2025". @param {StrainRow} strain */
export function phageYearsText(strain) {
  if (strain.firstYear === null) return null;
  return strain.firstYear === strain.lastYear ? String(strain.firstYear) : `${strain.firstYear} to ${strain.lastYear}`;
}

/** The strains as a markdown table for the twin. @param {StrainRow[]} strains @param {string} origin */
export function strainsMarkdown(strains, origin) {
  const lines = strains.map(
    (s) => `| [${cell(s.name)}](${origin}${s.path}) | ${cell(s.organism)} | ${cell(s.collection)} | ${cell(s.collectionNumber)} | ${s.phages.length} |`,
  );
  return [
    "| Strain | Organism | Collection | Collection number | Phages isolated |",
    "| --- | --- | --- | --- | --- |",
    ...lines,
  ].join("\n");
}

/**
 * One strain as markdown for the twin: the same facts the page states, with a fact nobody has set said with its reason.
 *
 * @param {StrainRow} strain
 * @param {Array<{ path: string, title: string }>} usedBy
 * @param {string} origin
 */
export function strainMarkdown(strain, usedBy, origin) {
  const years = phageYearsText(strain);
  const lines = [
    strain.organism ? `- Organism: ${strain.organism}` : "",
    strain.strain ? `- Strain: ${strain.strain}` : "",
    strain.collection && strain.collectionNumber ? `- Collection: ${strain.collection} ${strain.collectionNumber}` : "",
    strain.guideUrl ? `- SEA-PHAGES Guide page: ${strain.guideUrl}` : "",
    `- Biosafety level: ${strain.biosafetyLevel ?? `not found (${strain.biosafetyLevelMissing ?? "not recorded"})`}`,
    "",
    "## Phages isolated on it",
    "",
    strain.phages.length === 0
      ? "No phage in the lab's table names this host."
      : `${strain.phages.length} phage${strain.phages.length === 1 ? "" : "s"}, found ${years}, counted from the phages table.`,
    "",
    ...strain.phages.map((phage) => `- [${phage.name}](${origin}${phage.path}), ${phage.year}`),
    "",
    ...usedBy.map((protocol) => `- Used in: [${protocol.title}](${origin}${protocol.path})`),
  ];
  return `# ${strain.name}\n\n${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}

/* ------------------------------------------------------------------------------------------------ reagents */

/**
 * Whether a kind has a page for each item. Every kind does today (docs/KNOWLEDGE-BASE.md, A1); a kind that is only ever one table
 * says `itemPages: false`.
 *
 * @param {string} kind
 * @param {Readonly<Record<string, import("./kinds.mjs").KindSpec>>} [kinds]
 */
export function hasItemPage(kind, kinds = KINDS) {
  return kinds[kind]?.itemPages !== false;
}

/**
 * Where a procedure with the recipe profile is: a recipe's address is under /recipes (PROFILE_ROOTS in the procedure validator).
 *
 * @param {string} slug
 */
function recipePath(slug) {
  return `/recipes/${slug}`;
}

/**
 * What the protocols use of a reagent, read from the protocols that name it on one of their materials (`reagent: <id>`): the
 * protocols only, since a protocol's own amounts, stocks and finals are shown in that protocol's table. Nothing is stored on the
 * reagent, so a protocol that names it later is listed with no edit to the record.
 *
 * @param {string} id
 * @param {Array<{ path: string, title: string, materials?: Array<{ reagent?: { id: string } | null }> }>} protocols
 */
export function reagentUses(id, protocols) {
  return protocols
    .filter((protocol) => (protocol.materials ?? []).some((m) => m.reagent?.id === id))
    .map((protocol) => ({ path: protocol.path, title: protocol.title }));
}

/**
 * A reagent as the pages read it: the record's own fields and the protocols that use it, computed from the protocols. A reagent is
 * bought (a supplier and a catalog number, each stated or a gap with its reason) or prepared in the lab (a recipe, which is a
 * procedure with the recipe profile, or a gap with its reason).
 *
 * @param {import("./kinds.mjs").RegistryItem} item
 * @param {Parameters<typeof reagentUses>[1]} [protocols]
 */
export function reagentRow(item, protocols = []) {
  const f = /** @type {Record<string, unknown>} */ (item.fields);
  const prepared = f.prepared_in_lab === true;
  const recipe = stated(f.recipe);
  return {
    id: item.id,
    name: item.name,
    path: itemPath("reagent", item.id),
    abbreviation: stated(f.abbreviation),
    contents: stated(f.contents),
    preparedInLab: prepared,
    supplier: stated(f.supplier),
    supplierMissing: missingReason(f.supplier),
    catalogNumber: stated(f.catalog_number),
    catalogNumberMissing: missingReason(f.catalog_number),
    productUrl: stated(f.product_url),
    recipe,
    recipePath: recipe ? recipePath(recipe) : null,
    recipeMissing: missingReason(f.recipe),
    uses: reagentUses(item.id, protocols),
    draft: item.status === "draft",
  };
}

/** @typedef {ReturnType<typeof reagentRow>} ReagentRow */

/** The reagents as rows, each with the protocols that use it. @param {import("./kinds.mjs").RegistryItem[]} items @param {Parameters<typeof reagentUses>[1]} protocols */
export function reagentRows(items, protocols) {
  return items.filter((item) => item.kind === "reagent").map((item) => reagentRow(item, protocols));
}

/** The name as the table shows it: the abbreviation or what it is made of beside it, where a protocol states one. @param {ReagentRow} r */
export function reagentLabel(r) {
  const more = [r.abbreviation, r.contents].filter(Boolean).join("; ");
  return more ? `${r.name} (${more})` : r.name;
}

/** The "supplier" cell in words: who makes a bought reagent, or that the lab prepares it. @param {ReagentRow} r */
export function reagentSource(r) {
  return r.preparedInLab ? "Prepared in lab" : r.supplier;
}

/** The reagents catalog: the one table, with the columns a reagent is read by. */
export const REAGENTS = defineCatalog(
  /** @type {import("capsomer/behaviour/catalog").CatalogDefinition<ReagentRow>} */ ({
    id: "reagents",
    noun: ["reagent", "reagents"],
    basePath: kindPath("reagent"),
    itemKey: (r) => r.id,
    defaultSort: "name",
    pageSize: 50,
    fields: [
      { key: "name", label: "Reagent", value: (r) => reagentLabel(r), search: 3, column: { header: "Reagent" }, sort: true },
      { key: "supplier", label: "Supplier", value: (r) => reagentSource(r), facet: { kind: "many", order: "alpha" }, search: 1, column: { header: "Supplier", drop: 1 } },
      { key: "number", label: "Catalog number", value: (r) => r.catalogNumber, search: 2, column: { header: "Catalog number", drop: 1 } },
      { key: "link", label: "Link", value: (r) => r.productUrl ?? r.recipePath, column: { header: "Link", drop: 2 } },
      { key: "protocols", label: "Protocols", value: (r) => r.uses.map((u) => u.title).join("; "), search: 1, column: { header: "Protocols", drop: 2 } },
    ],
  }),
);

/** What a reagent's cells say where the record states nothing, with the reason, so a gap is listed and not left blank. @param {ReagentRow} r */
export function reagentGapText(r) {
  const parts = [];
  if (!r.preparedInLab && !r.supplier) parts.push(`supplier: ${r.supplierMissing ?? "not recorded"}`);
  if (!r.preparedInLab && !r.catalogNumber) parts.push(`catalog number: ${r.catalogNumberMissing ?? "not recorded"}`);
  if (r.preparedInLab && !r.recipe) parts.push(`recipe: ${r.recipeMissing ?? "not recorded"}`);
  return parts;
}

/**
 * The reagents as one markdown table for the twin, then every gap with its reason, so a machine reads what a person sees and what
 * is still missing.
 *
 * @param {ReagentRow[]} reagents
 * @param {string} origin
 */
export function reagentsMarkdown(reagents, origin) {
  const link = (/** @type {ReagentRow} */ r) => (r.productUrl ? `[product](${r.productUrl})` : r.recipePath ? `[recipe](${origin}${r.recipePath})` : "");
  const uses = (/** @type {ReagentRow} */ r) => r.uses.map((u) => `[${cell(u.title)}](${origin}${u.path})`).join(", ");
  const lines = reagents.map((r) => `| ${cell(reagentLabel(r))} | ${cell(reagentSource(r))} | ${cell(r.catalogNumber)} | ${link(r)} | ${uses(r)} |`);
  const gaps = reagents.flatMap((r) => reagentGapText(r).map((text) => `- ${r.name}, ${text}`));
  return [
    "| Reagent | Supplier | Catalog number | Link | Protocols |",
    "| --- | --- | --- | --- | --- |",
    ...lines,
    ...(gaps.length > 0 ? ["", "Not found:", "", ...gaps] : []),
  ].join("\n");
}

/**
 * One reagent as markdown for its item twin: the same facts as its page, every gap with its reason, and the protocols that use it.
 *
 * @param {ReagentRow} r
 * @param {string} origin
 */
export function reagentItemMarkdown(r, origin) {
  const lines = [
    r.abbreviation ? `- Abbreviation: ${r.abbreviation}` : "",
    r.contents ? `- Contents: ${r.contents}` : "",
    r.preparedInLab
      ? `- Source: prepared in the lab${r.recipePath ? `, [recipe](${origin}${r.recipePath})` : `; recipe not found (${r.recipeMissing ?? "not recorded"})`}`
      : `- Supplier: ${r.supplier ?? `not found (${r.supplierMissing ?? "not recorded"})`}`,
    r.preparedInLab ? "" : `- Catalog number: ${r.catalogNumber ?? `not found (${r.catalogNumberMissing ?? "not recorded"})`}`,
    r.productUrl ? `- Product page: ${r.productUrl}` : "",
    "",
    ...r.uses.map((u) => `- Used in: [${u.title}](${origin}${u.path})`),
  ];
  return `# ${r.name}\n\n${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}

/* ------------------------------------------------------------------------------------------------ equipment */

/**
 * What the protocols do with an item, read from the protocols that name it in their equipment list (`equipment: <id>`). Nothing is
 * stored on the item, so a protocol that names it later is listed with no edit to the record.
 *
 * @param {string} id
 * @param {Array<{ path: string, title: string, equipment?: Array<{ name: string, item?: { id: string } | null }> }>} protocols
 */
export function equipmentUses(id, protocols) {
  return protocols
    .filter((protocol) => (protocol.equipment ?? []).some((e) => e.item?.id === id))
    .map((protocol) => ({ path: protocol.path, title: protocol.title }));
}

/**
 * An equipment item as the pages read it: the record's own fields, with a maker or rotor nobody has recorded said with its reason,
 * and the protocols that use it, computed from the protocols.
 *
 * @param {import("./kinds.mjs").RegistryItem} item
 * @param {Parameters<typeof equipmentUses>[1]} [protocols]
 */
export function equipmentRow(item, protocols = []) {
  const f = /** @type {Record<string, unknown>} */ (item.fields);
  return {
    id: item.id,
    name: item.name,
    path: itemPath("equipment", item.id),
    manufacturer: stated(f.manufacturer),
    manufacturerMissing: missingReason(f.manufacturer),
    rotor: stated(f.rotor),
    rotorMissing: missingReason(f.rotor),
    uses: equipmentUses(item.id, protocols),
    draft: item.status === "draft",
  };
}

/** @typedef {ReturnType<typeof equipmentRow>} EquipmentRow */

/** The equipment as rows, each with the protocols that use it. @param {import("./kinds.mjs").RegistryItem[]} items @param {Parameters<typeof equipmentUses>[1]} protocols */
export function equipmentRows(items, protocols) {
  return items.filter((item) => item.kind === "equipment").map((item) => equipmentRow(item, protocols));
}

/** The equipment catalog: the one table, with the columns an item is read by. */
export const EQUIPMENT_CATALOG = defineCatalog(
  /** @type {import("capsomer/behaviour/catalog").CatalogDefinition<EquipmentRow>} */ ({
    id: "equipment",
    noun: ["item", "items"],
    basePath: kindPath("equipment"),
    itemKey: (e) => e.id,
    defaultSort: "name",
    pageSize: 50,
    fields: [
      { key: "name", label: "Equipment", value: (e) => e.name, search: 3, column: { header: "Equipment" }, sort: true },
      { key: "manufacturer", label: "Manufacturer", value: (e) => e.manufacturer, facet: { kind: "many", order: "alpha" }, search: 1, column: { header: "Manufacturer", drop: 1 } },
      { key: "rotor", label: "Rotor", value: (e) => e.rotor, search: 1, column: { header: "Rotor", drop: 2 } },
      { key: "protocols", label: "Protocols", value: (e) => e.uses.map((u) => u.title).join("; "), search: 1, column: { header: "Protocols", drop: 1 } },
    ],
  }),
);

/** What an item's cells say where the record states nothing, with the reason. @param {EquipmentRow} e */
export function equipmentGapText(e) {
  const parts = [];
  if (!e.manufacturer) parts.push(`manufacturer: ${e.manufacturerMissing ?? "not recorded"}`);
  if (!e.rotor && e.rotorMissing) parts.push(`rotor: ${e.rotorMissing}`);
  return parts;
}

/**
 * The equipment as one markdown table for the twin, then every gap with its reason, so a machine reads what a person sees and what
 * is still missing.
 *
 * @param {EquipmentRow[]} items
 * @param {string} origin
 */
export function equipmentMarkdown(items, origin) {
  const uses = (/** @type {EquipmentRow} */ e) => e.uses.map((u) => `[${cell(u.title)}](${origin}${u.path})`).join(", ");
  const lines = items.map((e) => `| ${cell(e.name)} | ${cell(e.manufacturer)} | ${cell(e.rotor)} | ${uses(e)} |`);
  const gaps = items.flatMap((e) => equipmentGapText(e).map((text) => `- ${e.name}, ${text}`));
  return [
    "| Equipment | Manufacturer | Rotor | Protocols |",
    "| --- | --- | --- | --- |",
    ...lines,
    ...(gaps.length > 0 ? ["", "Not found:", "", ...gaps] : []),
  ].join("\n");
}

/**
 * One item of equipment as markdown for its item twin: who makes it, a centrifuge's rotor, every gap with its reason, and the
 * protocols that use it.
 *
 * @param {EquipmentRow} e
 * @param {string} origin
 */
export function equipmentItemMarkdown(e, origin) {
  const lines = [
    `- Manufacturer: ${e.manufacturer ?? `not found (${e.manufacturerMissing ?? "not recorded"})`}`,
    e.rotor ? `- Rotor: ${e.rotor}` : e.rotorMissing ? `- Rotor: not found (${e.rotorMissing})` : "",
    "",
    ...e.uses.map((u) => `- Used in: [${u.title}](${origin}${u.path})`),
  ];
  return `# ${e.name}\n\n${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
}

/* ------------------------------------------------------------------------------------------------ inventory */

/** A phage's host as plain words: the table's short form without its emphasis marks. @param {string | null} host */
function hostWords(host) {
  const entry = host ? /** @type {Record<string, { short: string }>} */ (HOSTS)[host] : null;
  return entry ? entry.short.replaceAll("*", "") : null;
}

/** The kind's label in the inventory's words, singular. @param {string} kind */
export function kindLabel(kind) {
  if (kind === "phage") return "Phage";
  const singular = KINDS[kind]?.singular ?? kind;
  return singular.charAt(0).toUpperCase() + singular.slice(1);
}

/**
 * One line about an item, read from its own record: what a reader scanning the inventory wants beside the name.
 *
 * @param {import("./kinds.mjs").RegistryItem} item
 */
function summaryOf(item) {
  if (item.kind === "primer") {
    const row = primerRow(item);
    return [row.direction ? `${DIRECTION_LABELS[row.direction]?.toLowerCase() ?? row.direction} primer` : "primer", row.target, row.length ? `${row.length} nt` : null]
      .filter(Boolean)
      .join(", ");
  }
  if (item.kind === "strain") {
    const f = /** @type {Record<string, unknown>} */ (item.fields);
    return [stated(f.organism), stated(f.collection) && stated(f.collection_number) ? `${stated(f.collection)} ${stated(f.collection_number)}` : null].filter(Boolean).join(", ");
  }
  if (item.kind === "equipment") {
    return [stated(/** @type {Record<string, unknown>} */ (item.fields).manufacturer)].filter(Boolean).join(", ");
  }
  if (item.kind === "reagent") {
    const f = /** @type {Record<string, unknown>} */ (item.fields);
    return [stated(f.abbreviation), stated(f.contents), f.prepared_in_lab === true ? "prepared in lab" : stated(f.supplier)].filter(Boolean).join(", ");
  }
  return "";
}

/**
 * The inventory's rows: every published registry item, and every phage read from the phages table. A phage is its own
 * table's and page's, so its row links to its section on /research/phages and states nothing the table does not.
 *
 * @param {import("./kinds.mjs").RegistryItem[]} items
 * @param {Array<{ name: string, year: number, host: string | null, county: string | null }>} phages
 */
export function inventoryRows(items, phages) {
  return [
    ...items.map((item) => ({
      key: `${item.kind}/${item.id}`,
      kind: item.kind,
      kindLabel: kindLabel(item.kind),
      name: item.name,
      summary: summaryOf(item),
      // A kind that is one table has no page for the item: its row goes to the table.
      path: hasItemPage(item.kind) ? itemPath(item.kind, item.id) : kindPath(item.kind),
    })),
    ...phages.map((phage) => ({
      key: `phage/${phage.name.toLowerCase()}`,
      kind: "phage",
      kindLabel: kindLabel("phage"),
      name: phage.name,
      summary: [`found in ${phage.year}`, hostWords(phage.host), phage.county].filter(Boolean).join(", "),
      path: `/research/phages#${phage.name.toLowerCase()}`,
    })),
  ];
}

/** @typedef {ReturnType<typeof inventoryRows>[number]} InventoryRow */

/** Where a kind sits in the inventory's order: the registry's kinds as defined, then the phages. @param {string} kind */
function kindRank(kind) {
  const at = Object.keys(KINDS).indexOf(kind);
  return at === -1 ? Object.keys(KINDS).length : at;
}

/** The master inventory catalog: every item of every kind, filtered by kind. */
export const INVENTORY = defineCatalog(
  /** @type {import("capsomer/behaviour/catalog").CatalogDefinition<InventoryRow>} */ ({
    id: "lab",
    noun: ["item", "items"],
    basePath: LAB_PATH,
    itemKey: (row) => row.key,
    defaultSort: "kind",
    pageSize: 50,
    fields: [
      { key: "name", label: "Name", value: (row) => row.name, search: 3, column: { header: "Name" }, sort: true },
      {
        key: "kind",
        label: "Kind",
        value: (row) => row.kind,
        facet: { kind: "many", general: true, label: kindLabel },
        column: { header: "Kind", drop: 2 },
        // The registry's kinds in the order they are defined, the phages after them, then by name: the default order
        // reads the lab's own records first and never buries them behind seventy-five phages.
        sort: { compare: (a, b) => kindRank(a.kind) - kindRank(b.kind) || a.name.localeCompare(b.name, "en", { numeric: true }) },
        search: 1,
      },
      { key: "summary", label: "About", value: (row) => row.summary, search: 2, column: { header: "About", drop: 1 } },
    ],
  }),
);

/* ------------------------------------------------------------------------------------------------ markdown twins */

const cell = (/** @type {string | number | null} */ value) => String(value ?? "").replaceAll("|", "\\|");

/**
 * The inventory as a markdown table, for the twin: the facts a person sees.
 *
 * @param {InventoryRow[]} rows
 * @param {string} origin
 */
export function inventoryMarkdown(rows, origin) {
  const lines = rows.map((row) => `| [${cell(row.name)}](${origin}${row.path}) | ${cell(row.kindLabel)} | ${cell(row.summary)} |`);
  return ["| Name | Kind | About |", "| --- | --- | --- |", ...lines].join("\n");
}

/**
 * A site on a reference in words, as the page and the twin state it: its span in the accession's own coordinates, its
 * strand, and its mismatches counted from the primer's 5′ end, so a primer that binds only imperfectly says so.
 *
 * @param {{ strand: "+" | "-", start: number, end: number, mismatches: number, mismatchPositions: number[] }} site
 */
export function siteText(site) {
  const n = site.mismatches;
  const mismatch =
    n === 0
      ? "no mismatches"
      : `${n} mismatch${n === 1 ? "" : "es"} (primer base${n === 1 ? "" : "s"} ${site.mismatchPositions.join(", ")}, counted from its 5′ end)`;
  return `${site.start} to ${site.end}, ${site.strand} strand, ${mismatch}`;
}

/**
 * The span of a product on its reference and the sites that make it, in words.
 *
 * @param {{ length: number, start: number, end: number }} product
 */
export function productSpanText(product) {
  return `${product.length} bp, ${product.start} to ${product.end}`;
}

/**
 * The primers as a markdown table for the twin, with the computed columns beside the stored ones.
 *
 * @param {PrimerListRow[]} primers
 * @param {string} origin
 */
export function primersMarkdown(primers, origin) {
  const lines = primers.map(
    (p) =>
      `| [${cell(p.name)}](${origin}${p.path}) | \`${p.sequence ?? ""}\` | ${cell(p.direction)} | ${cell(p.target)} | ${cell(p.length)} | ${cell(p.tm)} | ${cell(productText(p.product))} |`,
  );
  return [
    "| Primer | Sequence (5′ to 3′) | Direction | Target | Length (nt) | Tm estimate (°C) | Product (computed) |",
    "| --- | --- | --- | --- | --- | --- | --- |",
    ...lines,
  ].join("\n");
}

/**
 * The Tm sentence, the sentence that says it is not an annealing temperature, and the calculator that gives one, in one
 * place for the page and the twin (test/registry-tm.test.mjs: no page presents a computed Tm as the annealing temperature).
 */
export function tmNote() {
  return { statement: tmStatement(), annealing: annealingSentence(), calculator: NEB_TM_CALCULATOR, conditions: TM_CONDITIONS };
}

/**
 * One primer as markdown for the twin: the same facts the page states, with the computed ones computed, and what could not be
 * found said with its reason rather than left out.
 *
 * @param {PrimerRow} primer
 * @param {PrimerRow[]} primers every published primer, for the mate and the pair's product
 * @param {Array<{ path: string, title: string }>} usedBy
 * @param {string} origin
 */
export function primerMarkdown(primer, primers, usedBy, origin) {
  const mate = mateOf(primer, primers);
  const pair = pairProduct(primer, primers);
  const published = primer.publishedIn
    ? `${primer.publishedIn}${primer.publishedDoi ? ` ([doi:${primer.publishedDoi}](https://doi.org/${primer.publishedDoi}))` : primer.publishedUrl ? ` (${primer.publishedUrl})` : ""}`
    : null;
  const lines = [
    primer.sequence ? `- Sequence (5′ to 3′): \`${primer.sequence}\`` : "",
    primer.reverseComplement ? `- Reverse complement (5′ to 3′): \`${primer.reverseComplement}\`` : "",
    primer.direction ? `- Direction: ${primer.direction}` : "",
    primer.target ? `- Target: ${primer.target}` : "",
    primer.set ? `- Set: ${primer.set}` : "",
    mate ? `- Pairs with: [${mate.name}](${origin}${mate.path})` : "",
    primer.length ? `- Length: ${primer.length} nt` : "",
    primer.gcPercent !== null ? `- GC content: ${primer.gcPercent}%` : "",
    primer.tm !== null ? `- Melting temperature estimate (Tm): ${primer.tm} °C` : "",
    `- Annealing temperature: depends on the polymerase; use the [${NEB_TM_CALCULATOR.name}](${NEB_TM_CALCULATOR.url})`,
    "",
    "## In the literature",
    "",
    `- Published in: ${published ?? `not found (${primer.publishedInMissing ?? "not recorded"})`}`,
    `- Name in the paper: ${primer.publishedName ?? `not found (${primer.publishedNameMissing ?? "not recorded"})`}`,
    primer.publishedSequence
      ? `- Sequence in the paper: \`${primer.publishedSequence}\` (${primer.matchesPublished ? "the stored sequence is identical" : "DIFFERS from the stored sequence"})`
      : `- Sequence in the paper: not found (${primer.publishedSequenceMissing ?? "not recorded"})`,
    primer.publishedProduct
      ? `- Product in the paper: ${primer.publishedProduct}`
      : primer.direction === "forward"
        ? `- Product in the paper: not found (${primer.publishedProductMissing ?? "not recorded"})`
        : "",
    "",
    "## On the reference",
    "",
    primer.placement
      ? `- Reference: ${primer.placement.reference.id} (${primer.placement.reference.description}), computed from the sequence`
      : primer.reference
        ? `- Reference: ${primer.reference}; the primer binds it nowhere within ${MAX_MISMATCHES} mismatches`
        : "- Reference: none recorded",
    ...(primer.placement ? primer.placement.sites.map((site) => `- Position: ${siteText(site)}`) : []),
    ...(pair ? pair.products.map((product) => `- Product (computed, with ${mate?.name ?? "its pair"}): ${productSpanText(product)}`) : []),
    "",
    ...usedBy.map((protocol) => `- Used in: [${protocol.title}](${origin}${protocol.path})`),
  ];
  return `# ${primer.name}\n\n${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n${primer.tm !== null ? `\n${tmNote().statement} ${tmNote().annealing}\n` : ""}`;
}