@apisurf/canonui 0.1.1 → 0.1.2
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 +17 -11
- package/dist/bin.js +113 -3
- package/package.json +1 -1
- package/theme/components/Copy.astro +42 -29
- package/theme/components/SectionCopy.astro +51 -0
- package/theme/lib/markdown.ts +37 -3
- package/theme/lib/sections.ts +64 -0
- package/theme/pages/index.astro +5 -0
- package/theme/styles/theme.css +50 -0
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
|
-
|
|
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
|
|
64
|
-
|
|
|
65
|
-
| `theme/layouts/Site.astro`
|
|
66
|
-
| `theme/components/Block.astro`
|
|
67
|
-
| `theme/components/Toc.astro`
|
|
68
|
-
| `theme/components/Copy.astro`
|
|
69
|
-
| `theme/
|
|
70
|
-
| `theme/
|
|
71
|
-
| `theme/
|
|
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 = [
|
|
@@ -629,6 +634,99 @@ function run(bin, env, verbose) {
|
|
|
629
634
|
});
|
|
630
635
|
}
|
|
631
636
|
|
|
637
|
+
// theme/lib/markdown.ts
|
|
638
|
+
import { Marked } from "marked";
|
|
639
|
+
var sink = null;
|
|
640
|
+
var slugs = null;
|
|
641
|
+
var anchors = null;
|
|
642
|
+
var prefix = "";
|
|
643
|
+
var marked = new Marked({ gfm: true, breaks: false });
|
|
644
|
+
marked.use({
|
|
645
|
+
renderer: {
|
|
646
|
+
/**
|
|
647
|
+
* Headings carry an id so a section can be linked, and are recorded on the
|
|
648
|
+
* way past so the page can draw its own contents.
|
|
649
|
+
*/
|
|
650
|
+
heading(token) {
|
|
651
|
+
const text = this.parser.parseInline(token.tokens);
|
|
652
|
+
const id = slug(stripTags(text));
|
|
653
|
+
anchors?.set(token, id);
|
|
654
|
+
if (sink && (token.depth === 2 || token.depth === 3)) {
|
|
655
|
+
sink.push({ id, text: stripTags(text), depth: token.depth });
|
|
656
|
+
}
|
|
657
|
+
return `<h${token.depth} id="${id}"><a class="anchor" href="#${id}">${text}</a></h${token.depth}>
|
|
658
|
+
`;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
var cache = /* @__PURE__ */ new Map();
|
|
663
|
+
function renderBlock2(block) {
|
|
664
|
+
const hit = cache.get(block.uid);
|
|
665
|
+
if (hit) return hit;
|
|
666
|
+
sink = [];
|
|
667
|
+
slugs = /* @__PURE__ */ new Map();
|
|
668
|
+
anchors = /* @__PURE__ */ new Map();
|
|
669
|
+
prefix = `b${block.seq}`;
|
|
670
|
+
const tokens = marked.lexer(block.content);
|
|
671
|
+
const html = marked.parser(tokens);
|
|
672
|
+
const rendered = { html, headings: sink, parts: split(tokens, anchors) };
|
|
673
|
+
sink = null;
|
|
674
|
+
slugs = null;
|
|
675
|
+
anchors = null;
|
|
676
|
+
cache.set(block.uid, rendered);
|
|
677
|
+
return rendered;
|
|
678
|
+
}
|
|
679
|
+
function split(tokens, ids) {
|
|
680
|
+
const parts = [{ id: null, depth: null, content: "" }];
|
|
681
|
+
for (const token of tokens) {
|
|
682
|
+
const depth = token.type === "heading" && token.depth <= 2 ? token.depth : null;
|
|
683
|
+
const id = depth ? ids.get(token) : void 0;
|
|
684
|
+
if (depth && id) parts.push({ id, depth, content: "" });
|
|
685
|
+
parts[parts.length - 1].content += token.raw;
|
|
686
|
+
}
|
|
687
|
+
return parts;
|
|
688
|
+
}
|
|
689
|
+
function slug(text) {
|
|
690
|
+
const base = text.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, "-").replace(/^-+|-+$/g, "");
|
|
691
|
+
const stem = `${prefix}-${base || "section"}`;
|
|
692
|
+
const seen = slugs?.get(stem) ?? 0;
|
|
693
|
+
slugs?.set(stem, seen + 1);
|
|
694
|
+
return seen === 0 ? stem : `${stem}-${seen + 1}`;
|
|
695
|
+
}
|
|
696
|
+
function stripTags(html) {
|
|
697
|
+
return html.replace(/<[^>]*>/g, "");
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// theme/lib/sections.ts
|
|
701
|
+
function sections(blocks) {
|
|
702
|
+
const found = [];
|
|
703
|
+
let open2 = [];
|
|
704
|
+
const start = (id, depth, block) => {
|
|
705
|
+
open2 = open2.filter((section2) => section2.depth < depth);
|
|
706
|
+
const section = { id, depth, blocks: [] };
|
|
707
|
+
found.push(section);
|
|
708
|
+
open2.push(section);
|
|
709
|
+
add(block);
|
|
710
|
+
};
|
|
711
|
+
const add = (block) => {
|
|
712
|
+
for (const section of open2) section.blocks.push(block);
|
|
713
|
+
};
|
|
714
|
+
for (const block of blocks) {
|
|
715
|
+
if (block.title) {
|
|
716
|
+
start(`b${block.seq}`, 2, block);
|
|
717
|
+
} else if (block.type !== "markdown") {
|
|
718
|
+
add(block);
|
|
719
|
+
} else {
|
|
720
|
+
for (const part of renderBlock2(block).parts) {
|
|
721
|
+
const piece = { ...block, content: part.content };
|
|
722
|
+
if (part.id && part.depth) start(part.id, part.depth, piece);
|
|
723
|
+
else if (part.content.trim()) add(piece);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
return found;
|
|
728
|
+
}
|
|
729
|
+
|
|
632
730
|
// src/build.ts
|
|
633
731
|
var BUILDS_DIR = "builds";
|
|
634
732
|
async function build(options) {
|
|
@@ -669,6 +767,12 @@ function writeMarkdown(snapshot, out) {
|
|
|
669
767
|
bundleDocument(snapshot, { toc: true, meta: false }),
|
|
670
768
|
"utf8"
|
|
671
769
|
);
|
|
770
|
+
const dir = join(out, "sections");
|
|
771
|
+
rmSync(dir, { recursive: true, force: true });
|
|
772
|
+
mkdirSync2(dir, { recursive: true });
|
|
773
|
+
for (const section of sections(snapshot.blocks)) {
|
|
774
|
+
writeFileSync(join(dir, `${section.id}.md`), bundleSection(section.blocks), "utf8");
|
|
775
|
+
}
|
|
672
776
|
}
|
|
673
777
|
function prepare(options) {
|
|
674
778
|
const db = openRead(options);
|
|
@@ -714,15 +818,15 @@ function report(options, result) {
|
|
|
714
818
|
return;
|
|
715
819
|
}
|
|
716
820
|
const what = result.rendered ? "built" : "wrote snapshot for";
|
|
717
|
-
const
|
|
821
|
+
const slug2 = result.snapshot.document.slug;
|
|
718
822
|
process.stdout.write(
|
|
719
823
|
`
|
|
720
|
-
${what} ${
|
|
824
|
+
${what} ${slug2}
|
|
721
825
|
|
|
722
826
|
blocks ${result.blocks}
|
|
723
827
|
output ${result.out}
|
|
724
828
|
|
|
725
|
-
` + (result.rendered && !options.serving ? ` Serve it: canonui serve ${
|
|
829
|
+
` + (result.rendered && !options.serving ? ` Serve it: canonui serve ${slug2}
|
|
726
830
|
|
|
727
831
|
` : "\n")
|
|
728
832
|
);
|
|
@@ -1132,6 +1236,7 @@ var SITES_HELP = `canonui sites \u2014 what a built site is, and what it needs.
|
|
|
1132
1236
|
The folder
|
|
1133
1237
|
index.html the document: its title, description and every block
|
|
1134
1238
|
index.md the same document as markdown
|
|
1239
|
+
sections/ each top-level section as markdown, named for its anchor
|
|
1135
1240
|
404.html served for anything else
|
|
1136
1241
|
_astro/ the stylesheet, and scripts only where a block needs one
|
|
1137
1242
|
|
|
@@ -1169,6 +1274,11 @@ The markdown twin
|
|
|
1169
1274
|
without the front matter. It is what the copy button under the title puts
|
|
1170
1275
|
on the clipboard, and what curl gets you without one.
|
|
1171
1276
|
|
|
1277
|
+
Each top-level section \u2014 a titled block, or an h1 or h2 at the top of an
|
|
1278
|
+
untitled markdown block, up to the next heading of its rank \u2014 has its own
|
|
1279
|
+
under /sections/, copied by the button that appears when its heading is
|
|
1280
|
+
hovered. An h1 runs on past the h2s under it, so it copies all of them.
|
|
1281
|
+
|
|
1172
1282
|
The button fetches what it copies, so it wants the site served rather than
|
|
1173
1283
|
opened off the disk \u2014 canonui open is enough, and so is any host. Everything
|
|
1174
1284
|
else on the page works either way.
|
package/package.json
CHANGED
|
@@ -11,9 +11,9 @@
|
|
|
11
11
|
* and hydration.
|
|
12
12
|
*/
|
|
13
13
|
interface Props {
|
|
14
|
-
/** The .md the button copies. */
|
|
15
|
-
href
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
162
|
-
|
|
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>
|
package/theme/lib/markdown.ts
CHANGED
|
@@ -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
|
-
|
|
77
|
-
|
|
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
|
+
}
|
package/theme/pages/index.astro
CHANGED
|
@@ -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>
|
package/theme/styles/theme.css
CHANGED
|
@@ -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
|
*
|