@apisurf/canonui 0.1.2 → 0.1.4

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,20 +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
- 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
59
+ five small scripts: the wide toggle, the copy button, the one that puts it
60
+ under 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/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 |
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, twice under 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 |
73
73
 
74
74
  ### The markdown twin
75
75
 
package/dist/bin.js CHANGED
@@ -118,7 +118,34 @@ function shiftHeadings(markdown, by) {
118
118
  import { dirname, isAbsolute, resolve } from "node:path";
119
119
  import { homedir } from "node:os";
120
120
  import { existsSync, mkdirSync } from "node:fs";
121
- 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
+ }
122
149
 
123
150
  // ../db/src/schema.ts
124
151
  var MIGRATIONS = [
@@ -243,7 +270,7 @@ function openDb(options = {}) {
243
270
  );
244
271
  }
245
272
  if (!options.mustExist) mkdirSync(dirname(file), { recursive: true });
246
- const db = new Database(file);
273
+ const db = openDatabase(file);
247
274
  db.pragma("journal_mode = WAL");
248
275
  db.pragma("synchronous = NORMAL");
249
276
  db.pragma("temp_store = MEMORY");
@@ -1276,8 +1303,9 @@ The markdown twin
1276
1303
 
1277
1304
  Each top-level section \u2014 a titled block, or an h1 or h2 at the top of an
1278
1305
  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.
1306
+ under /sections/, copied by the "Copy section" button under its heading.
1307
+ An h1 runs on past the h2s under it, so it copies all of them. The "Copy
1308
+ heading" button beside it copies the heading's text alone.
1281
1309
 
1282
1310
  The button fetches what it copies, so it wants the site served rather than
1283
1311
  opened off the disk \u2014 canonui open is enough, and so is any host. Everything
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apisurf/canonui",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
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",
@@ -13,7 +13,7 @@
13
13
  interface Props {
14
14
  /** The .md the button copies. Left out when a script fills it in. */
15
15
  href?: string;
16
- /** What it copies, for the resting label — "document", "section". */
16
+ /** What it copies, for the resting label — "document", "section", "heading". */
17
17
  what: string;
18
18
  }
19
19
 
@@ -109,7 +109,14 @@ const { href, what } = Astro.props;
109
109
  }
110
110
  }
111
111
 
