@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/LICENSE +15 -0
- package/README.md +97 -0
- package/astro.config.mjs +30 -0
- package/dist/bin.js +1925 -0
- package/package.json +67 -0
- package/theme/components/Block.astro +76 -0
- package/theme/components/Blocks.astro +26 -0
- package/theme/components/Copy.astro +167 -0
- package/theme/components/Mermaid.astro +31 -0
- package/theme/components/Toc.astro +75 -0
- package/theme/components/Wide.astro +77 -0
- package/theme/layouts/Site.astro +74 -0
- package/theme/lib/markdown.ts +115 -0
- package/theme/lib/outline.ts +48 -0
- package/theme/lib/snapshot.ts +49 -0
- package/theme/pages/404.astro +12 -0
- package/theme/pages/index.astro +32 -0
- package/theme/styles/theme.css +543 -0
- package/tsconfig.json +5 -0
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>
|