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
| Function | Lines | Complexity | Covered | CRAP | Mutation |
|---|---|---|---|---|---|
| kindPath | 23 to 25 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| itemPath | 28 to 30 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| kindFromSegment | 38 to 40 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:39> | 39 to 39 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| registryTabs | 50 to 56 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:51> | 51 to 55 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:52> | 52 to 52 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| primerRow | 67 to 103 | 8 | 100% | WatchCRAP 8 | No data: no mutants in this code |
| primerRows | 113 to 116 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:114> | 114 to 114 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:115> | 115 to 115 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| productText | 128 to 133 | 4 | 25% | WatchCRAP 10.8 | No data: no mutants in this code |
| <anonymous:130> | 130 to 130 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:131> | 131 to 131 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| itemKey | 141 to 141 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 145 to 145 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 146 to 146 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| value | 150 to 150 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| label | 151 to 151 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| value | 154 to 154 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 155 to 155 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 156 to 156 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 157 to 157 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 158 to 158 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| mateOf | 170 to 173 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:172> | 172 to 172 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| pairProduct | 183 to 190 | 12 | 100% | WatchCRAP 12 | No data: no mutants in this code |
| protocolsUsing | 199 to 203 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:201> | 201 to 201 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:201> | 201 to 201 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:202> | 202 to 202 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| strainRow | 215 to 236 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| <anonymous:217> | 217 to 217 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:217> | 217 to 217 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:218> | 218 to 218 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:231> | 231 to 231 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| strainRows | 241 to 243 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:242> | 242 to 242 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:242> | 242 to 242 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| itemKey | 251 to 251 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 255 to 255 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 256 to 256 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 257 to 257 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 258 to 258 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 259 to 259 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| protocolsUsingStrain | 271 to 275 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:273> | 273 to 273 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:273> | 273 to 273 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:274> | 274 to 274 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| phageYearsText | 278 to 281 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| strainsMarkdown | 284 to 293 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:286> | 286 to 286 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| strainMarkdown | 302 to 322 | 10 | 100% | WatchCRAP 10 | No data: no mutants in this code |
| <anonymous:317> | 317 to 317 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:319> | 319 to 319 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| hasItemPage | 333 to 335 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| recipePath | 342 to 344 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| reagentUses | 354 to 358 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:356> | 356 to 356 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:356> | 356 to 356 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:357> | 357 to 357 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| reagentRow | 368 to 390 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| reagentRows | 395 to 397 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:396> | 396 to 396 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:396> | 396 to 396 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| reagentLabel | 400 to 403 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| reagentSource | 406 to 408 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| itemKey | 416 to 416 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 420 to 420 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 421 to 421 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 422 to 422 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 423 to 423 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| value | 424 to 424 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:424> | 424 to 424 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| reagentGapText | 430 to 436 | 10 | 100% | WatchCRAP 10 | No data: no mutants in this code |
| reagentsMarkdown | 445 to 456 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| link | 446 to 446 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| uses | 447 to 447 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:447> | 447 to 447 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:448> | 448 to 448 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:449> | 449 to 449 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:449> | 449 to 449 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| reagentItemMarkdown | 464 to 477 | 12 | 8% | CriticalCRAP 125.2 | No data: no mutants in this code |
| <anonymous:474> | 474 to 474 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| equipmentUses | 488 to 492 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:490> | 490 to 490 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:490> | 490 to 490 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:491> | 491 to 491 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| equipmentRow | 501 to 514 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| equipmentRows | 519 to 521 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:520> | 520 to 520 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:520> | 520 to 520 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| itemKey | 529 to 529 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 533 to 533 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 534 to 534 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 535 to 535 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 536 to 536 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:536> | 536 to 536 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| equipmentGapText | 542 to 547 | 5 | 100% | OkCRAP 5 | No data: no mutants in this code |
| equipmentMarkdown | 556 to 566 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| uses | 557 to 557 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:557> | 557 to 557 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:558> | 558 to 558 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:559> | 559 to 559 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:559> | 559 to 559 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| equipmentItemMarkdown | 575 to 583 | 5 | 13% | WatchCRAP 21.8 | No data: no mutants in this code |
| <anonymous:580> | 580 to 580 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| hostWords | 588 to 591 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| kindLabel | 594 to 598 | 3 | 100% | OkCRAP 3 | No data: no mutants in this code |
| summaryOf | 605 to 624 | 11 | 95% | WatchCRAP 11 | No data: no mutants in this code |
| inventoryRows | 633 to 653 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| <anonymous:635> | 635 to 643 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:644> | 644 to 651 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| kindRank | 658 to 661 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| itemKey | 669 to 669 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 673 to 673 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| value | 677 to 677 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| compare | 682 to 682 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| value | 685 to 685 | 1 | 100% | OkCRAP 1 | No data: no mutants in this code |
| cell | 692 to 692 | 2 | 100% | OkCRAP 2 | No data: no mutants in this code |
| inventoryMarkdown | 700 to 703 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| <anonymous:701> | 701 to 701 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| siteText | 711 to 718 | 4 | 13% | WatchCRAP 14.7 | No data: no mutants in this code |
| productSpanText | 725 to 727 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| primersMarkdown | 735 to 745 | 1 | 11% | OkCRAP 1.7 | No data: no mutants in this code |
| <anonymous:737> | 737 to 738 | 2 | 0% | WatchCRAP 6 | No data: no mutants in this code |
| tmNote | 751 to 753 | 1 | 33% | OkCRAP 1.3 | No data: no mutants in this code |
| primerMarkdown | 764 to 808 | 28 | 2% | CriticalCRAP 756.9 | No data: no mutants in this code |
| <anonymous:802> | 802 to 802 | 1 | 0% | OkCRAP 2 | No data: no mutants in this code |
| <anonymous:803> | 803 to 803 | 2 | 0% | WatchCRAP 6 | No data: no mutants in this code |
| <anonymous:805> | 805 to 805 | 1 | 0% | OkCRAP 2 | No 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` : ""}`;
}