@apisurf/canonui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Markdown, at build time.
3
+ *
4
+ * `marked` rather than Astro's own pipeline: that one renders `.md` files it
5
+ * finds on disk, and every block here is a string that arrived from a database.
6
+ * Rendering happens during the build, so nothing ships to the browser.
7
+ *
8
+ * The output is not sanitized. A block's HTML is written by whoever owns the
9
+ * database — the same person running the build — and stripping their markup
10
+ * would break the `html` block type, which exists precisely to pass markup
11
+ * through. Do not point a build at a database you did not write.
12
+ */
13
+ import { Marked } from "marked";
14
+ import type { SnapshotBlock } from "@canon/db";
15
+
16
+ /** One entry in a page's outline: a heading, and the anchor that reaches it. */
17
+ export interface Heading {
18
+ id: string;
19
+ text: string;
20
+ depth: 2 | 3;
21
+ }
22
+
23
+ /** A rendered block: its markup, and the headings a table of contents can link. */
24
+ export interface RenderedBlock {
25
+ html: string;
26
+ headings: Heading[];
27
+ }
28
+
29
+ /**
30
+ * Where the renderer files headings while a parse is running.
31
+ *
32
+ * `marked.parse` is called synchronously and one block at a time, so a single
33
+ * slot cannot be written by two parses at once. It is the alternative to
34
+ * lexing every block twice — once for the outline and once for the markup —
35
+ * and to the two passes drifting apart on the ids they produce.
36
+ */
37
+ let sink: Heading[] | null = null;
38
+ let slugs: Map<string, number> | null = null;
39
+ let prefix = "";
40
+
41
+ const marked = new Marked({ gfm: true, breaks: false });
42
+
43
+ marked.use({
44
+ renderer: {
45
+ /**
46
+ * Headings carry an id so a section can be linked, and are recorded on the
47
+ * way past so the page can draw its own contents.
48
+ */
49
+ heading(token) {
50
+ const text = this.parser.parseInline(token.tokens);
51
+ const id = slug(stripTags(text));
52
+ if (sink && (token.depth === 2 || token.depth === 3)) {
53
+ sink.push({ id, text: stripTags(text), depth: token.depth });
54
+ }
55
+ return `<h${token.depth} id="${id}"><a class="anchor" href="#${id}">${text}</a></h${token.depth}>\n`;
56
+ },
57
+ },
58
+ });
59
+
60
+ /**
61
+ * Render one block's markdown, and collect the headings inside it.
62
+ *
63
+ * Memoized on the block's uid because the page asks twice: once to build the
64
+ * table of contents, before anything is drawn, and once to draw the block
65
+ * itself. The second call is the same block with the same content.
66
+ */
67
+ const cache = new Map<string, RenderedBlock>();
68
+
69
+ export function renderBlock(block: SnapshotBlock): RenderedBlock {
70
+ const hit = cache.get(block.uid);
71
+ if (hit) return hit;
72
+
73
+ sink = [];
74
+ slugs = new Map();
75
+ prefix = `b${block.seq}`;
76
+ const html = marked.parse(block.content, { async: false });
77
+ const rendered: RenderedBlock = { html, headings: sink };
78
+ sink = null;
79
+ slugs = null;
80
+
81
+ cache.set(block.uid, rendered);
82
+ return rendered;
83
+ }
84
+
85
+ /**
86
+ * A block's headings, without drawing it.
87
+ *
88
+ * Only markdown contributes: an `html` block's headings would need a parser to
89
+ * find, and the other three types have none to find.
90
+ */
91
+ export function headingsOf(block: SnapshotBlock): Heading[] {
92
+ return block.type === "markdown" ? renderBlock(block).headings : [];
93
+ }
94
+
95
+ /**
96
+ * `b2-when-a-retry-fires` — stable across builds, unique within the document.
97
+ *
98
+ * The block prefix is what keeps two blocks that both open with "Overview"
99
+ * from claiming the same anchor.
100
+ */
101
+ function slug(text: string): string {
102
+ const base = text
103
+ .toLowerCase()
104
+ .replace(/[^\p{L}\p{N}]+/gu, "-")
105
+ .replace(/^-+|-+$/g, "");
106
+ const stem = `${prefix}-${base || "section"}`;
107
+ const seen = slugs?.get(stem) ?? 0;
108
+ slugs?.set(stem, seen + 1);
109
+ return seen === 0 ? stem : `${stem}-${seen + 1}`;
110
+ }
111
+
112
+ /** Heading text for the contents list, which takes text and not markup. */
113
+ function stripTags(html: string): string {
114
+ return html.replace(/<[^>]*>/g, "");
115
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * A document's outline: what a reader can jump to.
3
+ *
4
+ * Two things make a section in a document, and the outline has to show both.
5
+ * A block with a title is a section boundary the author drew explicitly; a
6
+ * markdown heading inside a block is one they drew in prose. Listing only the
7
+ * second would lose the structure the block model exists to express, and
8
+ * listing only the first would stop at four entries on a page with forty
9
+ * headings.
10
+ */
11
+ import type { SnapshotBlock } from "@canon/db";
12
+ import { headingsOf } from "./markdown";
13
+
14
+ export interface OutlineEntry {
15
+ id: string;
16
+ text: string;
17
+ /** 1 for a section, 2 for something inside it. Nesting, not heading level. */
18
+ depth: 1 | 2;
19
+ }
20
+
21
+ /**
22
+ * Build it.
23
+ *
24
+ * A titled block owns the headings under it, so they indent; an untitled
25
+ * block's own h2s are the sections. Either way the reader sees one list whose
26
+ * indentation matches what the document looks like.
27
+ */
28
+ export function outline(blocks: SnapshotBlock[]): OutlineEntry[] {
29
+ const entries: OutlineEntry[] = [];
30
+
31
+ for (const block of blocks) {
32
+ const headings = headingsOf(block);
33
+
34
+ if (block.title) {
35
+ entries.push({ id: `b${block.seq}`, text: block.title, depth: 1 });
36
+ for (const heading of headings) {
37
+ entries.push({ id: heading.id, text: heading.text, depth: 2 });
38
+ }
39
+ continue;
40
+ }
41
+
42
+ for (const heading of headings) {
43
+ entries.push({ id: heading.id, text: heading.text, depth: heading.depth === 2 ? 1 : 2 });
44
+ }
45
+ }
46
+
47
+ return entries;
48
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The snapshot this build renders.
3
+ *
4
+ * Read once, at module load, from the path in $CANON_SITE_DATA. The theme never
5
+ * opens the database — it is handed a plain JSON document, which is what keeps
6
+ * SQLite out of this package and Astro out of the CLI's dependency tree.
7
+ *
8
+ * The shape is `DocumentSnapshot` from @canon/db, brought in as a type-only
9
+ * import. It disappears from the output, which is what lets the same JSON be
10
+ * rendered by something that is not this theme at all.
11
+ */
12
+ import { readFileSync } from "node:fs";
13
+ import type { DocumentSnapshot, SnapshotBlock } from "@canon/db";
14
+
15
+ /** The snapshot shape this theme renders. The CLI and the theme ship as a pair. */
16
+ const SNAPSHOT_VERSION = 7;
17
+
18
+ function load(): DocumentSnapshot {
19
+ const path = process.env.CANON_SITE_DATA;
20
+ if (!path) {
21
+ throw new Error(
22
+ "canonui: CANON_SITE_DATA is not set. The theme renders a snapshot, and `canonui build` is what writes one.",
23
+ );
24
+ }
25
+
26
+ const parsed = JSON.parse(readFileSync(path, "utf8")) as DocumentSnapshot;
27
+ if (parsed.version !== SNAPSHOT_VERSION) {
28
+ throw new Error(
29
+ `canonui: snapshot version ${parsed.version} is not one this theme understands ` +
30
+ `(${SNAPSHOT_VERSION}). Upgrade @apisurf/canonui, or canon, so the two match.`,
31
+ );
32
+ }
33
+ return parsed;
34
+ }
35
+
36
+ export const snapshot: DocumentSnapshot = load();
37
+ export const document = snapshot.document;
38
+ /** In the order `canon mv block` put them. */
39
+ export const blocks: SnapshotBlock[] = snapshot.blocks;
40
+
41
+ /** `2026-08-25` — the same date on every machine that builds this site. */
42
+ export function isoDate(epochMs: number): string {
43
+ return new Date(epochMs).toISOString().slice(0, 10);
44
+ }
45
+
46
+ /** Href for the document's markdown twin, honouring the configured base path. */
47
+ export function mdHref(base: string): string {
48
+ return `${base.endsWith("/") ? base : `${base}/`}index.md`;
49
+ }
@@ -0,0 +1,12 @@
1
+ ---
2
+ import Site from "../layouts/Site.astro";
3
+ import { document } from "../lib/snapshot";
4
+
5
+ const base = import.meta.env.BASE_URL;
6
+ ---
7
+
8
+ <Site title={`Not found · ${document.title}`}>
9
+ <h1>Not found</h1>
10
+ <p>There is nothing at this address.</p>
11
+ <p><a href={base}>Back to {document.title}</a></p>
12
+ </Site>
@@ -0,0 +1,32 @@
1
+ ---
2
+ /**
3
+ * The document, at the root of the site.
4
+ *
5
+ * One document builds into one page: its title, its description, then every
6
+ * block in order, with the contents rail beside it.
7
+ */
8
+ import Site from "../layouts/Site.astro";
9
+ import Blocks from "../components/Blocks.astro";
10
+ import Copy from "../components/Copy.astro";
11
+ import Toc from "../components/Toc.astro";
12
+ import { outline } from "../lib/outline";
13
+ import { blocks, document, mdHref } from "../lib/snapshot";
14
+
15
+ const base = import.meta.env.BASE_URL;
16
+ ---
17
+
18
+ <Site title={document.title} description={document.description}>
19
+ <Toc slot="aside" entries={outline(blocks)} />
20
+
21
+ <article>
22
+ <header class="page-header">
23
+ <h1>{document.title}</h1>
24
+ {document.description && <p class="lede">{document.description}</p>}
25
+ <p class="page-actions">
26
+ <Copy href={mdHref(base)} what="document" />
27
+ </p>
28
+ </header>
29
+
30
+ <Blocks blocks={blocks} />
31
+ </article>
32
+ </Site>