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 38e16eb on 2026-09-15. Licensed under the Functional Source License (FSL-1.1-ALv2).
This commit is contained in:
ThreadCount
2026-09-16 07:57:54 +10:00
commit 0910bc32c1
457 changed files with 55952 additions and 0 deletions
+236
View File
@@ -0,0 +1,236 @@
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));