@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.
package/package.json ADDED
@@ -0,0 +1,67 @@
1
+ {
2
+ "name": "@apisurf/canonui",
3
+ "version": "0.1.1",
4
+ "description": "canonui — build and preview the documents that canon holds: render one into static HTML, or serve it locally.",
5
+ "keywords": [
6
+ "astro",
7
+ "canon",
8
+ "cli",
9
+ "docs",
10
+ "preview",
11
+ "static-site",
12
+ "theme"
13
+ ],
14
+ "homepage": "https://github.com/apisurf/canon/tree/main/packages/ui#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/apisurf/canon/issues"
17
+ },
18
+ "license": "ISC",
19
+ "author": "Luka Vidakovic (https://github.com/apisurfer)",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/apisurf/canon.git",
23
+ "directory": "packages/ui"
24
+ },
25
+ "bin": {
26
+ "canonui": "./dist/bin.js"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "!dist/**/*.map",
31
+ "theme",
32
+ "astro.config.mjs",
33
+ "tsconfig.json"
34
+ ],
35
+ "type": "module",
36
+ "publishConfig": {
37
+ "access": "public",
38
+ "registry": "https://registry.npmjs.org/"
39
+ },
40
+ "dependencies": {
41
+ "astro": "7.2.0",
42
+ "better-sqlite3": "11.7.0",
43
+ "marked": "18.0.9",
44
+ "mermaid": "11.16.1"
45
+ },
46
+ "devDependencies": {
47
+ "@astrojs/check": "0.9.10",
48
+ "@types/better-sqlite3": "7.6.12",
49
+ "@types/node": "22.14.0",
50
+ "tsup": "8.3.5",
51
+ "typescript": "5.6.3",
52
+ "vitest": "4.1.8",
53
+ "@canon/core": "0.0.0",
54
+ "@canon/db": "0.0.0"
55
+ },
56
+ "engines": {
57
+ "node": ">=20"
58
+ },
59
+ "scripts": {
60
+ "build": "tsup",
61
+ "dev": "tsup --watch",
62
+ "typecheck": "tsc -p tsconfig.cli.json --noEmit && astro sync && astro check --minimumSeverity error",
63
+ "test": "vitest run",
64
+ "test:watch": "vitest",
65
+ "lint": "oxlint --deny-warnings"
66
+ }
67
+ }
@@ -0,0 +1,76 @@
1
+ ---
2
+ /**
3
+ * One content block, drawn according to its type.
4
+ *
5
+ * Five types, five renderings, and no fallthrough: an unknown type is drawn as
6
+ * a visible notice rather than silently skipped. A block that does not appear
7
+ * in the output looks exactly like a block that was never written, and that is
8
+ * the wrong thing to be ambiguous about.
9
+ */
10
+ import type { SnapshotBlock } from "@canon/db";
11
+ import { renderBlock } from "../lib/markdown";
12
+
13
+ interface Props {
14
+ block: SnapshotBlock;
15
+ }
16
+
17
+ const { block } = Astro.props;
18
+ const attrs = block.attrs ?? {};
19
+ const anchor = `b${block.seq}`;
20
+ ---
21
+
22
+ <section class:list={["block", `block-${block.type}`]} id={anchor}>
23
+ {
24
+ block.title && (
25
+ <h2 class="block-title">
26
+ <a class="anchor" href={`#${anchor}`}>
27
+ {block.title}
28
+ </a>
29
+ </h2>
30
+ )
31
+ }
32
+
33
+ {
34
+ block.type === "markdown" && (
35
+ <div class="prose" set:html={renderBlock(block).html} />
36
+ )
37
+ }
38
+
39
+ {
40
+ block.type === "text" && (
41
+ <div class="prose">
42
+ {block.content.split(/\n{2,}/).map((paragraph) => (
43
+ <p>{paragraph}</p>
44
+ ))}
45
+ </div>
46
+ )
47
+ }
48
+
49
+ {block.type === "html" && <div class="prose" set:html={block.content} />}
50
+
51
+ {
52
+ block.type === "img" && (
53
+ <figure>
54
+ <img
55
+ src={block.content}
56
+ alt={typeof attrs.alt === "string" ? attrs.alt : ""}
57
+ width={attrs.width as number | string | undefined}
58
+ height={attrs.height as number | string | undefined}
59
+ loading="lazy"
60
+ decoding="async"
61
+ />
62
+ {typeof attrs.caption === "string" && <figcaption>{attrs.caption}</figcaption>}
63
+ </figure>
64
+ )
65
+ }
66
+
67
+ {block.type === "mermaid" && <pre class="mermaid">{block.content}</pre>}
68
+
69
+ {
70
+ !["markdown", "text", "html", "img", "mermaid"].includes(block.type) && (
71
+ <p class="block-unknown">
72
+ Block {block.seq} has type <code>{block.type}</code>, which this theme cannot draw.
73
+ </p>
74
+ )
75
+ }
76
+ </section>
@@ -0,0 +1,26 @@
1
+ ---
2
+ /**
3
+ * A document's blocks, in order, plus whatever they need at the page level.
4
+ *
5
+ * The mermaid runtime is the one thing a block cannot pull in for itself: three
6
+ * diagrams in a document need one initialization, not three.
7
+ */
8
+ import type { SnapshotBlock } from "@canon/db";
9
+ import Block from "./Block.astro";
10
+ import Mermaid from "./Mermaid.astro";
11
+
12
+ interface Props {
13
+ blocks: SnapshotBlock[];
14
+ }
15
+
16
+ const { blocks } = Astro.props;
17
+ const hasMermaid = blocks.some((block) => block.type === "mermaid");
18
+ ---
19
+
20
+ {blocks.map((block) => <Block block={block} />)}
21
+ {
22
+ blocks.length === 0 && (
23
+ <p class="empty">This document has no blocks yet. Add one with <code>canon new block</code>.</p>
24
+ )
25
+ }
26
+ {hasMermaid && <Mermaid />}
@@ -0,0 +1,167 @@
1
+ ---
2
+ /**
3
+ * The control that puts a document on the clipboard as markdown.
4
+ *
5
+ * What it copies is fetched rather than embedded, so the page does not carry
6
+ * the whole document a second time for a button most readers never press. The
7
+ * cost is a request on first click.
8
+ *
9
+ * Both labels ship in the markup and CSS picks the one that matches the state,
10
+ * the way the wide toggle does, so the button never reads wrongly between paint
11
+ * and hydration.
12
+ */
13
+ interface Props {
14
+ /** The .md the button copies. */
15
+ href: string;
16
+ /** What it copies, for the resting label — "document". */
17
+ what: string;
18
+ }
19
+
20
+ const { href, what } = Astro.props;
21
+ ---
22
+
23
+ <button
24
+ type="button"
25
+ class="copy"
26
+ data-copy={href}
27
+ data-state="idle"
28
+ aria-label={`Copy this ${what} as markdown`}
29
+ >
30
+ <svg
31
+ class="copy-icon copy-icon-idle"
32
+ viewBox="0 0 16 16"
33
+ width="12"
34
+ height="12"
35
+ aria-hidden="true"
36
+ fill="none"
37
+ stroke="currentColor"
38
+ stroke-width="1.6"
39
+ stroke-linecap="round"
40
+ stroke-linejoin="round"
41
+ >
42
+ <rect x="5.5" y="5.5" width="8" height="8" rx="1.5"></rect>
43
+ <path d="M10.5 2.5h-8v8"></path>
44
+ </svg>
45
+ <svg
46
+ class="copy-icon copy-icon-done"
47
+ viewBox="0 0 16 16"
48
+ width="12"
49
+ height="12"
50
+ aria-hidden="true"
51
+ fill="none"
52
+ stroke="currentColor"
53
+ stroke-width="1.8"
54
+ stroke-linecap="round"
55
+ stroke-linejoin="round"
56
+ >
57
+ <path d="M3 8.5l3.5 3.5L13 5"></path>
58
+ </svg>
59
+ {/* Announced rather than only drawn: the icon swap is the whole feedback. */}
60
+ <span class="copy-label" aria-live="polite">
61
+ <span class="copy-text copy-text-idle">Copy {what}</span>
62
+ <span class="copy-text copy-text-done">Copied</span>
63
+ <span class="copy-text copy-text-failed">Copy failed</span>
64
+ </span>
65
+ </button>
66
+
67
+ <script>
68
+ /*
69
+ * Astro bundles this once per page however many buttons are on it, so the
70
+ * listener is bound by query rather than by the component instance.
71
+ */
72
+ const RESET_MS = 1600;
73
+
74
+ /*
75
+ * Fetched once per document per page load. A reader who copies twice is
76
+ * checking they got it, not asking for a fresh render of a static file.
77
+ */
78
+ const cache = new Map<string, Promise<string>>();
79
+
80
+ function markdown(url: string): Promise<string> {
81
+ let pending = cache.get(url);
82
+ if (!pending) {
83
+ pending = fetch(url).then((response) => {
84
+ if (!response.ok) throw new Error(`${url}: ${response.status}`);
85
+ return response.text();
86
+ });
87
+ // A failed fetch must not be the answer to every later click.
88
+ pending.catch(() => cache.delete(url));
89
+ cache.set(url, pending);
90
+ }
91
+ return pending;
92
+ }
93
+
94
+ /**
95
+ * The clipboard API needs a secure context, which `canonui serve --host` on a
96
+ * LAN address is not. The fallback is the old selection trick: deprecated,
97
+ * and still working everywhere the modern one does not.
98
+ */
99
+ function legacyCopy(text: string): boolean {
100
+ const field = document.createElement("textarea");
101
+ field.value = text;
102
+ field.setAttribute("readonly", "");
103
+ // Off-screen rather than hidden: the selection has to be real to be copied.
104
+ field.style.cssText = "position:fixed;top:0;left:-9999px;opacity:0";
105
+ document.body.appendChild(field);
106
+ field.select();
107
+ try {
108
+ return document.execCommand("copy");
109
+ } catch {
110
+ return false;
111
+ } finally {
112
+ field.remove();
113
+ }
114
+ }
115
+
116
+ async function copy(url: string): Promise<boolean> {
117
+ /*
118
+ * Handing the clipboard the pending fetch rather than awaiting it first is
119
+ * what keeps this working in Safari, which grants the write only while the
120
+ * click that asked for it is still the task in hand.
121
+ */
122
+ if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
123
+ try {
124
+ const blob = markdown(url).then((text) => new Blob([text], { type: "text/plain" }));
125
+ await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
126
+ return true;
127
+ } catch {
128
+ // Older Chrome and Firefox reject a promise here. They take the text.
129
+ }
130
+ }
131
+
132
+ const text = await markdown(url);
133
+ try {
134
+ await navigator.clipboard.writeText(text);
135
+ return true;
136
+ } catch {
137
+ return legacyCopy(text);
138
+ }
139
+ }
140
+
141
+ for (const button of document.querySelectorAll<HTMLButtonElement>("[data-copy]")) {
142
+ let timer: ReturnType<typeof setTimeout> | undefined;
143
+ const url = button.dataset.copy as string;
144
+
145
+ // Intent, a moment before the click. By the time it lands the document is
146
+ // usually already here, which is the difference between copying instantly
147
+ // and copying after a round trip.
148
+ const warm = () => void markdown(url).catch(() => {});
149
+ button.addEventListener("pointerenter", warm);
150
+ button.addEventListener("focus", warm);
151
+
152
+ button.addEventListener("click", async () => {
153
+ clearTimeout(timer);
154
+ let ok = false;
155
+ try {
156
+ ok = await copy(url);
157
+ } catch {
158
+ ok = false;
159
+ }
160
+
161
+ button.dataset.state = ok ? "done" : "failed";
162
+ timer = setTimeout(() => {
163
+ button.dataset.state = "idle";
164
+ }, RESET_MS);
165
+ });
166
+ }
167
+ </script>
@@ -0,0 +1,31 @@
1
+ ---
2
+ /**
3
+ * Mermaid, rendered in the browser.
4
+ *
5
+ * Included only when the document actually holds a mermaid block, so a plain
6
+ * document never ships the library. The import is bundled by Astro rather than
7
+ * fetched from a CDN: the output has to work from a file server with no network
8
+ * behind it.
9
+ */
10
+ ---
11
+
12
+ <script>
13
+ import mermaid from "mermaid";
14
+
15
+ /* Light, like the rest of the theme, and told the page's ground so the
16
+ diagram sits on paper rather than on a white card. */
17
+ mermaid.initialize({
18
+ startOnLoad: true,
19
+ securityLevel: "strict",
20
+ theme: "default",
21
+ fontFamily: "inherit",
22
+ themeVariables: {
23
+ background: "#fbfaf7",
24
+ primaryColor: "#f2f0ea",
25
+ primaryTextColor: "#24231f",
26
+ primaryBorderColor: "#d6d2c6",
27
+ lineColor: "#8a867d",
28
+ textColor: "#24231f",
29
+ },
30
+ });
31
+ </script>
@@ -0,0 +1,75 @@
1
+ ---
2
+ /**
3
+ * What is on this page, in the rail beside it.
4
+ *
5
+ * Drawn only when there is something to navigate: a page with two sections is
6
+ * already visible in one screen, and a contents list for it is furniture. The
7
+ * highlight follows the reader down the page, which is the whole reason the
8
+ * rail is worth its column — a list of links they have to match against their
9
+ * own scroll position is one they stop reading.
10
+ */
11
+ import type { OutlineEntry } from "../lib/outline";
12
+
13
+ interface Props {
14
+ entries: OutlineEntry[];
15
+ }
16
+
17
+ const { entries } = Astro.props;
18
+ ---
19
+
20
+ {
21
+ entries.length > 2 && (
22
+ <nav class="toc" aria-label="On this page">
23
+ <p class="toc-title">On this page</p>
24
+ <ul class="toc-list">
25
+ {entries.map((entry) => (
26
+ <li class:list={["toc-item", `toc-depth-${entry.depth}`]}>
27
+ <a href={`#${entry.id}`}>{entry.text}</a>
28
+ </li>
29
+ ))}
30
+ </ul>
31
+ </nav>
32
+ )
33
+ }
34
+
35
+ <script>
36
+ /*
37
+ * Mark the heading the reader is at.
38
+ *
39
+ * The observer fires on every heading crossing the viewport; the one to mark
40
+ * is the last one that has crossed the top, which is not what any single
41
+ * entry's own visibility tells you. So the entries are kept and the whole set
42
+ * is re-read on each batch.
43
+ */
44
+ const links = new Map<string, HTMLAnchorElement>();
45
+ for (const link of document.querySelectorAll<HTMLAnchorElement>(".toc-list a")) {
46
+ links.set(decodeURIComponent(link.hash.slice(1)), link);
47
+ }
48
+
49
+ if (links.size > 0) {
50
+ const headings = [...links.keys()]
51
+ .map((id) => document.getElementById(id))
52
+ .filter((el): el is HTMLElement => el !== null);
53
+
54
+ let current: HTMLAnchorElement | null = null;
55
+
56
+ const mark = () => {
57
+ // The last heading whose top is above the reading line, or the first.
58
+ const line = 96;
59
+ let found = headings[0];
60
+ for (const heading of headings) {
61
+ if (heading.getBoundingClientRect().top <= line) found = heading;
62
+ }
63
+
64
+ const link = found ? (links.get(found.id) ?? null) : null;
65
+ if (link === current) return;
66
+ current?.removeAttribute("aria-current");
67
+ link?.setAttribute("aria-current", "true");
68
+ current = link;
69
+ };
70
+
71
+ const observer = new IntersectionObserver(mark, { rootMargin: "-80px 0px 0px 0px" });
72
+ for (const heading of headings) observer.observe(heading);
73
+ mark();
74
+ }
75
+ </script>
@@ -0,0 +1,77 @@
1
+ ---
2
+ /**
3
+ * The control that trades the rail for reading width.
4
+ *
5
+ * The measure is right for prose and wrong for the things a page carries
6
+ * alongside it — a wide table, a diagram, a screenshot. Rather than give each
7
+ * of those its own escape hatch, the whole page has one mode: the rail steps
8
+ * aside, the gutters tighten and the content takes the room. The reader turns
9
+ * it on, and the same control turns it off, from wherever they have scrolled
10
+ * to — which is why it is pinned to the window rather than left at the top of
11
+ * the page they have just read past.
12
+ *
13
+ * Both labels ship in the markup and CSS picks the one that matches the mode.
14
+ * The alternative is a script correcting the label after the fact, and the
15
+ * page would be restored wide while its own button still read "Expand".
16
+ */
17
+ ---
18
+
19
+ <button type="button" class="wide-toggle" data-wide>
20
+ <svg
21
+ class="wide-icon wide-icon-out"
22
+ viewBox="0 0 16 16"
23
+ width="12"
24
+ height="12"
25
+ aria-hidden="true"
26
+ fill="none"
27
+ stroke="currentColor"
28
+ stroke-width="1.6"
29
+ stroke-linecap="round"
30
+ stroke-linejoin="round"
31
+ >
32
+ <path d="M6 2H2v4M14 6V2h-4M2 10v4h4M10 14h4v-4"></path>
33
+ </svg>
34
+ <svg
35
+ class="wide-icon wide-icon-in"
36
+ viewBox="0 0 16 16"
37
+ width="12"
38
+ height="12"
39
+ aria-hidden="true"
40
+ fill="none"
41
+ stroke="currentColor"
42
+ stroke-width="1.6"
43
+ stroke-linecap="round"
44
+ stroke-linejoin="round"
45
+ >
46
+ <path d="M2 6h4V2M14 6h-4V2M2 10h4v4M14 10h-4v4"></path>
47
+ </svg>
48
+ <span class="wide-label wide-label-out">Expand</span>
49
+ <span class="wide-label wide-label-in">Collapse</span>
50
+ </button>
51
+
52
+ <script>
53
+ /*
54
+ * The mode outlives the page. Every link here is a full navigation, so a
55
+ * reader who widened the page to read a table would land back in the measure
56
+ * on the next page and have to say so again. The class itself is set by an
57
+ * inline script in the head — by the time this module runs the page has
58
+ * already been painted, and restoring the mode from here would show the
59
+ * narrow layout first.
60
+ */
61
+ const KEY = "canon:wide";
62
+
63
+ const remember = (wide: boolean) => {
64
+ try {
65
+ localStorage.setItem(KEY, wide ? "1" : "0");
66
+ } catch {
67
+ // Private browsing, a file:// origin with storage off — the mode still
68
+ // works for this page, it just does not follow the reader to the next.
69
+ }
70
+ };
71
+
72
+ for (const button of document.querySelectorAll("[data-wide]")) {
73
+ button.addEventListener("click", () => {
74
+ remember(document.documentElement.classList.toggle("wide"));
75
+ });
76
+ }
77
+ </script>
@@ -0,0 +1,74 @@
1
+ ---
2
+ /**
3
+ * The default theme: one shell, three slots.
4
+ *
5
+ * Everything a different theme would want to replace is a named slot with a
6
+ * default behind it — `aside`, `footer`, and `head` for anything that belongs
7
+ * in the document head. A page that passes nothing gets the defaults.
8
+ *
9
+ * The shell is two columns: the document, and the rail beside it listing what
10
+ * is in it. The rail is the column the reader can do without: `Wide` drops it
11
+ * and lifts the measure, so a document holding a wide table or a diagram can be
12
+ * read at the width of the window and put back afterwards.
13
+ */
14
+ import { snapshot, document, isoDate } from "../lib/snapshot";
15
+ import Wide from "../components/Wide.astro";
16
+ import "../styles/theme.css";
17
+
18
+ interface Props {
19
+ title: string;
20
+ description?: string | null;
21
+ }
22
+
23
+ const { title, description } = Astro.props;
24
+ ---
25
+
26
+ <!doctype html>
27
+ <html lang="en">
28
+ <head>
29
+ <meta charset="utf-8" />
30
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
31
+ <title>{title}</title>
32
+ {description && <meta name="description" content={description} />}
33
+ <meta name="generator" content="canon" />
34
+ {/* Before the first paint, so a reader who left the page wide does not
35
+ watch it start narrow. Inline and blocking for the same reason. */}
36
+ <script is:inline>
37
+ try {
38
+ if (localStorage.getItem("canon:wide") === "1")
39
+ document.documentElement.classList.add("wide");
40
+ } catch {}
41
+ </script>
42
+ <slot name="head" />
43
+ </head>
44
+ <body>
45
+ <a class="skip" href="#content">Skip to content</a>
46
+
47
+ <Wide />
48
+
49
+ <div class="shell">
50
+ <main id="content" class="site-main">
51
+ <slot />
52
+
53
+ <footer class="site-footer">
54
+ <slot name="footer">
55
+ <p>
56
+ Updated{" "}
57
+ <time datetime={new Date(document.updatedAt).toISOString()}>
58
+ {isoDate(document.updatedAt)}
59
+ </time>
60
+ {" · "}Built{" "}
61
+ <time datetime={new Date(snapshot.generatedAt).toISOString()}>
62
+ {isoDate(snapshot.generatedAt)}
63
+ </time>
64
+ </p>
65
+ </slot>
66
+ </footer>
67
+ </main>
68
+
69
+ <div class="site-rail">
70
+ <slot name="aside" />
71
+ </div>
72
+ </div>
73
+ </body>
74
+ </html>