renderPage
One page, Markdown md, as plain, text-file-looking HTML: headings numbered like sections of a text file,
code highlighted, two-column tables as lists of terms, images (IMAGES) in the page. Links to other pages go to their files; a link to
something not on the site is shown as its path if elsewhere says it's a file of the repository, and is a
problem otherwise.
import { esc, hrefFor, IMAGES, LANGS, plain, resolvePath, slug } from "#codebase/tcmts/site"; import type { Highlighter, Page, Rendered } from "#codebase/tcmts/site"; import { Marked } from "marked"; import type { Tokens } from "marked"; export function renderPage( page: Page, pages: Page[], md: string, hl: Highlighter, elsewhere: (src: string) => boolean, live: boolean, codeBrowser = !live, ): Rendered { const bySrc = new Map(pages.map((p) => [p.src, p])); const dir = page.src.slice(0, Math.max(0, page.src.lastIndexOf("/"))); const r: Rendered = { html: "", title: page.label, toc: [], ids: [], links: [], problems: [] }; let h2 = 0; let h3 = 0; const marked = new Marked({ gfm: true, // `{@link tcmts.a.b}` (or `{@linkcode …}`, or with its own text after the name): a definition, by name (tcm // checked it when the page was saved, and it follows renames); on a site with the code browser (`codeBrowser`), its page // there, which isn't the app's (`rel="external"`). // Not in code, where it's written to be read as it is. extensions: [ { name: "tcmLink", level: "inline", start: (src: string) => src.indexOf("{@link"), tokenizer(src: string) { const m = /^\{@link(code|plain)?\s+([\w$.-]+)(?:\s*\|\s*|\s+)?([^}]*)\}/.exec(src); return m ? { type: "tcmLink", raw: m[0], code: m[1] === "code", name: m[2]!, label: m[3]!.trim(), } : undefined; }, renderer(token) { const { code, name, label } = token as unknown as { code: boolean; name: string; label: string; }; const shown = label ? esc(label) : esc(name.slice(name.lastIndexOf(".") + 1)); const inner = code || !label ? `<code>${shown}</code>` : shown; if (!codeBrowser || !name.startsWith("tcmts.")) return inner; const to = `code/tcm/${name.slice("tcmts.".length).replaceAll(".", "/")}.html`; return `<a href="${esc(to)}" rel="external" title="${esc(name)}">${inner}</a>`; }, }, ], renderer: { heading({ tokens, depth, text }: Tokens.Heading) { const inner = this.parser.parseInline(tokens); const id = slug(plain(text)); r.ids.push(id); if (depth === 1) { r.title = plain(text); return `<h1 id="${id}">${page.n ? `${page.n}. ` : ""}${inner}</h1>\n<div class="rule" aria-hidden="true"></div>\n`; } const num = depth === 2 ? `${page.n}.${++h2}` : `${page.n}.${h2}.${++h3}`; if (depth === 2) h3 = 0; if (depth === 2) r.toc.push({ id, text: plain(text), num }); return `<h${depth} id="${id}">${page.n ? `${num} ` : ""}${inner}</h${depth}>\n`; }, link({ href, tokens }: Tokens.Link) { const text = this.parser.parseInline(tokens); if (/^[a-z]+:/i.test(href)) return `<a href="${esc(href)}">${text}</a>`; const [target, anchor] = href.split("#") as [string, string | undefined]; // a link within the docs: to the page it names, or to an anchor on this one const resolved = target ? resolvePath(dir, target) : page.src; const to = bySrc.get(resolved); if (!to) { // a file in the repository but not on the site (source code, AGENTS.md): shown as its path if (elsewhere(resolved)) return `${text} <span class="path">(${esc(resolved)})</span>`; r.problems.push(`link to ${href}: no such page`); return text; } r.links.push({ file: to.file, ...(anchor ? { anchor } : {}), text: href }); return `<a href="${hrefFor(to.file, live)}${anchor ? `#${anchor}` : ""}">${text}</a>`; }, // an image (``, one of IMAGES): in the page, at its width; pressed, it's the // page's target, which shows it full size over the page, with a link (to nothing) that closes it image({ href, text }: Tokens.Image) { const src = resolvePath(dir, href); const svg = IMAGES[src]; if (svg === undefined) { r.problems.push(`image ${href}: no such image (tcmts.site.IMAGES)`); return esc(text); } const id = `image-${slug(src.slice(src.lastIndexOf("/") + 1).replace(/\.svg$/, ""))}`; r.ids.push(id); const url = `data:image/svg+xml,${svg.replace(/[\n%#"&]/g, encodeURIComponent)}`; return ( `<a class="image" id="${id}" href="#${id}" title="Full size"><img src="${url}" alt="${esc(text)}"></a>` + `<a class="image-close" href="#${id}-closed">[close]</a>` ); }, code({ text, lang }: Tokens.Code) { if (lang && LANGS.includes(lang)) return ( hl.codeToHtml(text, { lang, themes: { light: "github-light", dark: "github-dark" }, defaultColor: false, }) + "\n" ); return `<pre${lang ? ` data-lang="${esc(lang)}"` : ""}>${esc(text)}</pre>\n`; }, table(token: Tokens.Table) { const cell = (c: Tokens.TableCell) => this.parser.parseInline(c.tokens); // a two-column table is a list of terms and what they mean: shown the man-page way, the term on its line // and the description indented under it, so long terms (commands with all their flags) wrap freely if (token.header.length === 2) { const rows = token.rows .map((row) => `<dt>${cell(row[0]!)}</dt><dd>${cell(row[1]!)}</dd>`) .join("\n"); return `<dl>\n${rows}\n</dl>\n`; } const head = `<tr>${token.header.map((c) => `<th>${cell(c)}</th>`).join("")}</tr>`; const body = token.rows .map((row) => `<tr>${row.map((c) => `<td>${cell(c)}</td>`).join("")}</tr>`) .join("\n"); return `<div class="table"><table>\n<thead>${head}</thead>\n<tbody>${body}</tbody>\n</table></div>\n`; }, }, }); r.html = marked.parse(md) as string; return r; }
uses
- tcmts.site.Highlighter
- tcmts.site.IMAGES
- tcmts.site.LANGS
- tcmts.site.Page
- tcmts.site.Rendered
- tcmts.site.esc
- tcmts.site.hrefFor
- tcmts.site.plain
- tcmts.site.resolvePath
- tcmts.site.slug