Files
threadcount-community/lib/manual.ts
T
ThreadCount 8c86c988c1 ThreadCount Community edition
Uniform stock management for healthcare linen rooms: the coordinator app, the phone counter and the staff app, for your own server. Built from 2d04e45 on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
2026-09-16 04:04:31 +10:00

237 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import fs from "node:fs";
import path from "node:path";
import { SECTIONS, sectionTitle, type SectionId } from "@/lib/manual-links";
/* The manual: Markdown files under docs/manual/<section>/<slug>.md, read once per server process.
*
* One file serves the website (/docs/...), the app (/app/help/...) and the Community edition,
* which ships the folder. The Markdown is a deliberate subset (docs/manual/AUTHORING.md), parsed
* here into blocks rather than turned into HTML, so the renderer controls every element and no
* page can inject markup. No dependency: the subset is small enough that a parser is shorter than
* the configuration a general one would need. */
export type Inline = string | { t: "code"; v: string } | { t: "b"; c: Inline[] } | { t: "a"; href: string; c: Inline[] };
export type Block =
| { t: "h2"; id: string; n: string; text: string }
| { t: "p"; c: Inline[] }
| { t: "ul"; items: Inline[][] }
| { t: "ol"; items: Inline[][] }
| { t: "table"; head: Inline[][]; rows: Inline[][][] }
| { t: "code"; v: string }
| { t: "callout"; tone: "plain" | "careful"; label: string; c: Inline[] };
export type Heading = { id: string; n: string; text: string };
export type ManualPage = {
section: SectionId;
slug: string;
title: string;
order: number;
summary: string;
screen?: string;
role?: string;
keywords: string[];
blocks: Block[];
headings: Heading[];
words: number;
};
/** The date the manual as a whole was last checked against the code. */
export const MANUAL_REVIEWED = "15 Sep 2026";
const ROOT = path.join(process.cwd(), "docs", "manual");
export const slugify = (s: string) =>
s.toLowerCase().replace(/['`]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "section";
const INLINE = /`([^`]+)`|\*\*(.+?)\*\*|\[([^\]]+)\]\(([^)\s]+)\)/g;
export function inline(s: string): Inline[] {
const out: Inline[] = [];
let last = 0;
for (const m of s.matchAll(INLINE)) {
const at = m.index ?? 0;
if (at > last) out.push(s.slice(last, at));
if (m[1] !== undefined) out.push({ t: "code", v: m[1] });
else if (m[2] !== undefined) out.push({ t: "b", c: inline(m[2]) });
else out.push({ t: "a", href: m[4], c: inline(m[3]) });
last = at + m[0].length;
}
if (last < s.length) out.push(s.slice(last));
return out;
}
export const plain = (c: Inline[]): string =>
c.map((x) => (typeof x === "string" ? x : x.t === "code" ? x.v : plain(x.c))).join("");
/** Split a table row on pipes that are not inside backticks. */
function cells(row: string): string[] {
const r = row.trim().replace(/^\|/, "").replace(/\|$/, "");
const out: string[] = [];
let cur = "", code = false;
for (const ch of r) {
if (ch === "`") code = !code;
if (ch === "|" && !code) { out.push(cur.trim()); cur = ""; } else cur += ch;
}
out.push(cur.trim());
return out;
}
const BULLET = /^\s*[-*] /;
const NUMBER = /^\s*\d+[.)] /;
const starts = (l: string) => l.startsWith("```") || l.startsWith("#") || l.startsWith(">") || l.trim().startsWith("|") || BULLET.test(l) || NUMBER.test(l);
export function parseBody(src: string): Block[] {
const lines = src.replace(/\r/g, "").split("\n");
const blocks: Block[] = [];
const used = new Set<string>();
let n = 0;
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
if (line.startsWith("```")) {
const buf: string[] = [];
i++;
while (i < lines.length && !lines[i].startsWith("```")) buf.push(lines[i++]);
i++;
blocks.push({ t: "code", v: buf.join("\n") });
continue;
}
if (/^#{1,6} /.test(line)) {
const text = line.replace(/^#{1,6} /, "").trim();
if (line.startsWith("## ")) {
n++;
let id = slugify(text);
while (used.has(id)) id += "-2";
used.add(id);
blocks.push({ t: "h2", id, n: String(n).padStart(2, "0"), text });
} else {
// The subset has one heading level. Anything else is kept as a bold line rather than dropped.
blocks.push({ t: "p", c: [{ t: "b", c: inline(text) }] });
}
i++;
continue;
}
if (line.startsWith(">")) {
const buf: string[] = [];
while (i < lines.length && lines[i].startsWith(">")) buf.push(lines[i++].replace(/^>\s?/, ""));
const text = buf.join(" ").trim();
const m = /^\*\*(.+?)\*\*[:.]?\s*(.*)$/.exec(text);
const label = m ? m[1] : "Note";
blocks.push({ t: "callout", tone: /careful|warning|caution|important|before you/i.test(label) ? "careful" : "plain", label, c: inline(m ? m[2] : text) });
continue;
}
if (line.trim().startsWith("|")) {
const rows: string[][] = [];
while (i < lines.length && lines[i].trim().startsWith("|")) {
const r = lines[i++].trim();
if (/^\|[\s:|-]+\|?$/.test(r)) continue;
rows.push(cells(r));
}
const [head = [], ...body] = rows;
blocks.push({ t: "table", head: head.map(inline), rows: body.map((r) => r.map(inline)) });
continue;
}
if (BULLET.test(line) || NUMBER.test(line)) {
const ordered = NUMBER.test(line);
const marker = ordered ? NUMBER : BULLET;
const items: string[] = [];
while (i < lines.length) {
const l = lines[i];
if (marker.test(l)) items.push(l.replace(marker, ""));
else if (items.length && /^\s{2,}\S/.test(l)) items[items.length - 1] += " " + l.trim();
else break;
i++;
}
blocks.push({ t: ordered ? "ol" : "ul", items: items.map(inline) });
continue;
}
const buf: string[] = [];
while (i < lines.length && lines[i].trim() && !starts(lines[i])) buf.push(lines[i++].trim());
if (buf.length) blocks.push({ t: "p", c: inline(buf.join(" ")) });
else i++;
}
return blocks;
}
function frontMatter(src: string): [Record<string, string>, string] {
const m = /^---\n([\s\S]*?)\n---\n?/.exec(src.replace(/\r/g, ""));
if (!m) return [{}, src];
const meta: Record<string, string> = {};
for (const l of m[1].split("\n")) {
const k = /^([a-z]+):\s*(.*)$/.exec(l.trim());
if (k) meta[k[1]] = k[2].replace(/^["']|["']$/g, "").trim();
}
return [meta, src.replace(/\r/g, "").slice(m[0].length)];
}
const blockText = (b: Block): string =>
b.t === "h2" ? b.text : b.t === "code" ? b.v : b.t === "p" || b.t === "callout" ? plain(b.c) : b.t === "table" ? [...b.head, ...b.rows.flat()].map(plain).join(" ") : b.items.map(plain).join(" ");
export function parsePage(section: SectionId, slug: string, src: string): ManualPage {
const [meta, body] = frontMatter(src);
const blocks = parseBody(body);
const words = blocks.map(blockText).join(" ").split(/\s+/).filter(Boolean).length;
return {
section,
slug,
title: meta.title || slug,
order: Number(meta.order) || 99,
summary: meta.summary || "",
screen: meta.screen || undefined,
role: meta.role || undefined,
keywords: (meta.keywords || "").split(",").map((k) => k.trim()).filter(Boolean),
blocks,
headings: blocks.filter((b): b is Extract<Block, { t: "h2" }> => b.t === "h2").map(({ id, n, text }) => ({ id, n, text })),
words,
};
}
let cache: ManualPage[] | null = null;
/** Every page, in reading order: section by section, then by `order`. */
export function manual(): ManualPage[] {
if (cache && process.env.NODE_ENV === "production") return cache;
const pages: ManualPage[] = [];
for (const s of SECTIONS) {
const dir = path.join(ROOT, s.id);
if (!fs.existsSync(dir)) continue;
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith(".md")).sort()) {
pages.push(parsePage(s.id, f.slice(0, -3), fs.readFileSync(path.join(dir, f), "utf8")));
}
}
const at = (id: string) => SECTIONS.findIndex((s) => s.id === id);
pages.sort((a, b) => at(a.section) - at(b.section) || a.order - b.order || a.title.localeCompare(b.title));
cache = pages;
return pages;
}
export const findPage = (section: string, slug: string) => manual().find((p) => p.section === section && p.slug === slug);
export function neighbours(p: ManualPage): { prev?: ManualPage; next?: ManualPage } {
const all = manual();
const i = all.findIndex((x) => x.section === p.section && x.slug === p.slug);
return { prev: all[i - 1], next: all[i + 1] };
}
export type TreeSection = { id: SectionId; title: string; blurb: string; pages: { slug: string; title: string; summary: string }[] };
export function tree(): TreeSection[] {
const all = manual();
return SECTIONS.map((s) => ({ id: s.id, title: s.title, blurb: s.blurb, pages: all.filter((p) => p.section === s.id).map(({ slug, title, summary }) => ({ slug, title, summary })) }))
.filter((s) => s.pages.length > 0);
}
/** What the search box knows about each page: small enough to ship inline with every manual page. */
export type SearchEntry = { h: string; t: string; s: string; sec: string; k: string; hd: { id: string; text: string }[] };
export function searchIndex(): SearchEntry[] {
return manual().map((p) => ({
h: `${p.section}/${p.slug}`,
t: p.title,
s: p.summary,
sec: sectionTitle(p.section),
k: [...p.keywords, p.screen ?? ""].join(" ").toLowerCase(),
hd: p.headings.map(({ id, text }) => ({ id, text })),
}));
}
export const readMinutes = (words: number) => Math.max(1, Math.ceil(words / 220));