112
- async function copy(url: string): Promise<boolean> {
112
+ /** What a button copies: the text it carries, or the .md it points at. */
113
+ function source(button: HTMLButtonElement): Promise<string> | null {
114
+ const { copy: url, copyText: text } = button.dataset;
115
+ if (text !== undefined) return Promise.resolve(text);
116
+ return url ? markdown(url) : null;
117
+ }
118
+
119
+ async function copy(pending: Promise<string>): Promise<boolean> {
113
120
  /*
114
121
  * Handing the clipboard the pending fetch rather than awaiting it first is
115
122
  * what keeps this working in Safari, which grants the write only while the
@@ -117,7 +124,7 @@ const { href, what } = Astro.props;
117
124
  */
118
125
  if (typeof ClipboardItem !== "undefined" && navigator.clipboard?.write) {
119
126
  try {
120
- const blob = markdown(url).then((text) => new Blob([text], { type: "text/plain" }));
127
+ const blob = pending.then((text) => new Blob([text], { type: "text/plain" }));
121
128
  await navigator.clipboard.write([new ClipboardItem({ "text/plain": blob })]);
122
129
  return true;
123
130
  } catch {
@@ -125,7 +132,7 @@ const { href, what } = Astro.props;
125
132
  }
126
133
  }
127
134
 
128
- const text = await markdown(url);
135
+ const text = await pending;
129
136
  try {
130
137
  await navigator.clipboard.writeText(text);
131
138
  return true;
@@ -136,14 +143,14 @@ const { href, what } = Astro.props;
136
143
 
137
144
  /*
138
145
  * 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
146
+ * are put under their headings after this runs, and a handler per button would
140
147
  * miss them.
141
148
  */
142
149
  const timers = new WeakMap<HTMLButtonElement, ReturnType<typeof setTimeout>>();
143
150
 
144
151
  const buttonOf = (event: Event) =>
145
152
  event.target instanceof Element
146
- ? event.target.closest<HTMLButtonElement>("button[data-copy]")
153
+ ? event.target.closest<HTMLButtonElement>("button[data-copy], button[data-copy-text]")
147
154
  : null;
148
155
 
149
156
  // Intent, a moment before the click. By the time it lands the document is
@@ -158,13 +165,13 @@ const { href, what } = Astro.props;
158
165
 
159
166
  document.addEventListener("click", async (event) => {
160
167
  const button = buttonOf(event);
161
- const url = button?.dataset.copy;
162
- if (!button || !url) return;
168
+ const pending = button && source(button);
169
+ if (!button || !pending) return;
163
170
 
164
171
  clearTimeout(timers.get(button));
165
172
  let ok = false;
166
173
  try {
167
- ok = await copy(url);
174
+ ok = await copy(pending);
168
175
  } catch {
169
176
  ok = false;
170
177
  }
@@ -1,11 +1,12 @@
1
1
  ---
2
2
  /**
3
- * A copy button on each top-level section's heading, shown when it is hovered.
3
+ * Two copy buttons under each top-level section's heading: one for the heading
4
+ * alone, one for the whole section as markdown.
4
5
  *
5
6
  * 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.
7
+ * inside a markdown block, drawn by marked as a string — so the buttons are put
8
+ * under them in the browser, from one template, rather than written twice. They
9
+ * are `Copy` either way, so they look and behave like the document's own.
9
10
  */
10
11
  import Copy from "./Copy.astro";
11
12
 
@@ -20,7 +21,10 @@ const { sections } = Astro.props;
20
21
  {
21
22
  sections.length > 0 && (
22
23
  <template data-section-copy={JSON.stringify(sections)}>
23
- <Copy what="section" />
24
+ <p class="section-actions">
25
+ <Copy what="heading" />
26
+ <Copy what="section" />
27
+ </p>
24
28
  </template>
25
29
  )
26
30
  }
@@ -36,16 +40,18 @@ const { sections } = Astro.props;
36
40
  // A titled block's anchor is on the block, and its heading is the title.
37
41
  const target = document.getElementById(id);
38
42
  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;
43
+ const actions = template?.content.firstElementChild?.cloneNode(true);
44
+ if (!heading || !(actions instanceof HTMLElement)) continue;
45
+ const [headingCopy, sectionCopy] = actions.querySelectorAll("button");
46
+ if (!headingCopy || !sectionCopy) continue;
41
47
 
42
48
  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);
49
+ // The heading is already on the page, so it is copied from there.
50
+ headingCopy.dataset.copyText = title;
51
+ headingCopy.setAttribute("aria-label", `Copy the heading “${title}”`);
52
+ sectionCopy.dataset.copy = href;
53
+ sectionCopy.setAttribute("aria-label", `Copy the section “${title}” as markdown`);
48
54
  heading.classList.add("section-heading");
49
- heading.append(button);
55
+ heading.after(actions);
50
56
  }
51
57
  </script>
@@ -437,53 +437,20 @@ pre.mermaid[data-processed="true"] {
437
437
  }
438
438
 
439
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.
440
+ * The same button under each section's heading, twice: one for the heading
441
+ * alone and one for the section it opens. On a line of their own rather than
442
+ * beside the title, so a long title has nothing to run into.
443
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;
444
+ h1.section-heading,
445
+ h2.section-heading {
446
+ margin-bottom: 0.4rem;
470
447
  }
471
448
 
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
- }
449
+ p.section-actions {
450
+ display: flex;
451
+ flex-wrap: wrap;
452
+ gap: 0.4rem;
453
+ margin: 0 0 0.9rem;
487
454
  }
488
455
 
489
456
  /* -----------------------------------------------------------------------------