@supersuit/artifacts 0.21.0 → 0.23.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 +59 -0
- package/README.md +76 -3
- package/lib/artifacts/checklist.d.ts +54 -0
- package/lib/artifacts/checklist.js +160 -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 +12 -1
- package/lib/artifacts/render.js +55 -14
- package/lib/artifacts/widgets.d.ts +28 -1
- package/lib/artifacts/widgets.js +90 -6
- package/lib/routes/artifacts.js +5 -2
- package/lib/routes/state-routes.js +6 -2
- package/lib/widgets/checklist-sync.d.ts +54 -0
- package/lib/widgets/checklist-sync.js +255 -0
- package/lib/widgets/checklist.d.ts +18 -0
- package/lib/widgets/checklist.js +127 -0
- package/package.json +1 -1
|
@@ -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,32 @@ 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. */
|
|
31
|
+
export declare function checklistSendIds(markdown: string): string[] | null;
|
|
32
|
+
/** Does any checklist on this page sync? Then every checklist on it shares one set of ticks. */
|
|
33
|
+
export declare function checklistSyncs(checklists: Iterable<ChecklistBlock>): boolean;
|
|
34
|
+
/** The ids an answer in the `checklist` slot may name, or null when the page takes none: on a page
|
|
35
|
+
* that syncs, every item of every checklist on it (ids are page-wide); else the items of the
|
|
36
|
+
* checklist that offers Send. The state route asks, so a synced set can never carry an id the
|
|
37
|
+
* page does not draw. */
|
|
38
|
+
export declare function checklistAnswerIds(markdown: string): string[] | null;
|
|
39
|
+
/** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
|
|
40
|
+
* whoever `send:` names; the owner reads it through /responses. A page whose checklists `sync`
|
|
41
|
+
* declares the same slot SHARED instead, so every reader is shown every reader's set and the
|
|
42
|
+
* widget takes the newest as the page's. Like `comments:`, the slot is the widget's own, so
|
|
43
|
+
* state: may not declare one by that name. */
|
|
44
|
+
export declare function mergeChecklistState(state: StateConfig | undefined, checklists: ChecklistBlock[]): {
|
|
45
|
+
ok: true;
|
|
46
|
+
state: StateConfig | undefined;
|
|
20
47
|
} | {
|
|
21
48
|
ok: false;
|
|
22
49
|
error: string;
|
|
@@ -38,5 +65,5 @@ export declare function hasNotesWidget(markdown: string): boolean;
|
|
|
38
65
|
export declare function checkNoteValue(v: unknown): string | null;
|
|
39
66
|
/** The stable id of every top-level block, by the offset it starts at in the markdown. The
|
|
40
67
|
* 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
|
|
68
|
+
* that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
|
|
42
69
|
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,88 @@ 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
|
+
const synced = lists.find((l) => l.sync);
|
|
120
|
+
if (c.block.sync && synced && synced.sync !== c.block.sync)
|
|
121
|
+
return { ok: false, error: `line ${line}: this checklist says sync: ${c.block.sync} and the one on line ${synced.line} says sync: ${synced.sync}; a page's ticks are one set, so say it one way` };
|
|
122
|
+
if ((c.block.sync && lists.some((l) => l.send)) || (c.block.send && synced))
|
|
123
|
+
return { ok: false, error: `line ${line}: a page whose checklists sync takes no send; the synced ticks already reach the owner` };
|
|
124
|
+
lists.push(c.block);
|
|
125
|
+
}
|
|
126
|
+
const ids = assignChecklistIds(lists);
|
|
127
|
+
return ids.ok ? { ok: true, checklists: ids.blocks } : ids;
|
|
128
|
+
}
|
|
129
|
+
const fencesOf = (markdown) => {
|
|
130
|
+
const fences = [];
|
|
131
|
+
walk(parse(markdown).children, (n) => { if (n.type === 'code' && n.lang === 'checklist')
|
|
132
|
+
fences.push(n); });
|
|
133
|
+
return fences;
|
|
134
|
+
};
|
|
135
|
+
/** Every checklist on a page, by the line its fence opens on in the body, with the ids the page
|
|
136
|
+
* draws. A page whose checklists do not parse (published before checklists existed, so never
|
|
137
|
+
* validated) draws none rather than half of them. */
|
|
138
|
+
export function checklistsOf(markdown) {
|
|
139
|
+
const r = scanChecklists(fencesOf(markdown), 0);
|
|
140
|
+
return new Map(r.ok ? r.checklists.map((c) => [c.line, c]) : []);
|
|
141
|
+
}
|
|
142
|
+
/** The item ids of the page's checklist that offers Send, or null when none does. */
|
|
143
|
+
export function checklistSendIds(markdown) {
|
|
144
|
+
const r = scanChecklists(fencesOf(markdown), 0);
|
|
145
|
+
const c = r.ok ? r.checklists.find((l) => l.send) : undefined;
|
|
146
|
+
return c ? c.items.map((i) => i.id) : null;
|
|
147
|
+
}
|
|
148
|
+
/** Does any checklist on this page sync? Then every checklist on it shares one set of ticks. */
|
|
149
|
+
export function checklistSyncs(checklists) {
|
|
150
|
+
for (const c of checklists)
|
|
151
|
+
if (c.sync)
|
|
152
|
+
return true;
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
/** The ids an answer in the `checklist` slot may name, or null when the page takes none: on a page
|
|
156
|
+
* that syncs, every item of every checklist on it (ids are page-wide); else the items of the
|
|
157
|
+
* checklist that offers Send. The state route asks, so a synced set can never carry an id the
|
|
158
|
+
* page does not draw. */
|
|
159
|
+
export function checklistAnswerIds(markdown) {
|
|
160
|
+
const r = scanChecklists(fencesOf(markdown), 0);
|
|
161
|
+
if (!r.ok)
|
|
162
|
+
return null;
|
|
163
|
+
if (checklistSyncs(r.checklists))
|
|
164
|
+
return r.checklists.flatMap((c) => c.items.map((i) => i.id));
|
|
165
|
+
const c = r.checklists.find((l) => l.send);
|
|
166
|
+
return c ? c.items.map((i) => i.id) : null;
|
|
167
|
+
}
|
|
168
|
+
/** A checklist with `send` declares its own slot, `checklist`, shape one, private, written by
|
|
169
|
+
* whoever `send:` names; the owner reads it through /responses. A page whose checklists `sync`
|
|
170
|
+
* declares the same slot SHARED instead, so every reader is shown every reader's set and the
|
|
171
|
+
* widget takes the newest as the page's. Like `comments:`, the slot is the widget's own, so
|
|
172
|
+
* state: may not declare one by that name. */
|
|
173
|
+
export function mergeChecklistState(state, checklists) {
|
|
174
|
+
const c = checklists.find((l) => l.sync) ?? checklists.find((l) => l.send);
|
|
175
|
+
if (!c)
|
|
176
|
+
return { ok: true, state };
|
|
177
|
+
const what = c.sync ? 'sync' : 'send';
|
|
178
|
+
if (state && Object.hasOwn(state.slots, CHECKLIST_SLOT))
|
|
179
|
+
return { ok: false, error: `line ${c.line}: the checklist's ${what} writes to slot "${CHECKLIST_SLOT}", and state: declares a slot by that name; rename that slot` };
|
|
180
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
181
|
+
const slot = c.sync
|
|
182
|
+
? { shape: 'one', visibility: 'shared', writers: c.sync }
|
|
183
|
+
: { shape: 'one', visibility: 'private', writers: c.send };
|
|
184
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [CHECKLIST_SLOT]: slot } } };
|
|
101
185
|
}
|
|
102
186
|
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
103
187
|
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
@@ -157,7 +241,7 @@ function assetName(url) {
|
|
|
157
241
|
}
|
|
158
242
|
/** The stable id of every top-level block, by the offset it starts at in the markdown. The
|
|
159
243
|
* 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
|
|
244
|
+
* that draw no prose (a notes, links or checklist fence, a rule, raw html) get none. */
|
|
161
245
|
export function blocksOf(markdown) {
|
|
162
246
|
const out = new Map();
|
|
163
247
|
const seen = new Map();
|
|
@@ -195,7 +279,7 @@ export function blocksOf(markdown) {
|
|
|
195
279
|
content = plain(n);
|
|
196
280
|
}
|
|
197
281
|
else if (n.type === 'code') {
|
|
198
|
-
if (n.lang === 'links' || n.lang === NOTES_SLOT)
|
|
282
|
+
if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist')
|
|
199
283
|
continue;
|
|
200
284
|
kind = 'code';
|
|
201
285
|
content = n.value;
|
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';
|
|
@@ -302,7 +303,7 @@ export function createArtifactRoutes(config) {
|
|
|
302
303
|
const title = v.title ?? a.title;
|
|
303
304
|
const summary = v.summary ?? a.summary;
|
|
304
305
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
305
|
-
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, 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 }) })] }));
|
|
306
307
|
}
|
|
307
308
|
/** The page itself, shared by open and gated pages. */
|
|
308
309
|
async function Body(a, { top, reader }) {
|
|
@@ -325,7 +326,9 @@ export function createArtifactRoutes(config) {
|
|
|
325
326
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
326
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] }));
|
|
327
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] }));
|
|
328
|
-
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 }) }));
|
|
329
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,
|
|
330
333
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
331
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, checklistAnswerIds, 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 ? checklistAnswerIds(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,54 @@
|
|
|
1
|
+
export type SyncStatus = 'loading' | 'synced' | 'saving' | 'error' | 'sign-in' | 'local';
|
|
2
|
+
export type SyncSnapshot = {
|
|
3
|
+
done: ReadonlySet<string>;
|
|
4
|
+
status: SyncStatus;
|
|
5
|
+
message?: string;
|
|
6
|
+
signIn?: string | null;
|
|
7
|
+
};
|
|
8
|
+
export declare const SYNC_POLL_MS = 4000;
|
|
9
|
+
export declare const SYNC_SAVE_DELAY_MS = 400;
|
|
10
|
+
/** The page's set from a state answer: the newest shared entry's `done`, or null when the answer
|
|
11
|
+
* carries no checklist slot. An empty slot is an empty set. */
|
|
12
|
+
export declare function latestSharedDone(body: unknown): string[] | null;
|
|
13
|
+
declare class SyncStore {
|
|
14
|
+
private artifactId;
|
|
15
|
+
private blocks;
|
|
16
|
+
private done;
|
|
17
|
+
private status;
|
|
18
|
+
private message?;
|
|
19
|
+
private signIn?;
|
|
20
|
+
private snap;
|
|
21
|
+
private listeners;
|
|
22
|
+
private dirty;
|
|
23
|
+
private inflight;
|
|
24
|
+
private saveTimer;
|
|
25
|
+
private pollTimer;
|
|
26
|
+
private onVisible;
|
|
27
|
+
constructor(artifactId: string);
|
|
28
|
+
private make;
|
|
29
|
+
private emit;
|
|
30
|
+
private set;
|
|
31
|
+
private visible;
|
|
32
|
+
private url;
|
|
33
|
+
/** Every id a checklist on the page draws. A set from an earlier version can name an item the
|
|
34
|
+
* page no longer has, and the server refuses a set naming one, so only these are posted. */
|
|
35
|
+
private known;
|
|
36
|
+
snapshot: () => SyncSnapshot;
|
|
37
|
+
subscribe: (l: () => void) => () => void;
|
|
38
|
+
register(block: object, ids: readonly string[]): void;
|
|
39
|
+
unregister(block: object): void;
|
|
40
|
+
private start;
|
|
41
|
+
private stop;
|
|
42
|
+
private toLocal;
|
|
43
|
+
/** Read the page's set. Skipped while this reader has a change not yet saved, so a poll can
|
|
44
|
+
* never take back a tick the reader just made. */
|
|
45
|
+
pull(): Promise<void>;
|
|
46
|
+
toggle(id: string): void;
|
|
47
|
+
private schedule;
|
|
48
|
+
/** Post the whole page-wide set. Last write wins on the whole set (see the top of this file). */
|
|
49
|
+
push(): Promise<void>;
|
|
50
|
+
}
|
|
51
|
+
/** The one store for a page, shared by every checklist block on it. */
|
|
52
|
+
export declare function syncStoreFor(artifactId: string): SyncStore;
|
|
53
|
+
export declare const SERVER_SYNC_SNAPSHOT: SyncSnapshot;
|
|
54
|
+
export {};
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// The shared set behind a page whose checklists `sync`: one store per page, held by every
|
|
3
|
+
// checklist block on it, because item ids are page-wide and the host keeps ONE set for the page
|
|
4
|
+
// (the shared `one` slot `checklist`, where each writer's latest set is an entry).
|
|
5
|
+
//
|
|
6
|
+
// The newest entry by its server time is the page's set. A tick updates the screen at once and,
|
|
7
|
+
// after a short pause, posts the WHOLE page-wide set; a poll every few seconds while the tab is
|
|
8
|
+
// visible brings in the other readers' ticks. Concurrency is last-write-wins on the whole set: two
|
|
9
|
+
// people ticking different items inside the same couple of seconds can lose one of the two ticks.
|
|
10
|
+
// That is accepted for a list two people walk through together; a per-item merge would need the
|
|
11
|
+
// host to keep per-item state, which this version does not.
|
|
12
|
+
//
|
|
13
|
+
// A host that keeps no answers (501), a page that takes none (404), or a first read that fails
|
|
14
|
+
// drops the store to `local`: ticks live in localStorage exactly as an unsynced checklist's do.
|
|
15
|
+
import { CHECKLIST_SLOT, checklistStorageKey } from '../artifacts/checklist.js';
|
|
16
|
+
export const SYNC_POLL_MS = 4000;
|
|
17
|
+
export const SYNC_SAVE_DELAY_MS = 400;
|
|
18
|
+
const RETRY_MS = 5000;
|
|
19
|
+
/** The page's set from a state answer: the newest shared entry's `done`, or null when the answer
|
|
20
|
+
* carries no checklist slot. An empty slot is an empty set. */
|
|
21
|
+
export function latestSharedDone(body) {
|
|
22
|
+
const slot = body?.slots?.[CHECKLIST_SLOT];
|
|
23
|
+
if (!slot)
|
|
24
|
+
return null;
|
|
25
|
+
let best = null;
|
|
26
|
+
for (const e of slot.shared ?? [])
|
|
27
|
+
if (typeof e?.at === 'string' && (!best || e.at > best.at))
|
|
28
|
+
best = e;
|
|
29
|
+
const done = best?.value?.done;
|
|
30
|
+
return Array.isArray(done) ? done.filter((d) => typeof d === 'string') : [];
|
|
31
|
+
}
|
|
32
|
+
function readLocal(artifactId, id) {
|
|
33
|
+
try {
|
|
34
|
+
return globalThis.localStorage?.getItem(checklistStorageKey(artifactId, id)) === '1';
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
function writeLocal(artifactId, id, on) {
|
|
41
|
+
try {
|
|
42
|
+
if (on)
|
|
43
|
+
globalThis.localStorage?.setItem(checklistStorageKey(artifactId, id), '1');
|
|
44
|
+
else
|
|
45
|
+
globalThis.localStorage?.removeItem(checklistStorageKey(artifactId, id));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Storage blocked: the tick lives for this visit only.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
class SyncStore {
|
|
52
|
+
artifactId;
|
|
53
|
+
blocks = new Map();
|
|
54
|
+
done = new Set();
|
|
55
|
+
status = 'loading';
|
|
56
|
+
message;
|
|
57
|
+
signIn;
|
|
58
|
+
snap;
|
|
59
|
+
listeners = new Set();
|
|
60
|
+
dirty = false;
|
|
61
|
+
inflight = false;
|
|
62
|
+
saveTimer = null;
|
|
63
|
+
pollTimer = null;
|
|
64
|
+
onVisible = () => { if (this.visible())
|
|
65
|
+
void this.pull(); };
|
|
66
|
+
constructor(artifactId) {
|
|
67
|
+
this.artifactId = artifactId;
|
|
68
|
+
this.snap = this.make();
|
|
69
|
+
}
|
|
70
|
+
make() {
|
|
71
|
+
return { done: new Set(this.done), status: this.status, ...(this.message ? { message: this.message } : {}), ...(this.signIn !== undefined ? { signIn: this.signIn } : {}) };
|
|
72
|
+
}
|
|
73
|
+
emit() {
|
|
74
|
+
this.snap = this.make();
|
|
75
|
+
for (const l of this.listeners)
|
|
76
|
+
l();
|
|
77
|
+
}
|
|
78
|
+
set(status, message, signIn) {
|
|
79
|
+
this.status = status;
|
|
80
|
+
this.message = message;
|
|
81
|
+
this.signIn = signIn;
|
|
82
|
+
}
|
|
83
|
+
visible() {
|
|
84
|
+
return typeof document === 'undefined' || document.visibilityState !== 'hidden';
|
|
85
|
+
}
|
|
86
|
+
url() {
|
|
87
|
+
return `/api/artifacts/${this.artifactId}/state`;
|
|
88
|
+
}
|
|
89
|
+
/** Every id a checklist on the page draws. A set from an earlier version can name an item the
|
|
90
|
+
* page no longer has, and the server refuses a set naming one, so only these are posted. */
|
|
91
|
+
known() {
|
|
92
|
+
const out = new Set();
|
|
93
|
+
for (const ids of this.blocks.values())
|
|
94
|
+
for (const id of ids)
|
|
95
|
+
out.add(id);
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
snapshot = () => this.snap;
|
|
99
|
+
subscribe = (l) => {
|
|
100
|
+
this.listeners.add(l);
|
|
101
|
+
return () => { this.listeners.delete(l); };
|
|
102
|
+
};
|
|
103
|
+
register(block, ids) {
|
|
104
|
+
// Back in the map after a stop (React's strict mode unmounts and remounts once).
|
|
105
|
+
if (!stores.has(this.artifactId))
|
|
106
|
+
stores.set(this.artifactId, this);
|
|
107
|
+
this.blocks.set(block, ids);
|
|
108
|
+
if (this.status === 'local') {
|
|
109
|
+
for (const id of ids)
|
|
110
|
+
if (readLocal(this.artifactId, id))
|
|
111
|
+
this.done.add(id);
|
|
112
|
+
this.emit();
|
|
113
|
+
}
|
|
114
|
+
if (this.blocks.size === 1)
|
|
115
|
+
this.start();
|
|
116
|
+
}
|
|
117
|
+
unregister(block) {
|
|
118
|
+
this.blocks.delete(block);
|
|
119
|
+
if (this.blocks.size === 0)
|
|
120
|
+
this.stop();
|
|
121
|
+
}
|
|
122
|
+
start() {
|
|
123
|
+
void this.pull();
|
|
124
|
+
if (typeof document !== 'undefined')
|
|
125
|
+
document.addEventListener('visibilitychange', this.onVisible);
|
|
126
|
+
this.pollTimer = setInterval(() => { if (this.visible())
|
|
127
|
+
void this.pull(); }, SYNC_POLL_MS);
|
|
128
|
+
}
|
|
129
|
+
stop() {
|
|
130
|
+
if (this.pollTimer)
|
|
131
|
+
clearInterval(this.pollTimer);
|
|
132
|
+
if (this.saveTimer)
|
|
133
|
+
clearTimeout(this.saveTimer);
|
|
134
|
+
this.pollTimer = this.saveTimer = null;
|
|
135
|
+
if (typeof document !== 'undefined')
|
|
136
|
+
document.removeEventListener('visibilitychange', this.onVisible);
|
|
137
|
+
stores.delete(this.artifactId);
|
|
138
|
+
}
|
|
139
|
+
toLocal() {
|
|
140
|
+
if (this.pollTimer)
|
|
141
|
+
clearInterval(this.pollTimer);
|
|
142
|
+
this.pollTimer = null;
|
|
143
|
+
this.done = new Set([...this.known()].filter((id) => readLocal(this.artifactId, id)));
|
|
144
|
+
this.set('local');
|
|
145
|
+
this.emit();
|
|
146
|
+
}
|
|
147
|
+
/** Read the page's set. Skipped while this reader has a change not yet saved, so a poll can
|
|
148
|
+
* never take back a tick the reader just made. */
|
|
149
|
+
async pull() {
|
|
150
|
+
if (this.status === 'local' || this.dirty || this.inflight)
|
|
151
|
+
return;
|
|
152
|
+
let r;
|
|
153
|
+
try {
|
|
154
|
+
r = await fetch(this.url(), { credentials: 'same-origin', cache: 'no-store' });
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
if (this.status === 'loading')
|
|
158
|
+
return this.toLocal();
|
|
159
|
+
this.set('error', 'offline; will retry');
|
|
160
|
+
return this.emit();
|
|
161
|
+
}
|
|
162
|
+
if (r.status === 501 || r.status === 404)
|
|
163
|
+
return this.toLocal();
|
|
164
|
+
const body = await r.json().catch(() => null);
|
|
165
|
+
if (!r.ok) {
|
|
166
|
+
if (this.status === 'loading')
|
|
167
|
+
return this.toLocal();
|
|
168
|
+
this.set('error', 'could not read the shared list; will retry');
|
|
169
|
+
return this.emit();
|
|
170
|
+
}
|
|
171
|
+
const done = latestSharedDone(body);
|
|
172
|
+
if (done === null)
|
|
173
|
+
return this.toLocal();
|
|
174
|
+
if (this.dirty || this.inflight)
|
|
175
|
+
return;
|
|
176
|
+
this.done = new Set(done);
|
|
177
|
+
if (this.status !== 'sign-in')
|
|
178
|
+
this.set('synced');
|
|
179
|
+
this.emit();
|
|
180
|
+
}
|
|
181
|
+
toggle(id) {
|
|
182
|
+
const on = !this.done.has(id);
|
|
183
|
+
if (on)
|
|
184
|
+
this.done.add(id);
|
|
185
|
+
else
|
|
186
|
+
this.done.delete(id);
|
|
187
|
+
writeLocal(this.artifactId, id, on);
|
|
188
|
+
if (this.status === 'local')
|
|
189
|
+
return this.emit();
|
|
190
|
+
this.dirty = true;
|
|
191
|
+
this.set('saving');
|
|
192
|
+
this.emit();
|
|
193
|
+
this.schedule(SYNC_SAVE_DELAY_MS);
|
|
194
|
+
}
|
|
195
|
+
schedule(ms) {
|
|
196
|
+
if (this.saveTimer)
|
|
197
|
+
clearTimeout(this.saveTimer);
|
|
198
|
+
this.saveTimer = setTimeout(() => { this.saveTimer = null; void this.push(); }, ms);
|
|
199
|
+
}
|
|
200
|
+
/** Post the whole page-wide set. Last write wins on the whole set (see the top of this file). */
|
|
201
|
+
async push() {
|
|
202
|
+
if (this.inflight || !this.dirty)
|
|
203
|
+
return;
|
|
204
|
+
this.inflight = true;
|
|
205
|
+
this.dirty = false;
|
|
206
|
+
const known = this.known();
|
|
207
|
+
const done = [...this.done].filter((id) => known.has(id));
|
|
208
|
+
let r = null;
|
|
209
|
+
let body = null;
|
|
210
|
+
try {
|
|
211
|
+
r = await fetch(this.url(), {
|
|
212
|
+
method: 'POST',
|
|
213
|
+
credentials: 'same-origin',
|
|
214
|
+
cache: 'no-store',
|
|
215
|
+
headers: { 'content-type': 'application/json' },
|
|
216
|
+
body: JSON.stringify({ slot: CHECKLIST_SLOT, op: 'set', value: { done } }),
|
|
217
|
+
});
|
|
218
|
+
body = (await r.json().catch(() => null));
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
r = null;
|
|
222
|
+
}
|
|
223
|
+
this.inflight = false;
|
|
224
|
+
if (r?.ok) {
|
|
225
|
+
if (this.dirty)
|
|
226
|
+
return this.schedule(SYNC_SAVE_DELAY_MS);
|
|
227
|
+
const latest = latestSharedDone(body);
|
|
228
|
+
if (latest)
|
|
229
|
+
this.done = new Set(latest);
|
|
230
|
+
this.set('synced');
|
|
231
|
+
}
|
|
232
|
+
else if (r?.status === 401) {
|
|
233
|
+
this.set('sign-in', undefined, typeof body?.signIn === 'string' ? body.signIn : null);
|
|
234
|
+
}
|
|
235
|
+
else if (r && r.status >= 400 && r.status < 500 && r.status !== 429) {
|
|
236
|
+
this.set('error', typeof body?.error === 'string' ? body.error : 'that tick did not save');
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// Offline, rate-limited or a server error: keep the tick and try again shortly.
|
|
240
|
+
this.dirty = true;
|
|
241
|
+
this.set('error', 'not saved yet; retrying');
|
|
242
|
+
this.schedule(RETRY_MS);
|
|
243
|
+
}
|
|
244
|
+
this.emit();
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
const stores = new Map();
|
|
248
|
+
/** The one store for a page, shared by every checklist block on it. */
|
|
249
|
+
export function syncStoreFor(artifactId) {
|
|
250
|
+
let s = stores.get(artifactId);
|
|
251
|
+
if (!s)
|
|
252
|
+
stores.set(artifactId, (s = new SyncStore(artifactId)));
|
|
253
|
+
return s;
|
|
254
|
+
}
|
|
255
|
+
export const SERVER_SYNC_SNAPSHOT = { done: new Set(), status: 'loading' };
|
|
@@ -0,0 +1,18 @@
|
|
|
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, sync, 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
|
+
/** The page's ticks are one shared set the host keeps (a page whose checklists `sync`). */
|
|
15
|
+
sync?: boolean;
|
|
16
|
+
owner?: string;
|
|
17
|
+
accent?: string;
|
|
18
|
+
}): import("react").JSX.Element;
|