@apisurf/canonui 0.1.1 → 0.1.3

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/README.md CHANGED
@@ -56,19 +56,20 @@ one stylesheet of plain CSS with tokens on `:root`.
56
56
  The page is two columns: the document — title, description, a copy button, then
57
57
  every block in order — and a rail listing its titled blocks and the headings
58
58
  inside markdown blocks. There is no framework, and the client-side runtime is
59
- four small scripts: the wide toggle, the copy button, the rail's scroll
60
- tracking, and mermaid when the document holds a diagram — bundled into the site
59
+ five small scripts: the wide toggle, the copy button, the one that puts it on
60
+ each section's heading, the rail's scroll tracking, and mermaid when the document holds a diagram — bundled into the site
61
61
  rather than fetched from a CDN, so it works offline.
62
62
 
63
- | File | What it is |
64
- | ------------------------------ | ----------------------------------------------- |
65
- | `theme/layouts/Site.astro` | The shell, and every slot a theme would replace |
66
- | `theme/components/Block.astro` | One block, drawn according to its type |
67
- | `theme/components/Toc.astro` | The contents rail |
68
- | `theme/components/Copy.astro` | The button that copies the document as markdown |
69
- | `theme/pages/index.astro` | The document, at `/` |
70
- | `theme/styles/theme.css` | The whole of the styling |
71
- | `theme/lib/snapshot.ts` | The snapshot, read once at build time |
63
+ | File | What it is |
64
+ | ------------------------------------ | ----------------------------------------------- |
65
+ | `theme/layouts/Site.astro` | The shell, and every slot a theme would replace |
66
+ | `theme/components/Block.astro` | One block, drawn according to its type |
67
+ | `theme/components/Toc.astro` | The contents rail |
68
+ | `theme/components/Copy.astro` | The button that copies the document as markdown |
69
+ | `theme/components/SectionCopy.astro` | The same button, on each section's heading |
70
+ | `theme/pages/index.astro` | The document, at `/` |
71
+ | `theme/styles/theme.css` | The whole of the styling |
72
+ | `theme/lib/snapshot.ts` | The snapshot, read once at build time |
72
73
 
73
74
  ### The markdown twin
74
75
 
@@ -81,6 +82,11 @@ copy button fetches it rather than embedding it, so the button wants the site
81
82
  served rather than opened off the disk, where `fetch` has no origin to work
82
83
  from; everything else on the page works either way.
83
84
 
85
+ Each top-level section is written the same way, to `/sections/<anchor>.md`,
86
+ with `bundleSection`. Where the sections fall is `theme/lib/sections.ts`, which
87
+ the command imports so that the files it writes and the headings the page puts
88
+ buttons on are cut by the same parse.
89
+
84
90
  The theme sits under `theme/` rather than Astro's default `src/`, which this
85
91
  package uses for the command itself. `astro.config.mjs` points `srcDir` at it.
86
92
 
package/dist/bin.js CHANGED
@@ -32,6 +32,11 @@ function bundleDocument(snapshot, options = {}) {
32
32
  return `${parts.join("\n\n")}
33
33
  `;
34
34
  }
