@supersuit/artifacts 0.20.0 → 0.22.0
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/CHANGELOG.md +48 -0
- package/README.md +70 -3
- package/lib/artifacts/checklist.d.ts +53 -0
- package/lib/artifacts/checklist.js +147 -0
- package/lib/artifacts/front-matter.js +6 -3
- package/lib/artifacts/index.d.ts +3 -2
- package/lib/artifacts/index.js +3 -2
- package/lib/artifacts/render.d.ts +11 -1
- package/lib/artifacts/render.js +53 -14
- package/lib/artifacts/widgets.d.ts +20 -1
- package/lib/artifacts/widgets.js +60 -6
- package/lib/routes/artifacts.d.ts +9 -0
- package/lib/routes/artifacts.js +16 -7
- package/lib/routes/state-routes.js +6 -2
- package/lib/widgets/checklist.d.ts +16 -0
- package/lib/widgets/checklist.js +95 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,54 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.22.0
|
|
7
|
+
|
|
8
|
+
**A page can carry a checklist a reader ticks off and comes back to.** A per-person Mac setup
|
|
9
|
+
page that remembers its ticks could not be published: the renderer escapes raw HTML (rightly),
|
|
10
|
+
and a ```` ```checklist ```` fence was refused at publish as a widget not yet drawn, so each prep
|
|
11
|
+
page went out as its own one-off site instead of on the operator's host.
|
|
12
|
+
|
|
13
|
+
- New widget `checklist`: items with stable ids (`{#id}` or the slug of the text, numbered on a
|
|
14
|
+
repeat), indented descriptions drawn as markdown with raw HTML escaped, ticks kept in the
|
|
15
|
+
reader's `localStorage` under `artifact-checklist:<page>:<item>` (storage that throws costs only
|
|
16
|
+
the memory), "n of m done", and 44px rows. Several per page.
|
|
17
|
+
- Optional `send: signed-in | anyone` in the fence: a Send button writes `{ done: [ids] }` to the
|
|
18
|
+
page's own `checklist` slot (shape one, private), which the owner reads through `/responses`.
|
|
19
|
+
The server refuses ids that are not that checklist's. Drawn only on a host with a state store.
|
|
20
|
+
- `sms:` and `tel:` links now work on every page (react-markdown's default dropped them), and open
|
|
21
|
+
without `target="_blank"`. Everything else still goes through the default transform.
|
|
22
|
+
- `ArtifactMarkdown` takes `checklist={{ artifactId, send, owner, accent }}`; the route passes it.
|
|
23
|
+
New exports: `checklistsOf`, `checklistSendIds`, `mergeChecklistState`, `parseChecklistFence`,
|
|
24
|
+
`checkChecklistValue`, `checklistStorageKey`, `CHECKLIST_SLOT`, `artifactUrlTransform`.
|
|
25
|
+
- DETECTOR: publish a page with a ```` ```checklist ```` fence. "the checklist widget is not
|
|
26
|
+
available in this version" means the host is on 0.21.0 or earlier.
|
|
27
|
+
- REMEDY: bump. A page with no checklist renders byte for byte as before (the 0.15.0 golden still
|
|
28
|
+
passes unchanged).
|
|
29
|
+
- Tests: the fence grammar and every refusal with its file line; ids page-wide; the slot a Send
|
|
30
|
+
declares; the component's ids and count; raw HTML in an item and a description stays text; a
|
|
31
|
+
page with no checklist identical with and without the new prop; ticks saved by page and item id
|
|
32
|
+
and back after a remount; blocked storage; Send's body and its sign-in answer; the route taking
|
|
33
|
+
only that checklist's ids and `/responses` reading it; sms/tel links and `javascript:` still
|
|
34
|
+
dead; and the packed fixture drawing and posting one. Each was broken on purpose and went red.
|
|
35
|
+
|
|
36
|
+
## 0.21.0 (2026-10-08)
|
|
37
|
+
|
|
38
|
+
**A page's past versions are for its admins.** Every reader who could open a page could also open
|
|
39
|
+
its History and read every earlier version at `/<id>/v/<n>`, so a passage edited out of a page
|
|
40
|
+
(a name taken out, a figure corrected) stayed readable one click away.
|
|
41
|
+
|
|
42
|
+
- New config `admins` (sign-in emails) and `history: 'admins' | 'everyone'`. The default,
|
|
43
|
+
`'admins'`, shows the History control and serves `/<id>/v/<n>` only to a signed-in reader whose
|
|
44
|
+
email is `ownerEmail` or in `admins`, compared without case. To everyone else the control is
|
|
45
|
+
absent and a past version is not found. "Version N · Updated <minute>" still shows to everyone.
|
|
46
|
+
- DETECTOR: after the bump, a reader who is not an admin no longer sees "History" under a page's
|
|
47
|
+
summary. A host that configures neither `ownerEmail` nor `admins` hides history from everyone.
|
|
48
|
+
- REMEDY: pass `admins` (and `ownerEmail`) for the people who should keep seeing history, or
|
|
49
|
+
`history: 'everyone'` to keep the 0.20.0 behaviour.
|
|
50
|
+
- Tests: signed out, a signed-in non-admin, the owner in another case and a listed admin, a past
|
|
51
|
+
version 404ing for non-admins, nobody configured, and `'everyone'` restoring the old page. The
|
|
52
|
+
earlier version and golden tests pin `history: 'everyone'`, so they still prove 0.20.0's page.
|
|
53
|
+
|
|
6
54
|
## 0.20.0 (2026-10-07)
|
|
7
55
|
|
|
8
56
|
**A host can hear when a confidential page is read.** The reader record answered `GET /reads`
|
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
28
|
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
29
29
|
| `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
|
|
30
|
+
| `checklistsOf`, `parseChecklistFence`, `checklistStorageKey` | The checklist widget's pure parts: the fence grammar, every item's id, where a tick is kept |
|
|
30
31
|
| `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
|
|
31
32
|
| `mintPublisherPass`, `verifyPublisherPass` | The publisher pass: an hour-long stand-in for the publish key, for one person on one host (see below) |
|
|
32
33
|
| `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
|
|
@@ -123,6 +124,17 @@ phone, a side panel on a desk), so opening it moves no text. Each past version i
|
|
|
123
124
|
the same password or confidential door as the page itself. A version that does not exist is a
|
|
124
125
|
404; on a shut page the door comes first, so it never says which versions exist.
|
|
125
126
|
|
|
127
|
+
**Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
|
|
128
|
+
one was edited to remove, so only the page's admins see the History control or open
|
|
129
|
+
`/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
|
|
130
|
+
matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
|
|
131
|
+
them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
|
|
132
|
+
behaviour, where every reader who can open the page reads its history.
|
|
133
|
+
|
|
134
|
+
```ts
|
|
135
|
+
createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
|
|
136
|
+
```
|
|
137
|
+
|
|
126
138
|
**Do not write a "Version history" section in the markdown.** Say what changed when you publish:
|
|
127
139
|
|
|
128
140
|
- `POST /api/artifacts?id=<id>¬e=<one line>`, or a `change:` line in the front matter. The query
|
|
@@ -395,9 +407,64 @@ the shell in the page's brand and kept through the state API above. Widgets need
|
|
|
395
407
|
state store (`state:` in the config); on a host without one they draw nothing, and the publish
|
|
396
408
|
response carries the no-answers warning.
|
|
397
409
|
|
|
398
|
-
This version draws
|
|
399
|
-
`poll
|
|
400
|
-
|
|
410
|
+
This version draws two widgets, **checklist** and **notes**. Poll and form are coming: a fence
|
|
411
|
+
named `poll` or `form` is refused at publish until then, so a page never ships a code block that
|
|
412
|
+
turns into a live widget on a later update. A checklist needs no state store unless it offers
|
|
413
|
+
Send.
|
|
414
|
+
|
|
415
|
+
### Checklist
|
|
416
|
+
|
|
417
|
+
A list a reader ticks off and comes back to: a setup page, a pre-call prep list, a launch
|
|
418
|
+
checklist. It is a known component the shell draws, not markup the author writes, so raw HTML
|
|
419
|
+
stays escaped and no author script ever runs.
|
|
420
|
+
|
|
421
|
+
````markdown
|
|
422
|
+
## Before the call
|
|
423
|
+
|
|
424
|
+
```checklist
|
|
425
|
+
send: anyone # optional: signed-in | anyone; adds a Send button, see below
|
|
426
|
+
- Update macOS {#update}
|
|
427
|
+
Apple menu > System Settings > General > Software Update.
|
|
428
|
+
Restart and check again until it says you're up to date.
|
|
429
|
+
- [ ] Install Chrome and sign in
|
|
430
|
+
Download it from [google.com/chrome](https://www.google.com/chrome/).
|
|
431
|
+
- Send us your GitHub username {#gh-send}
|
|
432
|
+
[Text Sam your username](sms:+15555550100&body=My%20GitHub%20username%20is%20)
|
|
433
|
+
```
|
|
434
|
+
````
|
|
435
|
+
|
|
436
|
+
- **An item is a line starting with `- `** (or `* `), optionally followed by `[ ]`. `[x]` is
|
|
437
|
+
accepted and ticks nothing: ticks belong to the reader.
|
|
438
|
+
- **Every item has a stable id.** `{#id}` at the end of the line sets it (lowercase letters,
|
|
439
|
+
digits, dashes, underscores; unique on the page). Without one the id is the slug of the item's
|
|
440
|
+
text, the way a heading's is (`Install Chrome and sign in` is `install-chrome-and-sign-in`), and
|
|
441
|
+
a repeat is numbered `-1`, `-2`. **Give an item an explicit id whenever its wording may change**:
|
|
442
|
+
a reworded item with a slugged id is a new item, and readers lose that tick.
|
|
443
|
+
- **Indented lines under an item are its description**, drawn as markdown: links, `code`, bold,
|
|
444
|
+
short lists. Raw HTML in an item or a description is shown as text. Images, headings, tables and
|
|
445
|
+
fences are not drawn inside a checklist.
|
|
446
|
+
- **Ticks are kept in the reader's browser**, in `localStorage` under
|
|
447
|
+
`artifact-checklist:<page id>:<item id>`, so they survive a reload, a republish, and a reworded
|
|
448
|
+
item with a kept id. Where storage is blocked (a private window, blocked site data) ticks last
|
|
449
|
+
for the visit. Nothing leaves the device unless the reader presses Send.
|
|
450
|
+
- **The list says how far along the reader is**, "3 of 8 done", and every row is a tap target at
|
|
451
|
+
least 44px tall, so it works one-handed on a phone.
|
|
452
|
+
- **Narration skips it**, like every widget: the fence is not prose and the component is
|
|
453
|
+
`data-nospeak`.
|
|
454
|
+
- **Several checklists on one page** are fine; ids are unique across all of them.
|
|
455
|
+
- **`sms:` and `tel:` links work on every page**, in a checklist or in prose, so "text us your
|
|
456
|
+
username" is one tap into the reader's own Messages.
|
|
457
|
+
- Refused at publish, naming the line in the file: an indented line before the first item, an
|
|
458
|
+
unindented line after one, an empty item, a bad or repeated `{#id}`, a name after `checklist`,
|
|
459
|
+
any setting but `send`, a checklist with no items, and a second checklist with `send`.
|
|
460
|
+
|
|
461
|
+
**Send** (`send: signed-in` or `send: anyone`) adds a "Send my progress to <owner>" button under
|
|
462
|
+
the list. It writes `{ done: [item ids] }` to the page's `checklist` slot, shape `one` and
|
|
463
|
+
`private`, so a reader's latest Send replaces their last and only the publisher reads them, through
|
|
464
|
+
`/responses` like any answer. `send:` names who may press it (`anyone` gets the anonymous cookie
|
|
465
|
+
and the rate limit, as `writers: anyone` does). The slot is the widget's own: a page may not
|
|
466
|
+
declare a `checklist` slot in `state:`. The server takes only ids of that checklist, each once. On a
|
|
467
|
+
host with no state store the button is not drawn; the ticks still work.
|
|
401
468
|
|
|
402
469
|
### Notes
|
|
403
470
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export declare const CHECKLIST_SLOT = "checklist";
|
|
2
|
+
export declare const MAX_CHECKLIST_ITEMS = 200;
|
|
3
|
+
/** An item id: lowercase letters (any script), digits, dashes and underscores. The same alphabet
|
|
4
|
+
* a heading slug uses, so a slugged id is always a valid one. */
|
|
5
|
+
export declare const CHECKLIST_ID: RegExp;
|
|
6
|
+
export type SendWriters = 'signed-in' | 'anyone';
|
|
7
|
+
export type ChecklistItem = {
|
|
8
|
+
id: string;
|
|
9
|
+
text: string;
|
|
10
|
+
description: string;
|
|
11
|
+
line: number;
|
|
12
|
+
};
|
|
13
|
+
export type ChecklistBlock = {
|
|
14
|
+
line: number;
|
|
15
|
+
send?: SendWriters;
|
|
16
|
+
items: ChecklistItem[];
|
|
17
|
+
};
|
|
18
|
+
/** What a Send stores in the page's `checklist` slot: the ids the reader had ticked. */
|
|
19
|
+
export type ChecklistValue = {
|
|
20
|
+
done: string[];
|
|
21
|
+
};
|
|
22
|
+
/** A block as written, before ids are made unique across the page. */
|
|
23
|
+
export type RawChecklistItem = ChecklistItem & {
|
|
24
|
+
explicit: boolean;
|
|
25
|
+
};
|
|
26
|
+
export type RawChecklistBlock = Omit<ChecklistBlock, 'items'> & {
|
|
27
|
+
items: RawChecklistItem[];
|
|
28
|
+
};
|
|
29
|
+
/** The fence body as written, before ids are made unique across the page. `line` is the line the
|
|
30
|
+
* fence opens on in the author's file, so every error names a line they can find. */
|
|
31
|
+
export declare function parseChecklistFence(value: string, line: number, meta?: string | null): {
|
|
32
|
+
ok: true;
|
|
33
|
+
block: RawChecklistBlock;
|
|
34
|
+
} | {
|
|
35
|
+
ok: false;
|
|
36
|
+
error: string;
|
|
37
|
+
};
|
|
38
|
+
/** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
|
|
39
|
+
* unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
|
|
40
|
+
* that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
|
|
41
|
+
* saved by page and item id. */
|
|
42
|
+
export declare function assignChecklistIds(blocks: RawChecklistBlock[]): {
|
|
43
|
+
ok: true;
|
|
44
|
+
blocks: ChecklistBlock[];
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
error: string;
|
|
48
|
+
};
|
|
49
|
+
/** The server's check on a Send, on a page whose checklist offers one: every id is an item of
|
|
50
|
+
* that checklist, once. The browser sends exactly this; anything else is refused. */
|
|
51
|
+
export declare function checkChecklistValue(v: unknown, ids: readonly string[]): string | null;
|
|
52
|
+
/** Where a reader's tick on one item is kept in their browser. */
|
|
53
|
+
export declare const checklistStorageKey: (artifactId: string, itemId: string) => string;
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// The checklist widget's pure parts: the fence's grammar, the ids every item keeps, and the
|
|
2
|
+
// server's check on what a Send hands the owner. Kept free of the markdown parser, like
|
|
3
|
+
// notes-place.ts, because the widget in the reader's browser imports the names from here.
|
|
4
|
+
//
|
|
5
|
+
// ```checklist
|
|
6
|
+
// send: anyone # optional: signed-in | anyone (who may press Send)
|
|
7
|
+
// - Update macOS {#update}
|
|
8
|
+
// Apple menu > System Settings > General > Software Update.
|
|
9
|
+
// - [ ] Sign in with your personal Apple Account
|
|
10
|
+
// ```
|
|
11
|
+
//
|
|
12
|
+
// An item is a line starting with `- ` (or `* `), optionally followed by `[ ]`. Its id is the
|
|
13
|
+
// `{#id}` at the end of the line when there is one, else a slug of its text. Indented lines under
|
|
14
|
+
// an item are its description, drawn as markdown with raw HTML escaped like the rest of the page.
|
|
15
|
+
// Ticks belong to the reader: `[x]` is accepted and ticks nothing.
|
|
16
|
+
export const CHECKLIST_SLOT = 'checklist';
|
|
17
|
+
export const MAX_CHECKLIST_ITEMS = 200;
|
|
18
|
+
const MAX_ITEM_CHARS = 500;
|
|
19
|
+
const MAX_ID_CHARS = 64;
|
|
20
|
+
/** An item id: lowercase letters (any script), digits, dashes and underscores. The same alphabet
|
|
21
|
+
* a heading slug uses, so a slugged id is always a valid one. */
|
|
22
|
+
export const CHECKLIST_ID = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,64}$/u;
|
|
23
|
+
const ITEM = /^[-*]\s+(?:\[[ xX]\]\s+)?(.*)$/;
|
|
24
|
+
const EXPLICIT_ID = /\s*\{#([^}\s]*)\}\s*$/;
|
|
25
|
+
/** GitHub's heading rule (widgets.slugify), repeated here so this file stays parser-free. */
|
|
26
|
+
function slug(text) {
|
|
27
|
+
const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
|
|
28
|
+
return (s || 'item').slice(0, MAX_ID_CHARS).replace(/-+$/, '') || 'item';
|
|
29
|
+
}
|
|
30
|
+
/** The fence body as written, before ids are made unique across the page. `line` is the line the
|
|
31
|
+
* fence opens on in the author's file, so every error names a line they can find. */
|
|
32
|
+
export function parseChecklistFence(value, line, meta) {
|
|
33
|
+
if (meta?.trim())
|
|
34
|
+
return { ok: false, error: `line ${line}: the checklist block takes no name; put settings like "send: anyone" inside it` };
|
|
35
|
+
const rows = value ? value.split('\n') : [];
|
|
36
|
+
const items = [];
|
|
37
|
+
let send;
|
|
38
|
+
let desc = null;
|
|
39
|
+
const close = () => {
|
|
40
|
+
if (desc && items.length)
|
|
41
|
+
items[items.length - 1].description = desc.join('\n').replace(/^\n+|\n+$/g, '');
|
|
42
|
+
desc = null;
|
|
43
|
+
};
|
|
44
|
+
for (let i = 0; i < rows.length; i++) {
|
|
45
|
+
const row = rows[i];
|
|
46
|
+
const at = line + 1 + i;
|
|
47
|
+
if (!row.trim()) {
|
|
48
|
+
if (desc)
|
|
49
|
+
desc.push('');
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (/^(\s{2,}|\t)/.test(row)) {
|
|
53
|
+
if (!items.length)
|
|
54
|
+
return { ok: false, error: `line ${at}: an indented line belongs under an item; start the list with "- "` };
|
|
55
|
+
desc.push(row.replace(/^(\t| {2,4})/, ''));
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const m = ITEM.exec(row);
|
|
59
|
+
if (m) {
|
|
60
|
+
close();
|
|
61
|
+
let text = m[1];
|
|
62
|
+
let id;
|
|
63
|
+
const e = EXPLICIT_ID.exec(text);
|
|
64
|
+
if (e) {
|
|
65
|
+
id = e[1];
|
|
66
|
+
text = text.slice(0, e.index);
|
|
67
|
+
if (!CHECKLIST_ID.test(id))
|
|
68
|
+
return { ok: false, error: `line ${at}: the id {#${id}} must be lowercase letters, digits, dashes or underscores, at most ${MAX_ID_CHARS}` };
|
|
69
|
+
}
|
|
70
|
+
text = text.trim();
|
|
71
|
+
if (!text)
|
|
72
|
+
return { ok: false, error: `line ${at}: a checklist item needs some text` };
|
|
73
|
+
if (text.length > MAX_ITEM_CHARS)
|
|
74
|
+
return { ok: false, error: `line ${at}: a checklist item is at most ${MAX_ITEM_CHARS} characters` };
|
|
75
|
+
if (items.length >= MAX_CHECKLIST_ITEMS)
|
|
76
|
+
return { ok: false, error: `line ${at}: a checklist takes at most ${MAX_CHECKLIST_ITEMS} items` };
|
|
77
|
+
items.push({ id: id ?? '', text, description: '', line: at, explicit: !!id });
|
|
78
|
+
desc = [];
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (items.length)
|
|
82
|
+
return { ok: false, error: `line ${at}: a checklist item starts with "- ", and its description under it is indented` };
|
|
83
|
+
const s = /^([A-Za-z_-]+)\s*:\s*([^#]*?)\s*(#.*)?$/.exec(row.trim());
|
|
84
|
+
if (!s)
|
|
85
|
+
return { ok: false, error: `line ${at}: the checklist block takes settings like "send: anyone", then items starting with "- "` };
|
|
86
|
+
if (s[1] !== 'send')
|
|
87
|
+
return { ok: false, error: `line ${at}: the checklist block has an unknown setting: ${s[1]}` };
|
|
88
|
+
if (s[2] !== 'signed-in' && s[2] !== 'anyone')
|
|
89
|
+
return { ok: false, error: `line ${at}: checklist send must be signed-in or anyone` };
|
|
90
|
+
send = s[2];
|
|
91
|
+
}
|
|
92
|
+
close();
|
|
93
|
+
if (!items.length)
|
|
94
|
+
return { ok: false, error: `line ${line}: a checklist needs at least one item starting with "- "` };
|
|
95
|
+
return { ok: true, block: { line, ...(send ? { send } : {}), items } };
|
|
96
|
+
}
|
|
97
|
+
/** Gives every item on the page its final id. An explicit `{#id}` is kept as written and must be
|
|
98
|
+
* unique on the page; an item without one takes the slug of its text, numbered `-1`, `-2` when
|
|
99
|
+
* that slug is already used, the way repeated headings are. Ids are page-wide because ticks are
|
|
100
|
+
* saved by page and item id. */
|
|
101
|
+
export function assignChecklistIds(blocks) {
|
|
102
|
+
const taken = new Map();
|
|
103
|
+
for (const b of blocks)
|
|
104
|
+
for (const it of b.items) {
|
|
105
|
+
if (!it.explicit)
|
|
106
|
+
continue;
|
|
107
|
+
if (taken.has(it.id))
|
|
108
|
+
return { ok: false, error: `line ${it.line}: the id {#${it.id}} is already used on line ${taken.get(it.id)}` };
|
|
109
|
+
taken.set(it.id, it.line);
|
|
110
|
+
}
|
|
111
|
+
const out = blocks.map((b) => ({
|
|
112
|
+
line: b.line,
|
|
113
|
+
...(b.send ? { send: b.send } : {}),
|
|
114
|
+
items: b.items.map(({ explicit, ...it }) => {
|
|
115
|
+
if (explicit)
|
|
116
|
+
return it;
|
|
117
|
+
const base = slug(it.text);
|
|
118
|
+
let id = base;
|
|
119
|
+
for (let k = 1; taken.has(id); k++)
|
|
120
|
+
id = `${base}-${k}`;
|
|
121
|
+
taken.set(id, it.line);
|
|
122
|
+
return { ...it, id };
|
|
123
|
+
}),
|
|
124
|
+
}));
|
|
125
|
+
return { ok: true, blocks: out };
|
|
126
|
+
}
|
|
127
|
+
/** The server's check on a Send, on a page whose checklist offers one: every id is an item of
|
|
128
|
+
* that checklist, once. The browser sends exactly this; anything else is refused. */
|
|
129
|
+
export function checkChecklistValue(v, ids) {
|
|
130
|
+
if (!v || typeof v !== 'object' || Array.isArray(v) || Object.keys(v).some((k) => k !== 'done'))
|
|
131
|
+
return 'a checklist answer is { done: [item ids] }';
|
|
132
|
+
const done = v.done;
|
|
133
|
+
if (!Array.isArray(done))
|
|
134
|
+
return 'a checklist answer is { done: [item ids] }';
|
|
135
|
+
const known = new Set(ids);
|
|
136
|
+
const seen = new Set();
|
|
137
|
+
for (const id of done) {
|
|
138
|
+
if (typeof id !== 'string' || !known.has(id))
|
|
139
|
+
return 'a checklist answer names only items on this page';
|
|
140
|
+
if (seen.has(id))
|
|
141
|
+
return 'a checklist answer names each item once';
|
|
142
|
+
seen.add(id);
|
|
143
|
+
}
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
/** Where a reader's tick on one item is kept in their browser. */
|
|
147
|
+
export const checklistStorageKey = (artifactId, itemId) => `artifact-checklist:${artifactId}:${itemId}`;
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
5
|
import { parseStateConfig } from './state.js';
|
|
6
|
-
import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
6
|
+
import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js';
|
|
7
7
|
import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
|
|
8
8
|
import { THEME_MODES } from '../brand/theme.js';
|
|
9
9
|
import { parseDefinitions } from './definitions.js';
|
|
@@ -94,14 +94,17 @@ export function parseArtifactSource(text) {
|
|
|
94
94
|
return { ok: false, error: 'comments_visible must be owner or readers' };
|
|
95
95
|
meta.commentsVisible = d.comments_visible;
|
|
96
96
|
}
|
|
97
|
-
// Widgets declare their own slots, so a notes block is merged into state: here, before the
|
|
97
|
+
// Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
|
|
98
98
|
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
99
99
|
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
|
100
100
|
const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
|
|
101
101
|
const w = scanWidgets(content, offset);
|
|
102
102
|
if (!w.ok)
|
|
103
103
|
return { ok: false, error: w.error };
|
|
104
|
-
const
|
|
104
|
+
const noted = mergeWidgetState(meta.state, w.notes);
|
|
105
|
+
if (!noted.ok)
|
|
106
|
+
return { ok: false, error: noted.error };
|
|
107
|
+
const merged = mergeChecklistState(noted.state, w.checklists);
|
|
105
108
|
if (!merged.ok)
|
|
106
109
|
return { ok: false, error: merged.error };
|
|
107
110
|
const withComments = mergeCommentsState(merged.state, meta.comments, meta.commentsVisible);
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
|
|
2
|
-
export { ArtifactMarkdown } from './render.js';
|
|
2
|
+
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
|
|
@@ -12,7 +12,8 @@ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, W
|
|
|
12
12
|
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
15
|
-
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
15
|
+
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
16
|
+
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
|
|
16
17
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
|
|
17
18
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
|
|
18
19
|
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
export { parseArtifactSource } from './front-matter.js';
|
|
2
|
-
export { ArtifactMarkdown } from './render.js';
|
|
2
|
+
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
5
5
|
export { isPublishAuthed, isPublisherAuthed } from './auth.js';
|
|
@@ -12,7 +12,8 @@ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, W
|
|
|
12
12
|
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
15
|
-
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
15
|
+
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
16
|
+
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
|
|
16
17
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
|
|
17
18
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
|
|
18
19
|
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, personalAudioDir, MAX_PERSONAL_PER_READER } from './personal-store.js';
|
|
@@ -3,6 +3,7 @@ import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js
|
|
|
3
3
|
import { type Heading } from './widgets.js';
|
|
4
4
|
import { type Definition } from './definitions.js';
|
|
5
5
|
export declare function isReachableHref(href: unknown): href is string;
|
|
6
|
+
export declare function artifactUrlTransform(url: string): string;
|
|
6
7
|
/** A pack's figure resolver (BrandPack.figure). */
|
|
7
8
|
export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
|
|
8
9
|
/** The page's top-level sections: every heading among the body's top-level blocks at the
|
|
@@ -10,8 +11,17 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
|
|
|
10
11
|
export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
|
|
11
12
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
12
13
|
* page carries a ```notes block. */
|
|
13
|
-
export declare function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }: {
|
|
14
|
+
export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }: {
|
|
14
15
|
markdown: string;
|
|
16
|
+
/** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
|
|
17
|
+
* (a host that keeps answers, on a page that declares the slot). Without it a checklist still
|
|
18
|
+
* draws and ticks, for the visit only. */
|
|
19
|
+
checklist?: {
|
|
20
|
+
artifactId: string;
|
|
21
|
+
send?: boolean;
|
|
22
|
+
owner?: string;
|
|
23
|
+
accent?: string;
|
|
24
|
+
};
|
|
15
25
|
/** The pack's section component (BrandPack.Section): each top-level section is drawn through it. */
|
|
16
26
|
Section?: ComponentType<SectionProps>;
|
|
17
27
|
/** The pack's figure resolver (BrandPack.figure). */
|
package/lib/artifacts/render.js
CHANGED
|
@@ -2,16 +2,20 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
// Markdown to the house look, for /a/<id>. Server-safe: no hooks, no client
|
|
3
3
|
// directive. Raw HTML is escaped by react-markdown's default; never add
|
|
4
4
|
// rehype-raw here, this renders strangers' links on a public route.
|
|
5
|
-
//
|
|
5
|
+
// Three extensions past GFM, all small on purpose:
|
|
6
6
|
// ```links one "url | note" per line, rendered as link cards
|
|
7
|
+
// ```checklist items a reader ticks, kept in their browser (widgets/checklist.tsx)
|
|
7
8
|
// > [!note] / > [!warning] a callout instead of a blockquote
|
|
9
|
+
// A checklist's item text and descriptions are markdown drawn by the same safe renderer: raw
|
|
10
|
+
// HTML escaped, links only where a reader can follow them, no images.
|
|
8
11
|
import { Children, isValidElement } from 'react';
|
|
9
|
-
import ReactMarkdown from 'react-markdown';
|
|
12
|
+
import ReactMarkdown, { defaultUrlTransform } from 'react-markdown';
|
|
10
13
|
import remarkGfm from 'remark-gfm';
|
|
11
14
|
import remarkParse from 'remark-parse';
|
|
12
15
|
import { unified } from 'unified';
|
|
13
|
-
import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
|
|
16
|
+
import { blocksOf, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
|
|
14
17
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
18
|
+
import { Checklist } from '../widgets/checklist.js';
|
|
15
19
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
16
20
|
import { CodeCopy } from '../reader/code-copy.js';
|
|
17
21
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
@@ -58,6 +62,13 @@ const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
|
58
62
|
export function isReachableHref(href) {
|
|
59
63
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
60
64
|
}
|
|
65
|
+
/** `sms:` and `tel:` links open the reader's own Messages or Phone: a prep page's "text us your
|
|
66
|
+
* username" is one tap. react-markdown's default drops both schemes (it keeps http(s), mailto,
|
|
67
|
+
* xmpp and irc); everything else still goes through that default, so `javascript:` stays dead. */
|
|
68
|
+
const PHONE_LINK = /^(?:sms|tel):/i;
|
|
69
|
+
export function artifactUrlTransform(url) {
|
|
70
|
+
return PHONE_LINK.test(url) ? url : defaultUrlTransform(url);
|
|
71
|
+
}
|
|
61
72
|
/** `` plays as video: an animation that must stay smooth on a phone. Animated WebP
|
|
62
73
|
* is decoded frame by frame on the CPU and stuttered on an iPhone at 24fps (2026-09-27); video
|
|
63
74
|
* is decoded in hardware. */
|
|
@@ -91,7 +102,27 @@ function soleImage(node) {
|
|
|
91
102
|
const src = kids[0].properties?.src;
|
|
92
103
|
return typeof src === 'string' ? { src, alt: String(kids[0].properties?.alt ?? '') } : null;
|
|
93
104
|
}
|
|
94
|
-
|
|
105
|
+
// Item text and descriptions: the page's own safe link, inline code and emphasis, nothing that
|
|
106
|
+
// would break out of a list row (no images, headings, tables, fences or rules).
|
|
107
|
+
const CHECKLIST_DISALLOWED = ['img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'table', 'pre', 'hr', 'blockquote', 'input'];
|
|
108
|
+
function checklistMarkdown(source, inline) {
|
|
109
|
+
const a = baseComponents(() => ({})).a;
|
|
110
|
+
return (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], urlTransform: artifactUrlTransform, disallowedElements: CHECKLIST_DISALLOWED, unwrapDisallowed: true, components: {
|
|
111
|
+
a,
|
|
112
|
+
p: ({ children }) => (inline ? _jsx(_Fragment, { children: children }) : _jsx("p", { className: "my-1 leading-relaxed", children: children })),
|
|
113
|
+
ul: ({ children }) => _jsx("ul", { className: "my-1 list-disc pl-5", children: children }),
|
|
114
|
+
ol: ({ children }) => _jsx("ol", { className: "my-1 list-decimal pl-5", children: children }),
|
|
115
|
+
code: ({ children }) => _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children }),
|
|
116
|
+
}, children: source }));
|
|
117
|
+
}
|
|
118
|
+
function ChecklistFence({ line, ctx }) {
|
|
119
|
+
const c = ctx.byLine.get(line);
|
|
120
|
+
// Not a checklist this page validated: draw nothing rather than its source as code.
|
|
121
|
+
if (!c)
|
|
122
|
+
return null;
|
|
123
|
+
return (_jsx(Checklist, { items: c.items.map((it) => ({ id: it.id, title: checklistMarkdown(it.text, true), description: it.description ? checklistMarkdown(it.description, false) : null })), artifactId: ctx.artifactId, send: ctx.send && ctx.artifactId ? c.send : undefined, owner: ctx.owner, accent: ctx.accent }));
|
|
124
|
+
}
|
|
125
|
+
function baseComponents(block, figure, checklists) {
|
|
95
126
|
return {
|
|
96
127
|
p: ({ node, children }) => {
|
|
97
128
|
// An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
|
|
@@ -118,11 +149,15 @@ function baseComponents(block, figure) {
|
|
|
118
149
|
// eslint-disable-next-line @next/next/no-img-element
|
|
119
150
|
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
|
|
120
151
|
},
|
|
121
|
-
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
|
|
122
|
-
|
|
152
|
+
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
|
|
153
|
+
// A phone link opens an app, not a tab.
|
|
154
|
+
target: PHONE_LINK.test(href) ? undefined : '_blank', rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
|
|
155
|
+
code: ({ node, className, children }) => {
|
|
123
156
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
124
157
|
if (lang === 'links')
|
|
125
158
|
return _jsx(LinkCards, { source: textOf(children) });
|
|
159
|
+
if (lang === 'checklist')
|
|
160
|
+
return checklists ? _jsx(ChecklistFence, { line: node?.position?.start.line ?? -1, ctx: checklists }) : null;
|
|
126
161
|
// A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
|
|
127
162
|
// answers) it draws nothing: its settings are not prose and never shown as code.
|
|
128
163
|
if (lang === 'notes')
|
|
@@ -136,7 +171,7 @@ function baseComponents(block, figure) {
|
|
|
136
171
|
// A links fence renders its own block; do not wrap it in <pre>.
|
|
137
172
|
const inner = Array.isArray(children) ? children[0] : children;
|
|
138
173
|
const cls = inner?.props?.className ?? '';
|
|
139
|
-
if (/language-(links|notes)/.test(cls))
|
|
174
|
+
if (/language-(links|notes|checklist)/.test(cls))
|
|
140
175
|
return _jsx(_Fragment, { children: children });
|
|
141
176
|
return (_jsx("pre", { ...block(node), "data-artifact-code": true,
|
|
142
177
|
// whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
|
|
@@ -169,20 +204,20 @@ const HEADING_CLASS = {
|
|
|
169
204
|
};
|
|
170
205
|
/** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
|
|
171
206
|
* so a contents link and a note both land on it. */
|
|
172
|
-
function pageComponents(headings, block, figure) {
|
|
207
|
+
function pageComponents(headings, block, figure, checklists) {
|
|
173
208
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
174
209
|
const heading = (depth) => function Heading({ node, children }) {
|
|
175
210
|
const Tag = `h${depth}`;
|
|
176
211
|
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
177
212
|
return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
|
|
178
213
|
};
|
|
179
|
-
return { ...baseComponents(block, figure), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
214
|
+
return { ...baseComponents(block, figure, checklists), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
180
215
|
}
|
|
181
216
|
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
182
217
|
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
183
218
|
* one headingsOf derived, the same one a note stores. */
|
|
184
|
-
function notesComponents(headings, block, figure) {
|
|
185
|
-
const components = baseComponents(block, figure);
|
|
219
|
+
function notesComponents(headings, block, figure, checklists) {
|
|
220
|
+
const components = baseComponents(block, figure, checklists);
|
|
186
221
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
187
222
|
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
188
223
|
const Tag = `h${depth}`;
|
|
@@ -261,7 +296,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
|
|
|
261
296
|
'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
|
|
262
297
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
263
298
|
* page carries a ```notes block. */
|
|
264
|
-
export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure, }) {
|
|
299
|
+
export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }) {
|
|
265
300
|
const on = !!notes && hasNotesWidget(markdown);
|
|
266
301
|
const headings = headingsOf(markdown);
|
|
267
302
|
const blocks = blocksOf(markdown);
|
|
@@ -272,10 +307,14 @@ export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure
|
|
|
272
307
|
const defined = definitions?.length ? definitions : null;
|
|
273
308
|
const outline = Section ? sectionOutline(markdown, headings) : [];
|
|
274
309
|
const plugins = [remarkGfm, ...(defined ? [remarkDefinitions(defined)] : []), ...(outline.length ? [remarkSections(outline[0].depth)] : [])];
|
|
275
|
-
const
|
|
310
|
+
const lists = /^ {0,3}(`{3,}|~{3,})\s*checklist\b/m.test(markdown) ? checklistsOf(markdown) : null;
|
|
311
|
+
const checklists = lists
|
|
312
|
+
? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, owner: checklist?.owner, accent: checklist?.accent }
|
|
313
|
+
: undefined;
|
|
314
|
+
const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
|
|
276
315
|
if (outline.length)
|
|
277
316
|
components.section = sectionComponent(Section, outline, block);
|
|
278
|
-
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
|
|
317
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
|
|
279
318
|
return (
|
|
280
319
|
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
281
320
|
_jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, CODE_IN_MD.test(markdown) ? _jsx(CodeCopy, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { StateConfig, Visibility } from './state.js';
|
|
2
2
|
import { type Heading } from './notes-place.js';
|
|
3
|
+
import { type ChecklistBlock } from './checklist.js';
|
|
3
4
|
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
|
|
4
5
|
export type NotesWidget = {
|
|
5
6
|
line: number;
|
|
@@ -17,6 +18,24 @@ export declare function headingsOf(markdown: string): Heading[];
|
|
|
17
18
|
export declare function scanWidgets(body: string, offset: number): {
|
|
18
19
|
ok: true;
|
|
19
20
|
notes: NotesWidget | null;
|
|
21
|
+
checklists: ChecklistBlock[];
|
|
22
|
+
} | {
|
|
23
|
+
ok: false;
|
|
24
|
+
error: string;
|
|
25
|
+
};
|
|
26
|
+
/** Every checklist on a page, by the line its fence opens on in the body, with the ids the page
|
|
27
|
+
* draws. A page whose checklists do not parse (published before checklists existed, so never
|
|
28
|
+
* validated) draws none rather than half of them. */
|
|
29
|
+
export declare function checklistsOf(markdown: string): Map<number, ChecklistBlock>;
|
|
30
|
+
/** The item ids of the page's checklist that offers Send, or null when none does. The state
|
|
31
|
+
* route asks, to check what a Send may name. */
|
|
32
|
+
export declare function checklistSendIds(markdown: string): string[] | null;
|
|
33
|
+
/** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
|
|
34
|
+
* whoever `send:` names; the owner reads it through /responses. Like `comments:`, the slot is
|
|
35
|
+
* the widget's own, so state: may not declare one by that name. */
|
|
36
|
+
export declare function mergeChecklistState(state: StateConfig | undefined, checklists: ChecklistBlock[]): {
|
|
37
|
+
ok: true;
|
|
38
|
+
state: StateConfig | undefined;
|
|
20
39
|
} | {
|
|
21
40
|
ok: false;
|
|
22
41
|
error: string;
|
|
@@ -38,5 +57,5 @@ export declare function hasNotesWidget(markdown: string): boolean;
|
|
|
38
57
|
export declare function checkNoteValue(v: unknown): string | null;
|
|
39
58
|
/** The stable id of every top-level block, by the offset it starts at in the markdown. The
|
|
40
59
|
* renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
|
|
41
|
-
* that draw no prose (a notes or
|
|
60
|
+
* that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
|
|
42
61
|
export declare function blocksOf(markdown: string): Map<number, string>;
|
package/lib/artifacts/widgets.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
|
|
2
2
|
// the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
|
|
3
3
|
//
|
|
4
|
-
// This version draws
|
|
5
|
-
//
|
|
4
|
+
// This version draws two widgets. `checklist` (./checklist.ts) is a list of items a reader ticks,
|
|
5
|
+
// kept in their own browser, with an optional Send that hands their ticks to the page's owner
|
|
6
|
+
// through the `one` slot `checklist`. `notes` is a small note control beside every heading, kept
|
|
7
|
+
// in the `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
|
|
6
8
|
// so a republish that renames or removes the heading keeps the note and shows it under "notes
|
|
7
9
|
// on earlier versions" instead of losing it or attaching it to the wrong section.
|
|
8
10
|
//
|
|
@@ -14,10 +16,11 @@ import remarkParse from 'remark-parse';
|
|
|
14
16
|
import remarkGfm from 'remark-gfm';
|
|
15
17
|
import { blockId } from './anchor.js';
|
|
16
18
|
import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
|
|
19
|
+
import { CHECKLIST_SLOT, assignChecklistIds, parseChecklistFence } from './checklist.js';
|
|
17
20
|
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
|
|
18
21
|
/** Widgets the design names and this version does not draw yet. A fence using one is refused at
|
|
19
22
|
* publish, so a page never ships a code block that turns into a live widget on a later update. */
|
|
20
|
-
const NOT_YET = ['poll', 'form'
|
|
23
|
+
const NOT_YET = ['poll', 'form'];
|
|
21
24
|
const MAX_HEADING_CHARS = 300;
|
|
22
25
|
const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
23
26
|
function inline(nodes) {
|
|
@@ -97,7 +100,58 @@ export function scanWidgets(body, offset) {
|
|
|
97
100
|
}
|
|
98
101
|
notes = w;
|
|
99
102
|
}
|
|
100
|
-
|
|
103
|
+
const lists = scanChecklists(fences, offset);
|
|
104
|
+
if (!lists.ok)
|
|
105
|
+
return lists;
|
|
106
|
+
return { ok: true, notes, checklists: lists.checklists };
|
|
107
|
+
}
|
|
108
|
+
function scanChecklists(fences, offset) {
|
|
109
|
+
const lists = [];
|
|
110
|
+
for (const f of fences) {
|
|
111
|
+
if (f.lang !== 'checklist')
|
|
112
|
+
continue;
|
|
113
|
+
const line = (f.position?.start.line ?? 1) + offset;
|
|
114
|
+
const c = parseChecklistFence(f.value, line, f.meta);
|
|
115
|
+
if (!c.ok)
|
|
116
|
+
return c;
|
|
117
|
+
if (c.block.send && lists.some((l) => l.send))
|
|
118
|
+
return { ok: false, error: `line ${line}: a page takes at most one checklist with send` };
|
|
119
|
+
lists.push(c.block);
|
|
120
|
+
}
|
|
121
|
+
const ids = assignChecklistIds(lists);
|
|
122
|
+
return ids.ok ? { ok: true, checklists: ids.blocks } : ids;
|
|
123
|
+
}
|
|
124
|
+
const fencesOf = (markdown) => {
|
|
125
|
+
const fences = [];
|
|
126
|
+
walk(parse(markdown).children, (n) => { if (n.type === 'code' && n.lang === 'checklist')
|
|
127
|
+
fences.push(n); });
|
|
128
|
+
return fences;
|
|
129
|
+
};
|
|
130
|
+
/** Every checklist on a page, by the line its fence opens on in the body, with the ids the page
|
|
131
|
+
* draws. A page whose checklists do not parse (published before checklists existed, so never
|
|
132
|
+
* validated) draws none rather than half of them. */
|
|
133
|
+
export function checklistsOf(markdown) {
|
|
134
|
+
const r = scanChecklists(fencesOf(markdown), 0);
|
|
135
|
+
return new Map(r.ok ? r.checklists.map((c) => [c.line, c]) : []);
|
|
136
|
+
}
|
|
137
|
+
/** The item ids of the page's checklist that offers Send, or null when none does. The state
|
|
138
|
+
* route asks, to check what a Send may name. */
|
|
139
|
+
export function checklistSendIds(markdown) {
|
|
140
|
+
const r = scanChecklists(fencesOf(markdown), 0);
|
|
141
|
+
const c = r.ok ? r.checklists.find((l) => l.send) : undefined;
|
|
142
|
+
return c ? c.items.map((i) => i.id) : null;
|
|
143
|
+
}
|
|
144
|
+
/** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
|
|
145
|
+
* whoever `send:` names; the owner reads it through /responses. Like `comments:`, the slot is
|
|
146
|
+
* the widget's own, so state: may not declare one by that name. */
|
|
147
|
+
export function mergeChecklistState(state, checklists) {
|
|
148
|
+
const c = checklists.find((l) => l.send);
|
|
149
|
+
if (!c)
|
|
150
|
+
return { ok: true, state };
|
|
151
|
+
if (state && Object.hasOwn(state.slots, CHECKLIST_SLOT))
|
|
152
|
+
return { ok: false, error: `line ${c.line}: the checklist's send writes to slot "${CHECKLIST_SLOT}", and state: declares a slot by that name; rename that slot` };
|
|
153
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
154
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [CHECKLIST_SLOT]: { shape: 'one', visibility: 'private', writers: c.send } } } };
|
|
101
155
|
}
|
|
102
156
|
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
103
157
|
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
@@ -157,7 +211,7 @@ function assetName(url) {
|
|
|
157
211
|
}
|
|
158
212
|
/** The stable id of every top-level block, by the offset it starts at in the markdown. The
|
|
159
213
|
* renderer puts it on the block as `data-block`, and a region comment is pinned to it. Blocks
|
|
160
|
-
* that draw no prose (a notes or
|
|
214
|
+
* that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
|
|
161
215
|
export function blocksOf(markdown) {
|
|
162
216
|
const out = new Map();
|
|
163
217
|
const seen = new Map();
|
|
@@ -195,7 +249,7 @@ export function blocksOf(markdown) {
|
|
|
195
249
|
content = plain(n);
|
|
196
250
|
}
|
|
197
251
|
else if (n.type === 'code') {
|
|
198
|
-
if (n.lang === 'links' || n.lang === NOTES_SLOT)
|
|
252
|
+
if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist')
|
|
199
253
|
continue;
|
|
200
254
|
kind = 'code';
|
|
201
255
|
content = n.value;
|
|
@@ -64,6 +64,15 @@ export type ArtifactRoutesConfig = {
|
|
|
64
64
|
* its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
|
|
65
65
|
* the publisher reads comments through /responses. */
|
|
66
66
|
ownerEmail?: string;
|
|
67
|
+
/** Sign-in emails, besides `ownerEmail`, of the people who administer this host's pages. They
|
|
68
|
+
* see a page's version history; with `history: 'admins'` (the default) nobody else does. */
|
|
69
|
+
admins?: readonly string[];
|
|
70
|
+
/** Who sees a page's past versions: the History control under the summary and every
|
|
71
|
+
* `/<id>/v/<n>`. `'admins'` (default): only `ownerEmail` and `admins`, signed in; to everyone
|
|
72
|
+
* else the History control is absent and a past version is not found, because an earlier draft
|
|
73
|
+
* can hold what the current one was edited to remove. `'everyone'`: every reader who can open
|
|
74
|
+
* the page, which was the only behaviour before 0.21.0. */
|
|
75
|
+
history?: 'admins' | 'everyone';
|
|
67
76
|
/** Turns a reader's voice memo into text on the server, so the service's key never reaches a
|
|
68
77
|
* browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
|
|
69
78
|
* own. Without it the transcribe route answers 404 and readers get the browser's live
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -6,6 +6,7 @@ import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
|
6
6
|
import { parseArtifactSource } from '../artifacts/front-matter.js';
|
|
7
7
|
import { narrationText } from '../artifacts/narration.js';
|
|
8
8
|
import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
9
|
+
import { CHECKLIST_SLOT } from '../artifacts/checklist.js';
|
|
9
10
|
import { ArtifactDoor } from '../artifacts/door.js';
|
|
10
11
|
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
11
12
|
import { cleanNote } from '../artifacts/store.js';
|
|
@@ -60,6 +61,9 @@ export function createArtifactRoutes(config) {
|
|
|
60
61
|
const signInOrigin = config.signInOrigin;
|
|
61
62
|
const owner = config.owner ?? brand.name;
|
|
62
63
|
const ownerEmail = config.ownerEmail?.trim().toLowerCase();
|
|
64
|
+
const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
|
|
65
|
+
/** Whether this reader may see a page's past versions (config `history`). */
|
|
66
|
+
const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
|
|
63
67
|
const readerSecret = () => config.readerSecret?.();
|
|
64
68
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
65
69
|
// Signing out alone cannot change the account: the authority still holds the Google session
|
|
@@ -195,7 +199,7 @@ export function createArtifactRoutes(config) {
|
|
|
195
199
|
if (!a)
|
|
196
200
|
notFound();
|
|
197
201
|
const { pack, brand } = lookOf(a);
|
|
198
|
-
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
|
|
202
|
+
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
199
203
|
if (a.access)
|
|
200
204
|
return GatedPage(a, id, render, n);
|
|
201
205
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
@@ -273,8 +277,8 @@ export function createArtifactRoutes(config) {
|
|
|
273
277
|
return store.history(id).catch(() => null);
|
|
274
278
|
}
|
|
275
279
|
/** "Version N · Updated <minute> · History": the line under the summary. */
|
|
276
|
-
function VersionLine({ a, n, at, history }) {
|
|
277
|
-
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
|
|
280
|
+
function VersionLine({ a, n, at, history, reader }) {
|
|
281
|
+
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 && seesHistory(reader) ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
|
|
278
282
|
}
|
|
279
283
|
/** The title block and the body, in the pack's Article when it has one. Without one this is
|
|
280
284
|
* exactly the markup every page had before packs could frame it. */
|
|
@@ -284,8 +288,11 @@ export function createArtifactRoutes(config) {
|
|
|
284
288
|
return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
|
|
285
289
|
}
|
|
286
290
|
/** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
|
|
287
|
-
async function VersionBody(a, n, { top }) {
|
|
291
|
+
async function VersionBody(a, n, { top, reader }) {
|
|
288
292
|
const { pack, brand } = lookOf(a);
|
|
293
|
+
// Past versions are for admins unless the host says otherwise: to anyone else none exists.
|
|
294
|
+
if (!seesHistory(reader))
|
|
295
|
+
notFound();
|
|
289
296
|
const v = store.version ? await store.version(a.id, n) : null;
|
|
290
297
|
if (!v)
|
|
291
298
|
notFound();
|
|
@@ -296,7 +303,7 @@ export function createArtifactRoutes(config) {
|
|
|
296
303
|
const title = v.title ?? a.title;
|
|
297
304
|
const summary = v.summary ?? a.summary;
|
|
298
305
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
299
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
|
|
306
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
|
|
300
307
|
}
|
|
301
308
|
/** The page itself, shared by open and gated pages. */
|
|
302
309
|
async function Body(a, { top, reader }) {
|
|
@@ -317,9 +324,11 @@ export function createArtifactRoutes(config) {
|
|
|
317
324
|
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
318
325
|
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
|
|
319
326
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
320
|
-
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
327
|
+
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id), reader: reader }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
321
328
|
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
322
|
-
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
329
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
330
|
+
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
331
|
+
checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) }));
|
|
323
332
|
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
324
333
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
325
334
|
recorded at publish, or there is none. */] }));
|
|
@@ -19,7 +19,8 @@ import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWrit
|
|
|
19
19
|
import { COMMENTS_SLOT, validateComment } from '../artifacts/comments.js';
|
|
20
20
|
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
21
21
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
22
|
-
import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
|
|
22
|
+
import { NOTES_SLOT, checkNoteValue, checklistSendIds, hasNotesWidget } from '../artifacts/widgets.js';
|
|
23
|
+
import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
|
|
23
24
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
24
25
|
export const ANON_COOKIE = 'artifact_anon';
|
|
25
26
|
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
@@ -242,7 +243,10 @@ export function createStateRoutes(ctx) {
|
|
|
242
243
|
return json({ error: 'no comment of yours with that id' }, 404);
|
|
243
244
|
}
|
|
244
245
|
else {
|
|
245
|
-
const
|
|
246
|
+
const sendIds = slot === CHECKLIST_SLOT ? checklistSendIds(a.markdown) : null;
|
|
247
|
+
const bad = checkValue(b.value)
|
|
248
|
+
?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null)
|
|
249
|
+
?? (sendIds ? checkChecklistValue(b.value, sendIds) : null);
|
|
246
250
|
if (bad)
|
|
247
251
|
return json({ error: bad }, 400);
|
|
248
252
|
let value = b.value;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type SendWriters } from '../artifacts/checklist.js';
|
|
3
|
+
export type ChecklistViewItem = {
|
|
4
|
+
id: string;
|
|
5
|
+
title: ReactNode;
|
|
6
|
+
description: ReactNode | null;
|
|
7
|
+
};
|
|
8
|
+
export declare function Checklist({ items, artifactId, send, owner, accent, }: {
|
|
9
|
+
items: ChecklistViewItem[];
|
|
10
|
+
/** The page, for the storage key. Without one (a past version) ticks last for the visit. */
|
|
11
|
+
artifactId?: string;
|
|
12
|
+
/** Who may press Send, when the page offers it and the host keeps answers. */
|
|
13
|
+
send?: SendWriters;
|
|
14
|
+
owner?: string;
|
|
15
|
+
accent?: string;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The checklist widget in the reader's browser. Each tick is kept in localStorage under the page
|
|
4
|
+
// and the item id (checklist.ts, checklistStorageKey), so it survives a reload, a republish and a
|
|
5
|
+
// reworded item, and never leaves the device unless the reader presses Send. Storage that throws
|
|
6
|
+
// (a private window, blocked site data) costs the reader only the memory: ticks still work for
|
|
7
|
+
// the visit.
|
|
8
|
+
//
|
|
9
|
+
// The item text and description arrive already drawn on the server, as markdown with raw HTML
|
|
10
|
+
// escaped, so this file draws no author content itself. Everything here is data-nospeak: the
|
|
11
|
+
// narrator reads the prose, never the checklist.
|
|
12
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
13
|
+
import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
|
|
14
|
+
function readTick(key) {
|
|
15
|
+
if (!key)
|
|
16
|
+
return false;
|
|
17
|
+
try {
|
|
18
|
+
return globalThis.localStorage?.getItem(key) === '1';
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function writeTick(key, on) {
|
|
25
|
+
if (!key)
|
|
26
|
+
return;
|
|
27
|
+
try {
|
|
28
|
+
if (on)
|
|
29
|
+
globalThis.localStorage?.setItem(key, '1');
|
|
30
|
+
else
|
|
31
|
+
globalThis.localStorage?.removeItem(key);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
// Storage blocked: the tick lives for this visit only.
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
export function Checklist({ items, artifactId, send, owner, accent, }) {
|
|
38
|
+
const key = useCallback((id) => (artifactId ? checklistStorageKey(artifactId, id) : null), [artifactId]);
|
|
39
|
+
const [done, setDone] = useState({});
|
|
40
|
+
const [sent, setSent] = useState({ status: 'idle' });
|
|
41
|
+
// Read after mount, never during render: the server has no storage, and reading it while
|
|
42
|
+
// hydrating would draw a page that disagrees with the server's.
|
|
43
|
+
const ids = items.map((it) => it.id).join('\n');
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
const next = {};
|
|
46
|
+
for (const id of ids.split('\n'))
|
|
47
|
+
if (readTick(key(id)))
|
|
48
|
+
next[id] = true;
|
|
49
|
+
setDone(next);
|
|
50
|
+
}, [ids, key]);
|
|
51
|
+
const toggle = (id) => {
|
|
52
|
+
setDone((d) => {
|
|
53
|
+
const on = !d[id];
|
|
54
|
+
writeTick(key(id), on);
|
|
55
|
+
const next = { ...d };
|
|
56
|
+
if (on)
|
|
57
|
+
next[id] = true;
|
|
58
|
+
else
|
|
59
|
+
delete next[id];
|
|
60
|
+
return next;
|
|
61
|
+
});
|
|
62
|
+
setSent((s) => (s.status === 'sent' ? { status: 'idle' } : s));
|
|
63
|
+
};
|
|
64
|
+
const count = items.filter((it) => done[it.id]).length;
|
|
65
|
+
const color = accent ?? 'var(--a-accent)';
|
|
66
|
+
const doSend = async () => {
|
|
67
|
+
if (!artifactId)
|
|
68
|
+
return;
|
|
69
|
+
setSent({ status: 'busy' });
|
|
70
|
+
try {
|
|
71
|
+
const r = await fetch(`/api/artifacts/${artifactId}/state`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
credentials: 'same-origin',
|
|
74
|
+
cache: 'no-store',
|
|
75
|
+
headers: { 'content-type': 'application/json' },
|
|
76
|
+
body: JSON.stringify({ slot: CHECKLIST_SLOT, op: 'set', value: { done: items.filter((it) => done[it.id]).map((it) => it.id) } }),
|
|
77
|
+
});
|
|
78
|
+
const body = (await r.json().catch(() => ({})));
|
|
79
|
+
if (r.ok)
|
|
80
|
+
setSent({ status: 'sent' });
|
|
81
|
+
else if (r.status === 401)
|
|
82
|
+
setSent({ status: 'sign-in', signIn: typeof body.signIn === 'string' ? body.signIn : null });
|
|
83
|
+
else
|
|
84
|
+
setSent({ status: 'error', message: typeof body.error === 'string' ? body.error : 'that did not send; try again' });
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
setSent({ status: 'error', message: 'that did not reach the page; try again' });
|
|
88
|
+
}
|
|
89
|
+
};
|
|
90
|
+
return (_jsxs("div", { "data-nospeak": true, "data-artifact-checklist": true, className: "my-6 rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)]", children: [_jsx("p", { "data-checklist-count": true, "aria-live": "polite", className: "m-0 border-b border-[color:var(--a-line)] px-4 py-2 text-[11px] uppercase tracking-[0.2em]", style: { color }, children: `${count} of ${items.length} done` }), _jsx("ul", { className: "m-0 list-none p-0", children: items.map((it) => {
|
|
91
|
+
const on = !!done[it.id];
|
|
92
|
+
const inputId = `checklist-${artifactId ?? 'page'}-${it.id}`;
|
|
93
|
+
return (_jsxs("li", { "data-checklist-item": it.id, "data-done": on ? 'yes' : 'no', className: "m-0 border-b border-[color:var(--a-line)] px-4 py-1 last:border-b-0", children: [_jsxs("label", { htmlFor: inputId, className: "flex cursor-pointer items-start gap-3 py-2", style: { minHeight: 44 }, children: [_jsx("input", { id: inputId, type: "checkbox", checked: on, onChange: () => toggle(it.id), className: "mt-0.5 shrink-0 cursor-pointer", style: { accentColor: color, width: 24, height: 24 } }), _jsx("span", { className: on ? 'text-[color:var(--a-muted)] line-through' : 'text-[color:var(--a-strong)]', children: it.title })] }), it.description ? _jsx("div", { className: "pb-2 pl-9 text-[15px] text-[color:var(--a-body)]", children: it.description }) : null] }, it.id));
|
|
94
|
+
}) }), send && artifactId ? (_jsxs("div", { className: "border-t border-[color:var(--a-line)] px-4 py-3", children: [_jsx("button", { type: "button", "data-checklist-send": true, disabled: sent.status === 'busy', onClick: () => void doSend(), className: "rounded-full px-5 text-sm font-medium disabled:opacity-40", style: { background: color, color: 'var(--a-on-accent, #111)', minHeight: 44 }, children: owner ? `Send my progress to ${owner}` : 'Send my progress' }), sent.status === 'sent' ? _jsx("p", { "data-checklist-sent": true, className: "mt-2 text-sm opacity-80", children: `Sent: ${count} of ${items.length} done.` }) : null, sent.status === 'error' ? _jsx("p", { className: "mt-2 text-sm text-amber-500", children: sent.message }) : null, sent.status === 'sign-in' ? (sent.signIn ? (_jsx("p", { className: "mt-2 text-sm", children: _jsx("a", { href: sent.signIn, className: "underline", style: { color }, children: "Sign in to send" }) })) : (_jsx("p", { className: "mt-2 text-sm opacity-70", children: "Sending is open to signed-in readers." }))) : null] })) : null] }));
|
|
95
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|