35
+ function bundleSection(blocks) {
36
+ const level = blocks[0]?.title ? 2 : 1;
37
+ return `${blocks.map((block) => renderBlock(block, block.title ? 2 : level)).join("\n\n")}
38
+ `;
39
+ }
35
40
  function frontMatter(snapshot) {
36
41
  const d = snapshot.document;
37
42
  const lines = [
@@ -113,7 +118,34 @@ function shiftHeadings(markdown, by) {
113
118
  import { dirname, isAbsolute, resolve } from "node:path";
114
119
  import { homedir } from "node:os";
115
120
  import { existsSync, mkdirSync } from "node:fs";
116
- import Database from "better-sqlite3";
121
+
122
+ // ../db/src/driver.ts
123
+ import Database from "libsql";
124
+ function openDatabase(file) {
125
+ const db = new Database(file);
126
+ const prepare2 = db.prepare.bind(db);
127
+ db.prepare = (sql) => {
128
+ const stmt = prepare2(sql);
129
+ const get = stmt.get.bind(stmt);
130
+ const all = stmt.all.bind(stmt);
131
+ const run2 = stmt.run.bind(stmt);
132
+ stmt.get = (...params) => withoutMetadata(get(params));
133
+ stmt.all = (...params) => all(params);
134
+ stmt.run = (...params) => run2(params);
135
+ return stmt;
136
+ };
137
+ db.pragma = (source, options) => {
138
+ const stmt = db.prepare(`PRAGMA ${source}`);
139
+ if (!options?.simple) return stmt.all();
140
+ const row = stmt.get();
141
+ return row && Object.values(row)[0];
142
+ };
143
+ return db;
144
+ }
145
+ function withoutMetadata(row) {
146
+ if (row && typeof row === "object") Reflect.deleteProperty(row, "_metadata");
147
+ return row;
148
+ }
117
149
 
118
150
  // ../db/src/schema.ts
119
151
  var MIGRATIONS = [
@@ -238,7 +270,7 @@ function openDb(options = {}) {
238
270
  );
239
271
  }
240
272
  if (!options.mustExist) mkdirSync(dirname(file), { recursive: true });
241
- const db = new Database(file);
273
+ const db = openDatabase(file);
242
274
  db.pragma("journal_mode = WAL");
243
275
  db.pragma("synchronous = NORMAL");
244
276
  db.pragma("temp_store = MEMORY");
@@ -629,6 +661,99 @@ function run(bin, env, verbose) {
629
661
  });
630
662
  }
631
663
 
664
+ // theme/lib/markdown.ts
665
+ import { Marked } from "marked";
666
+ var sink = null;
667
+ var slugs = null;
668
+ var anchors = null;
669
+ var prefix = "";
670
+ var marked = new Marked({ gfm: true, breaks: false });
671
+ marked.use({
672
+ renderer: {
673
+ /**
674
+ * Headings carry an id so a section can be linked, and are recorded on the
675
+ * way past so the page can draw its own contents.
676
+ */
677
+ heading(token) {
678
+ const text = this.parser.parseInline(token.tokens);
679
+ const id = slug(stripTags(text));
680
+ anchors?.set(token, id);
681
+ if (sink && (token.depth === 2 || token.depth === 3)) {
682
+ sink.push({ id, text: stripTags(text), depth: token.depth });
683
+ }
684
+ return `<h${token.depth} id="${id}"><a class="anchor" href="#${id}">${text}</a></h${token.depth}>
685
+ `;
686
+ }
687
+ }
688
+ });
689
+ var cache = /* @__PURE__ */ new Map();
690
+ function renderBlock2(block) {
691
+ const hit = cache.get(block.uid);
692
+ if (hit) return hit;
693
+ sink = [];
694
+ slugs = /* @__PURE__ */ new Map();
695
+ anchors = /* @__PURE__ */ new Map();
696
+ prefix = `b${block.seq}`;
697
+ const tokens = marked.lexer(block.content);
698
+ const html = marked.parser(tokens);
699
+ const rendered = { html, headings: sink, parts: split(tokens, anchors) };
700
+ sink = null;
701
+ slugs = null;
702
+ anchors = null;
703
+ cache.set(block.uid, rendered);
704
+ return rendered;
705
+ }
706
+ function split(tokens, ids) {
707
+ const parts = [{ id: null, depth: null, content: "" }];
708
+ for (const token of tokens) {
709
+ const depth = token.type === "heading" && token.depth <= 2 ? token.depth : null;
710
+ const id = depth ? ids.get(token) : void 0;
711
+ if (depth && id) parts.push({ id, depth, content: "" });
712
+ parts[parts.length - 1].content += token.raw;
713
+ }
714
+ return parts;
715
+ }
716
+ function slug(text) {
717
+ const base = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
718
+ const stem = `${prefix}-${base || "section"}`;
719
+ const seen = slugs?.get(stem) ?? 0;
720
+ slugs?.set(stem, seen + 1);
721
+ return seen === 0 ? stem : `${stem}-${seen + 1}`;
722
+ }
723
+ function stripTags(html) {
724
+ return html.replace(/<[^>]*>/g, "");
725
+ }
726
+
727
+ // theme/lib/sections.ts
728
+ function sections(blocks) {
729
+ const found = [];
730
+ let open2 = [];
731
+ const start = (id, depth, block) => {
732
+ open2 = open2.filter((section2) => section2.depth < depth);
733
+ const section = { id, depth, blocks: [] };
734
+ found.push(section);
735
+ open2.push(section);
736
+ add(block);
737
+ };
738
+ const add = (block) => {
739
+ for (const section of open2) section.blocks.push(block);
740
+ };
741
+ for (const block of blocks) {
742
+ if (block.title) {
743
+ start(`b${block.seq}`, 2, block);
744
+ } else if (block.type !== "markdown") {
745
+ add(block);
746
+ } else {
747
+ for (const part of renderBlock2(block).parts) {
748
+ const piece = { ...block, content: part.content };
749
+ if (part.id && part.depth) start(part.id, part.depth, piece);
750
+ else if (part.content.trim()) add(piece);
751
+ }
752
+ }
753
+ }
754
+ return found;
755
+ }
756
+
632
757
  // src/build.ts
633
758
  var BUILDS_DIR = "builds";
634
759
  async function build(options) {
@@ -669,6 +794,12 @@ function writeMarkdown(snapshot, out) {
669
794
  bundleDocument(snapshot, { toc: true, meta: false }),
670
795
  "utf8"
671
796
  );
797
+ const dir = join(out, "sections");
798
+ rmSync(dir, { recursive: true, force: true });
799
+ mkdirSync2(dir, { recursive: true });
800
+ for (const section of sections(snapshot.blocks)) {
801
+ writeFileSync(join(dir, `${section.id}.md`), bundleSection(section.blocks), "utf8");
802
+ }
672
803
  }
673
804
  function prepare(options) {
674
805
  const db = openRead(options);
@@ -714,15 +845,15 @@ function report(options, result) {
714
845
  return;
715
846
  }
716
847
  const what = result.rendered ? "built" : "wrote snapshot for";
717
- const slug = result.snapshot.document.slug;
848
+ const slug2 = result.snapshot.document.slug;
718
849
  process.stdout.write(
719
850
  `
720
- ${what} ${slug}
851
+ ${what} ${slug2}
721
852
 
722
853
  blocks ${result.blocks}
723
854
  output ${result.out}
724
855
 
725
- ` + (result.rendered && !options.serving ? ` Serve it: canonui serve ${slug}
856
+ ` + (result.rendered && !options.serving ? ` Serve it: canonui serve ${slug2}
726
857
 
727
858
  ` : "\n")
728
859
  );
@@ -1132,6 +1263,7 @@ var SITES_HELP = `canonui sites \u2014 what a built site is, and what it needs.
1132
1263
  The folder
1133
1264
  index.html the document: its title, description and every block
1134
1265
  index.md the same document as markdown
1266
+ sections/ each top-level section as markdown, named for its anchor
1135
1267
  404.html served for anything else
1136
1268
  _astro/ the stylesheet, and scripts only where a block needs one
1137
1269
 
@@ -1169,6 +1301,11 @@ The markdown twin
1169
1301
  without the front matter. It is what the copy button under the title puts
1170
1302
  on the clipboard, and what curl gets you without one.
1171
1303
 
1304
+ Each top-level section \u2014 a titled block, or an h1 or h2 at the top of an
1305
+ untitled markdown block, up to the next heading of its rank \u2014 has its own
1306
+ under /sections/, copied by the button that appears when its heading is
1307
+ hovered. An h1 runs on past the h2s under it, so it copies all of them.
1308
+
1172
1309
  The button fetches what it copies, so it wants the site served rather than
1173
1310
  opened off the disk \u2014 canonui open is enough, and so is any host. Everything
1174
1311
  else on the page works either way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/canonui",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "canonui — build and preview the documents that canon holds: render one into static HTML, or serve it locally.",
5
5
  "keywords": [
6
6
  "astro",
@@ -39,13 +39,12 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "astro": "7.2.0",
42
- "better-sqlite3": "11.7.0",
42
+ "libsql": "0.5.29",
43
43
  "marked": "18.0.9",
44
44
  "mermaid": "11.16.1"
45
45
  },
46
46
  "devDependencies": {
47
47
  "@astrojs/check": "0.9.10",
48
- "@types/better-sqlite3": "7.6.12",
49
48
  "@types/node": "22.14.0",
50
49
  "tsup": "8.3.5",
51
50
  "typescript": "5.6.3",
@@ -11,9 +11,9 @@
11
11
  * and hydration.
12
12
  */
13
13
  interface Props {
14
- /** The .md the button copies. */
15
- href: string;
16
- /** What it copies, for the resting label — "document". */
14
+ /** The .md the button copies. Left out when a script fills it in. */
15
+ href?: string;
16
+ /** What it copies, for the resting label — "document", "section". */
17
17
  what: string;
18
18
  }
19
19
 
@@ -65,10 +65,6 @@ const { href, what } = Astro.props;
65
65
  </button>
66
66
 
67
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
68
  const RESET_MS = 1600;
73
69
 
74
70
  /*
@@ -138,30 +134,47 @@ const { href, what } = Astro.props;
138
134
  }
139
135
  }
140
136
 
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;
137
+ /*
138
+ * Bound once on the document rather than on each button: the section buttons
139
+ * are put on their headings after this runs, and a handler per button would
140
+ * miss them.
141
+ */
142
+ const timers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
144
143
 
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);
144
+ const buttonOf = (event: Event) =>
145
+ event.target instanceof Element
146
+ ? event.target.closest<HTMLButtonElement>("button[data-copy]")
147
+ : null;
151
148
 
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
- }
149
+ // Intent, a moment before the click. By the time it lands the document is
150
+ // usually already here, which is the difference between copying instantly
151
+ // and copying after a round trip.
152
+ const warm = (event: Event) => {
153
+ const url = buttonOf(event)?.dataset.copy;
154
+ if (url) void markdown(url).catch(() => {});
155
+ };
156
+ document.addEventListener("pointerover", warm);
157
+ document.addEventListener("focusin", warm);
160
158
 
161
- button.dataset.state = ok ? "done" : "failed";
162
- timer = setTimeout(() => {
159
+ document.addEventListener("click", async (event) => {
160
+ const button = buttonOf(event);
161
+ const url = button?.dataset.copy;
162
+ if (!button || !url) return;
163
+
164
+ clearTimeout(timers.get(button));
165
+ let ok = false;
166
+ try {
167
+ ok = await copy(url);
168
+ } catch {
169
+ ok = false;
170
+ }
171
+
172
+ button.dataset.state = ok ? "done" : "failed";
173
+ timers.set(
174
+ button,
175
+ setTimeout(() => {
163
176
  button.dataset.state = "idle";
164
- }, RESET_MS);
165
- });
166
- }
177
+ }, RESET_MS),
178
+ );
179
+ });
167
180
  </script>
@@ -0,0 +1,51 @@
1
+ ---
2
+ /**
3
+ * A copy button on each top-level section's heading, shown when it is hovered.
4
+ *
5
+ * The headings come from two places — a block's title, drawn here, and an h2
6
+ * inside a markdown block, drawn by marked as a string — so the button is put
7
+ * on them in the browser, from one template, rather than written twice. It is
8
+ * `Copy` either way, so it looks and behaves like the document's own.
9
+ */
10
+ import Copy from "./Copy.astro";
11
+
12
+ interface Props {
13
+ /** Each section's anchor, and the .md its button copies. */
14
+ sections: { id: string; href: string }[];
15
+ }
16
+
17
+ const { sections } = Astro.props;
18
+ ---
19
+
20
+ {
21
+ sections.length > 0 && (
22
+ <template data-section-copy={JSON.stringify(sections)}>
23
+ <Copy what="section" />
24
+ </template>
25
+ )
26
+ }
27
+
28
+ <script>
29
+ const template = document.querySelector<HTMLTemplateElement>("template[data-section-copy]");
30
+ const sections = JSON.parse(template?.dataset.sectionCopy ?? "[]") as {
31
+ id: string;
32
+ href: string;
33
+ }[];
34
+
35
+ for (const { id, href } of sections) {
36
+ // A titled block's anchor is on the block, and its heading is the title.
37
+ const target = document.getElementById(id);
38
+ const heading = target?.matches("h1, h2") ? target : target?.querySelector(":scope > h2");
39
+ const button = template?.content.firstElementChild?.cloneNode(true);
40
+ if (!heading || !(button instanceof HTMLButtonElement)) continue;
41
+
42
+ const title = heading.textContent?.trim() ?? "";
43
+ button.dataset.copy = href;
44
+ button.classList.add("section-copy");
45
+ button.setAttribute("aria-label", `Copy the section “${title}” as markdown`);
46
+ // Named for its own text, not the button that now sits inside it.
47
+ heading.setAttribute("aria-label", title);
48
+ heading.classList.add("section-heading");
49
+ heading.append(button);
50
+ }
51
+ </script>
@@ -10,7 +10,7 @@
10
10
  * would break the `html` block type, which exists precisely to pass markup
11
11
  * through. Do not point a build at a database you did not write.
12
12
  */
13
- import { Marked } from "marked";
13
+ import { Marked, type Token } from "marked";
14
14
  import type { SnapshotBlock } from "@canon/db";
15
15
 
16
16
  /** One entry in a page's outline: a heading, and the anchor that reaches it. */
@@ -20,10 +20,21 @@ export interface Heading {
20
20
  depth: 2 | 3;
21
21
  }
22
22
 
23
+ /**
24
+ * A block's source, cut where it opens a section: the text before its first
25
+ * top-level h1 or h2, then one part per such heading, with its anchor and rank.
26
+ */
27
+ export interface BlockPart {
28
+ id: string | null;
29
+ depth: 1 | 2 | null;
30
+ content: string;
31
+ }
32
+
23
33
  /** A rendered block: its markup, and the headings a table of contents can link. */
24
34
  export interface RenderedBlock {
25
35
  html: string;
26
36
  headings: Heading[];
37
+ parts: BlockPart[];
27
38
  }
28
39
 
29
40
  /**
@@ -36,6 +47,8 @@ export interface RenderedBlock {
36
47
  */
37
48
  let sink: Heading[] | null = null;
38
49
  let slugs: Map<string, number> | null = null;
50
+ /** The anchor each heading was given, keyed by the token it was drawn from. */
51
+ let anchors: Map<Token, string> | null = null;
39
52
  let prefix = "";
40
53
 
41
54
  const marked = new Marked({ gfm: true, breaks: false });
@@ -49,6 +62,7 @@ marked.use({
49
62
  heading(token) {
50
63
  const text = this.parser.parseInline(token.tokens);
51
64
  const id = slug(stripTags(text));
65
+ anchors?.set(token, id);
52
66
  if (sink && (token.depth === 2 || token.depth === 3)) {
53
67
  sink.push({ id, text: stripTags(text), depth: token.depth });
54
68
  }
@@ -72,11 +86,16 @@ export function renderBlock(block: SnapshotBlock): RenderedBlock {
72
86
 
73
87
  sink = [];
74
88
  slugs = new Map();
89
+ anchors = new Map();
75
90
  prefix = `b${block.seq}`;
76
- const html = marked.parse(block.content, { async: false });
77
- const rendered: RenderedBlock = { html, headings: sink };
91
+ // Lexed and parsed as two steps so the top-level tokens are in hand to cut
92
+ // the source at. Their raw text, end to end, is the source.
93
+ const tokens = marked.lexer(block.content);
94
+ const html = marked.parser(tokens);
95
+ const rendered: RenderedBlock = { html, headings: sink, parts: split(tokens, anchors) };
78
96
  sink = null;
79
97
  slugs = null;
98
+ anchors = null;
80
99
 
81
100
  cache.set(block.uid, rendered);
82
101
  return rendered;
@@ -92,6 +111,21 @@ export function headingsOf(block: SnapshotBlock): Heading[] {
92
111
  return block.type === "markdown" ? renderBlock(block).headings : [];
93
112
  }
94
113
 
114
+ /**
115
+ * Cut at the h1s and h2s that stand at the top of the block. One inside a
116
+ * blockquote or a list is part of what surrounds it, not the start of a section.
117
+ */
118
+ function split(tokens: Token[], ids: Map<Token, string>): BlockPart[] {
119
+ const parts: BlockPart[] = [{ id: null, depth: null, content: "" }];
120
+ for (const token of tokens) {
121
+ const depth = token.type === "heading" && token.depth <= 2 ? (token.depth as 1 | 2) : null;
122
+ const id = depth ? ids.get(token) : undefined;
123
+ if (depth && id) parts.push({ id, depth, content: "" });
124
+ (parts[parts.length - 1] as BlockPart).content += token.raw;
125
+ }
126
+ return parts;
127
+ }
128
+
95
129
  /**
96
130
  * `b2-when-a-retry-fires` — stable across builds, unique within the document.
97
131
  *
@@ -0,0 +1,64 @@
1
+ /**
2
+ * A document's top-level sections: each heading at the top of the content,
3
+ * and everything under it up to the next heading of the same rank or higher.
4
+ *
5
+ * Three things open one — a titled block, whose title is drawn as an h2, and an
6
+ * h1 or an h2 standing at the top of an untitled markdown block. Sections nest
7
+ * the way markdown's do: an h1 runs on past the h2s under it, so the h1 copies
8
+ * all of them and each h2 copies itself. Headings inside a titled block are
9
+ * subsections of its title, as the outline and the markdown twin both have it.
10
+ *
11
+ * Shared by the page, which puts a copy button on each section's heading, and
12
+ * by `canonui build`, which writes the markdown that button copies. Both cut
13
+ * where the renderer cut, so a button cannot end up pointing at the wrong
14
+ * stretch of the document.
15
+ */
16
+ import type { SnapshotBlock } from "@canon/db";
17
+ import { renderBlock } from "./markdown";
18
+
19
+ export interface Section {
20
+ /** The heading's anchor, which also names the section's markdown file. */
21
+ id: string;
22
+ /** 1 for an h1, 2 for an h2 or a block title. */
23
+ depth: 1 | 2;
24
+ /** Its blocks, the first and last cut down to the part inside the section. */
25
+ blocks: SnapshotBlock[];
26
+ }
27
+
28
+ export function sections(blocks: readonly SnapshotBlock[]): Section[] {
29
+ const found: Section[] = [];
30
+ // The sections still running, outermost first.
31
+ let open: Section[] = [];
32
+
33
+ const start = (id: string, depth: 1 | 2, block: SnapshotBlock) => {
34
+ open = open.filter((section) => section.depth < depth);
35
+ const section = { id, depth, blocks: [] };
36
+ found.push(section);
37
+ open.push(section);
38
+ add(block);
39
+ };
40
+ const add = (block: SnapshotBlock) => {
41
+ for (const section of open) section.blocks.push(block);
42
+ };
43
+
44
+ for (const block of blocks) {
45
+ if (block.title) {
46
+ start(`b${block.seq}`, 2, block);
47
+ } else if (block.type !== "markdown") {
48
+ add(block);
49
+ } else {
50
+ for (const part of renderBlock(block).parts) {
51
+ const piece = { ...block, content: part.content };
52
+ if (part.id && part.depth) start(part.id, part.depth, piece);
53
+ else if (part.content.trim()) add(piece);
54
+ }
55
+ }
56
+ }
57
+
58
+ return found;
59
+ }
60
+
61
+ /** Href for a section's markdown, honouring the configured base path. */
62
+ export function sectionHref(base: string, id: string): string {
63
+ return `${base.endsWith("/") ? base : `${base}/`}sections/${encodeURIComponent(id)}.md`;
64
+ }
@@ -8,11 +8,14 @@
8
8
  import Site from "../layouts/Site.astro";
9
9
  import Blocks from "../components/Blocks.astro";
10
10
  import Copy from "../components/Copy.astro";
11
+ import SectionCopy from "../components/SectionCopy.astro";
11
12
  import Toc from "../components/Toc.astro";
12
13
  import { outline } from "../lib/outline";
14
+ import { sectionHref, sections } from "../lib/sections";
13
15
  import { blocks, document, mdHref } from "../lib/snapshot";
14
16
 
15
17
  const base = import.meta.env.BASE_URL;
18
+ const copyable = sections(blocks).map(({ id }) => ({ id, href: sectionHref(base, id) }));
16
19
  ---
17
20
 
18
21
  <Site title={document.title} description={document.description}>
@@ -29,4 +32,6 @@ const base = import.meta.env.BASE_URL;
29
32
 
30
33
  <Blocks blocks={blocks} />
31
34
  </article>
35
+
36
+ <SectionCopy sections={copyable} />
32
37
  </Site>
@@ -436,6 +436,56 @@ pre.mermaid[data-processed="true"] {
436
436
  margin: 0;
437
437
  }
438
438
 
439
+ /*
440
+ * The same button on each section's heading, kept out of sight until the
441
+ * heading is hovered. Pinned to the right edge rather than set after the title,
442
+ * so turning it on does not rewrap the heading.
443
+ */
444
+ .section-heading {
445
+ position: relative;
446
+ /* One line of the heading — 1.35rem at a leading of 1.3 — so the button can
447
+ centre on the first line rather than on all of them when the title wraps. */
448
+ --section-line: 1.755rem;
449
+ }
450
+
451
+ /* 2rem at 1.25. */
452
+ h1.section-heading {
453
+ --section-line: 2.5rem;
454
+ }
455
+
456
+ .section-copy {
457
+ position: absolute;
458
+ right: 0;
459
+ top: calc(var(--section-line) / 2);
460
+ transform: translateY(-50%);
461
+ background: var(--bg);
462
+ font-weight: 400;
463
+ letter-spacing: normal;
464
+ opacity: 0;
465
+ transition:
466
+ opacity 120ms ease,
467
+ color 120ms ease,
468
+ border-color 120ms ease,
469
+ background-color 120ms ease;
470
+ }
471
+
472
+ .section-heading:hover > .section-copy,
473
+ .section-copy:focus-visible,
474
+ .section-copy:not([data-state="idle"]) {
475
+ opacity: 1;
476
+ }
477
+
478
+ /* Nothing to hover on a touchscreen, so there it sits after the title instead. */
479
+ @media (hover: none) {
480
+ .section-copy {
481
+ position: static;
482
+ transform: none;
483
+ margin-left: 0.6rem;
484
+ vertical-align: middle;
485
+ opacity: 1;
486
+ }
487
+ }
488
+
439
489
  /* -----------------------------------------------------------------------------
440
490
  * Narrower screens
441
491
  *