@supersuit/artifacts 0.2.0 → 0.3.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 +39 -0
- package/README.md +51 -1
- package/lib/artifacts/front-matter.js +13 -0
- package/lib/artifacts/index.d.ts +1 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/notes-place.d.ts +27 -0
- package/lib/artifacts/notes-place.js +25 -0
- package/lib/artifacts/render.d.ts +7 -1
- package/lib/artifacts/render.js +41 -3
- package/lib/artifacts/state-store.d.ts +3 -0
- package/lib/artifacts/state-store.js +2 -0
- package/lib/artifacts/widgets.d.ts +38 -0
- package/lib/artifacts/widgets.js +141 -0
- package/lib/routes/artifacts.js +11 -5
- package/lib/routes/state-routes.js +5 -2
- package/lib/widgets/notes.d.ts +21 -0
- package/lib/widgets/notes.js +134 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,45 @@
|
|
|
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.3.0 (2026-09-25)
|
|
7
|
+
|
|
8
|
+
**Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
|
|
9
|
+
beside every heading, so readers can leave notes on a section and, on a `shared` page, read each
|
|
10
|
+
other's. Poll, form and checklist follow in a later release.
|
|
11
|
+
|
|
12
|
+
- **Fence:** ```` ```notes ```` with an optional `visibility: private | shared` line. At most one
|
|
13
|
+
per page. It declares slot `notes` of shape `many` itself, merged into `state:` at parse time
|
|
14
|
+
(a page with no `state:` gets `writers: signed-in`, `visibility: private`), so the 0.2.0 state
|
|
15
|
+
API takes notes unchanged. Refused at publish with the file's line number: a second block, a
|
|
16
|
+
name, an unknown key, `tally`, a `notes` slot declared `one`, a visibility that disagrees with
|
|
17
|
+
`state:`. `poll`, `form` and `checklist` fences are refused until they ship.
|
|
18
|
+
- **A note is `{ slug, heading, note }`**, checked by the server on a page with a notes block.
|
|
19
|
+
Headings carry their GitHub-style slug as an `id`; the renderer finds each heading by its source
|
|
20
|
+
line, so the slug on the page and the slug a note stores come from one derivation.
|
|
21
|
+
- **A renamed or removed heading keeps its notes**, shown under "Notes on earlier versions" with
|
|
22
|
+
the heading they were left under, never attached to another section.
|
|
23
|
+
- **Narration skips the widget**: the fence is not spoken and everything drawn is `data-nospeak`.
|
|
24
|
+
- **Password pages with `state:`** now set the answer API's unlock cookie on every open, not only
|
|
25
|
+
on `?key=`. Readers who unlocked such a page before 0.2.0 are no longer refused (the item parked
|
|
26
|
+
in 0.2.0).
|
|
27
|
+
- **The replace-at-cap check** reads one document (`StateStore.hasOne`, optional, implemented by
|
|
28
|
+
both shipped stores) instead of every answer on the page.
|
|
29
|
+
- **DETECTOR:** a page with a notes block published to a host with no state store gets `warning:
|
|
30
|
+
this host keeps no answers` and draws no controls. A notes page whose readers see no "note"
|
|
31
|
+
button beside its headings is on 0.2.0 or earlier.
|
|
32
|
+
- **REMEDY (hosts):** take 0.3.0. No new routes: the notes widget uses the 0.2.0 state routes
|
|
33
|
+
(`app/api/artifacts/[id]/state/route.ts`), which a host must already mount. A host with its own
|
|
34
|
+
`StateStore` may add `hasOne`; without it the old full read is used.
|
|
35
|
+
- **Tests:** `widgets.test.ts` (slugs, fence parsing, one per page, reserved widgets, merge,
|
|
36
|
+
note shape, placement), `front-matter.test.ts` (line numbers in the file, the first real page's
|
|
37
|
+
front matter), `render.test.tsx` (controls beside headings, fence never drawn as code),
|
|
38
|
+
`widgets/notes.test.tsx` (jsdom: shared notes under their heading, earlier versions, posting
|
|
39
|
+
appends `{ slug, heading, note }`, sign-in offer, removal, no-store host), `narration.test.ts`,
|
|
40
|
+
`state.test.tsx` (note shape refused, shared read, `hasOne` path), `artifacts.test.tsx` (API
|
|
41
|
+
cookie on every open, page wiring, publish refusal), `state-store.test.ts` (`hasOne`), and the
|
|
42
|
+
packed fixture (a notes page draws its controls, a note posts and reads back, a malformed one is
|
|
43
|
+
refused, the compiled widget keeps `'use client'`).
|
|
44
|
+
|
|
6
45
|
## 0.2.0 (2026-09-24)
|
|
7
46
|
|
|
8
47
|
**Reader answers.** A page can now take input from the people reading it. It declares named
|
package/README.md
CHANGED
|
@@ -25,7 +25,8 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
25
25
|
| `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
|
|
26
26
|
| `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
|
-
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing |
|
|
28
|
+
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
29
|
+
| `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
|
|
29
30
|
| `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
|
|
30
31
|
| `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
|
|
31
32
|
|
|
@@ -285,6 +286,55 @@ On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used
|
|
|
285
286
|
rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
|
|
286
287
|
the real client IP but another proxy may only append to.
|
|
287
288
|
|
|
289
|
+
## Widgets in a page
|
|
290
|
+
|
|
291
|
+
A widget is a fenced block in a page's markdown that gives readers a place to answer, drawn by
|
|
292
|
+
the shell in the page's brand and kept through the state API above. Widgets need a host with a
|
|
293
|
+
state store (`state:` in the config); on a host without one they draw nothing, and the publish
|
|
294
|
+
response carries the no-answers warning.
|
|
295
|
+
|
|
296
|
+
This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
|
|
297
|
+
`poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
|
|
298
|
+
block that turns into a live widget on a later update.
|
|
299
|
+
|
|
300
|
+
### Notes
|
|
301
|
+
|
|
302
|
+
````markdown
|
|
303
|
+
## Sales
|
|
304
|
+
|
|
305
|
+
The weekly pipeline review.
|
|
306
|
+
|
|
307
|
+
```notes
|
|
308
|
+
visibility: shared # optional: private | shared
|
|
309
|
+
```
|
|
310
|
+
````
|
|
311
|
+
|
|
312
|
+
- **A small "note" control sits beside every heading.** A reader opens it, writes, and saves. The
|
|
313
|
+
note appears under that heading; with `visibility: shared` every reader sees every note with
|
|
314
|
+
the writer's first name ("a reader" for a signed-out one), with `private` each reader sees only
|
|
315
|
+
their own and the publisher sees all through `/responses`.
|
|
316
|
+
- **At most one notes block per page.** Where it sits is where the page shows "Notes on earlier
|
|
317
|
+
versions" (below).
|
|
318
|
+
- **The block declares its own slot**: `notes`, shape `many`. A page needs no `state:` for it;
|
|
319
|
+
without one the page takes `writers: signed-in` and `visibility: private`. A page may declare
|
|
320
|
+
the slot itself (`slots: { notes: { shape: many } }`) and set `writers:` and `visibility:` as
|
|
321
|
+
usual. Refused at publish, naming the line in the file: a second notes block, a name after
|
|
322
|
+
`notes`, any key but `visibility`, `visibility: tally`, a `notes` slot declared `shape: one`,
|
|
323
|
+
and a block visibility that disagrees with one `state:` sets on the `notes` slot.
|
|
324
|
+
- **A note is `{ slug, heading, note }`**: the slug and the text of the heading it was left under,
|
|
325
|
+
and up to 4,000 characters of text. On a page with a notes block the server refuses any other
|
|
326
|
+
shape in the `notes` slot. Slugs follow GitHub's rule (lowercase, punctuation dropped, spaces
|
|
327
|
+
to dashes, a repeated heading `-1`, `-2`), and each heading carries its slug as its `id`, so
|
|
328
|
+
`#sales` links to it.
|
|
329
|
+
- **Notes survive a changed heading.** A note shows under the heading with its slug; failing
|
|
330
|
+
that, under a heading with exactly its text; otherwise under "Notes on earlier versions", with
|
|
331
|
+
the heading it was left under. It never attaches to a different section.
|
|
332
|
+
- **Narration skips it.** The fence is not prose, and everything the widget draws is marked
|
|
333
|
+
`data-nospeak`, so the narrator and the read-along highlighter read the page as before.
|
|
334
|
+
- On a gated page (`access:`) only signed-in readers the page is open to, after the agreement,
|
|
335
|
+
can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
|
|
336
|
+
page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
|
|
337
|
+
|
|
288
338
|
## Brand packs
|
|
289
339
|
|
|
290
340
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -3,6 +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
7
|
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
|
|
7
8
|
export function parseArtifactSource(text) {
|
|
8
9
|
const { data, content } = matter(text);
|
|
@@ -40,5 +41,17 @@ export function parseArtifactSource(text) {
|
|
|
40
41
|
return { ok: false, error: s.error };
|
|
41
42
|
meta.state = s.state;
|
|
42
43
|
}
|
|
44
|
+
// Widgets declare their own slots, so a notes block is merged into state: here, before the
|
|
45
|
+
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
46
|
+
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
|
47
|
+
const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
|
|
48
|
+
const w = scanWidgets(content, offset);
|
|
49
|
+
if (!w.ok)
|
|
50
|
+
return { ok: false, error: w.error };
|
|
51
|
+
const merged = mergeWidgetState(meta.state, w.notes);
|
|
52
|
+
if (!merged.ok)
|
|
53
|
+
return { ok: false, error: merged.error };
|
|
54
|
+
if (merged.state)
|
|
55
|
+
meta.state = merged.state;
|
|
43
56
|
return { ok: true, meta, body: content };
|
|
44
57
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
|
|
|
10
10
|
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';
|
|
11
11
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
12
12
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
13
|
+
export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
|
10
10
|
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';
|
|
11
11
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
12
12
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
13
|
+
export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const NOTES_SLOT = "notes";
|
|
2
|
+
export declare const MAX_NOTE_CHARS = 4000;
|
|
3
|
+
export type Heading = {
|
|
4
|
+
depth: number;
|
|
5
|
+
line: number;
|
|
6
|
+
text: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
export type NoteValue = {
|
|
10
|
+
slug: string;
|
|
11
|
+
heading: string;
|
|
12
|
+
note: string;
|
|
13
|
+
};
|
|
14
|
+
/** A note as a reader's page holds it: the stored value plus who and when. */
|
|
15
|
+
export type PlacedNote = NoteValue & {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
at: string;
|
|
19
|
+
mine: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Where each note shows: under the heading with its slug, else under a heading with its exact
|
|
22
|
+
* text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
|
|
23
|
+
* heading that merely looks close: a note on the wrong section is worse than one set aside. */
|
|
24
|
+
export declare function placeNotes(headings: Pick<Heading, 'slug' | 'text'>[], notes: PlacedNote[]): {
|
|
25
|
+
bySlug: Record<string, PlacedNote[]>;
|
|
26
|
+
earlier: PlacedNote[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Where each note on a page shows, and the few names both sides share. Kept free of the
|
|
2
|
+
// markdown parser on purpose: the notes widget runs in the reader's browser and imports this,
|
|
3
|
+
// and the parser has no business in that bundle.
|
|
4
|
+
export const NOTES_SLOT = 'notes';
|
|
5
|
+
export const MAX_NOTE_CHARS = 4000;
|
|
6
|
+
/** Where each note shows: under the heading with its slug, else under a heading with its exact
|
|
7
|
+
* text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
|
|
8
|
+
* heading that merely looks close: a note on the wrong section is worse than one set aside. */
|
|
9
|
+
export function placeNotes(headings, notes) {
|
|
10
|
+
const bySlug = {};
|
|
11
|
+
const earlier = [];
|
|
12
|
+
const slugs = new Set(headings.map((h) => h.slug));
|
|
13
|
+
const byText = new Map();
|
|
14
|
+
for (const h of headings)
|
|
15
|
+
if (!byText.has(h.text))
|
|
16
|
+
byText.set(h.text, h.slug);
|
|
17
|
+
for (const n of notes) {
|
|
18
|
+
const at = slugs.has(n.slug) ? n.slug : byText.get(n.heading);
|
|
19
|
+
if (at)
|
|
20
|
+
(bySlug[at] ??= []).push(n);
|
|
21
|
+
else
|
|
22
|
+
earlier.push(n);
|
|
23
|
+
}
|
|
24
|
+
return { bySlug, earlier };
|
|
25
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export declare function isReachableHref(href: unknown): href is string;
|
|
2
|
-
|
|
2
|
+
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
3
|
+
* page carries a ```notes block. */
|
|
4
|
+
export declare function ArtifactMarkdown({ markdown, notes }: {
|
|
3
5
|
markdown: string;
|
|
6
|
+
notes?: {
|
|
7
|
+
artifactId: string;
|
|
8
|
+
accent?: string;
|
|
9
|
+
};
|
|
4
10
|
}): import("react").JSX.Element;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import ReactMarkdown from 'react-markdown';
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import { hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
|
+
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
4
6
|
const GOLD = '#C2A15C';
|
|
5
7
|
function hostOf(url) {
|
|
6
8
|
try {
|
|
@@ -61,6 +63,10 @@ const components = {
|
|
|
61
63
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
62
64
|
if (lang === 'links')
|
|
63
65
|
return _jsx(LinkCards, { source: textOf(children) });
|
|
66
|
+
// A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
|
|
67
|
+
// answers) it draws nothing: its settings are not prose and never shown as code.
|
|
68
|
+
if (lang === 'notes')
|
|
69
|
+
return null;
|
|
64
70
|
if (!className) {
|
|
65
71
|
return _jsx("code", { className: "rounded bg-white/10 px-1.5 py-0.5 text-[0.9em] text-zinc-100", children: children });
|
|
66
72
|
}
|
|
@@ -70,7 +76,7 @@ const components = {
|
|
|
70
76
|
// A links fence renders its own block; do not wrap it in <pre>.
|
|
71
77
|
const inner = Array.isArray(children) ? children[0] : children;
|
|
72
78
|
const cls = inner?.props?.className ?? '';
|
|
73
|
-
if (/language-links/.test(cls))
|
|
79
|
+
if (/language-(links|notes)/.test(cls))
|
|
74
80
|
return _jsx(_Fragment, { children: children });
|
|
75
81
|
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-4 text-sm text-zinc-100", children: children }));
|
|
76
82
|
},
|
|
@@ -85,6 +91,38 @@ const components = {
|
|
|
85
91
|
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'rgba(255,255,255,0.03)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-zinc-100", children: body })] }));
|
|
86
92
|
},
|
|
87
93
|
};
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
const HEADING_CLASS = {
|
|
95
|
+
1: 'mt-10 mb-4 font-serif text-3xl text-zinc-50',
|
|
96
|
+
2: 'mt-10 mb-3 font-serif text-2xl text-zinc-50',
|
|
97
|
+
3: 'mt-8 mb-2 text-lg font-semibold text-zinc-100',
|
|
98
|
+
4: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
99
|
+
5: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
100
|
+
6: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
101
|
+
};
|
|
102
|
+
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
103
|
+
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
104
|
+
* one headingsOf derived, the same one a note stores. */
|
|
105
|
+
function notesComponents(headings) {
|
|
106
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
107
|
+
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
108
|
+
const Tag = `h${depth}`;
|
|
109
|
+
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
110
|
+
if (!h)
|
|
111
|
+
return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
|
|
112
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
|
|
113
|
+
};
|
|
114
|
+
const Code = components.code;
|
|
115
|
+
return {
|
|
116
|
+
...components,
|
|
117
|
+
h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6),
|
|
118
|
+
code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
122
|
+
* page carries a ```notes block. */
|
|
123
|
+
export function ArtifactMarkdown({ markdown, notes }) {
|
|
124
|
+
const on = !!notes && hasNotesWidget(markdown);
|
|
125
|
+
const headings = on ? headingsOf(markdown) : [];
|
|
126
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : components, children: markdown }));
|
|
127
|
+
return (_jsx("div", { className: "text-[17px] text-zinc-200", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
|
|
90
128
|
}
|
|
@@ -49,6 +49,9 @@ export interface StateStore {
|
|
|
49
49
|
countAnonWrite(artifactId: string, ipHash: string, minute: number): Promise<number>;
|
|
50
50
|
/** Every reader's entries in one slot on one page, for MAX_ENTRIES_PER_SLOT. */
|
|
51
51
|
countSlot(artifactId: string, slot: string): Promise<number>;
|
|
52
|
+
/** Does this reader already hold a `one` answer in this slot? One document read. Optional so a
|
|
53
|
+
* host's own store keeps working; without it the route falls back to loading the page. */
|
|
54
|
+
hasOne?(artifactId: string, slot: string, readerKey: string): Promise<boolean>;
|
|
52
55
|
}
|
|
53
56
|
export declare const readerKeyFor: {
|
|
54
57
|
signedIn: (uid: string) => string;
|
|
@@ -89,6 +89,7 @@ export function createStateStore(db, base) {
|
|
|
89
89
|
async countSlot(artifactId, slot) {
|
|
90
90
|
return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
|
|
91
91
|
},
|
|
92
|
+
hasOne: async (artifactId, slot, readerKey) => (await col().doc(oneId(artifactId, slot, readerKey)).get()).exists,
|
|
92
93
|
};
|
|
93
94
|
return store;
|
|
94
95
|
}
|
|
@@ -147,5 +148,6 @@ export function createMemoryStateStore() {
|
|
|
147
148
|
return n;
|
|
148
149
|
},
|
|
149
150
|
countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
|
|
151
|
+
hasOne: async (artifactId, slot, readerKey) => docs.has(oneId(artifactId, slot, readerKey)),
|
|
150
152
|
};
|
|
151
153
|
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateConfig, Visibility } from './state.js';
|
|
2
|
+
import { type Heading } from './notes-place.js';
|
|
3
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
|
|
4
|
+
export type NotesWidget = {
|
|
5
|
+
line: number;
|
|
6
|
+
visibility?: Exclude<Visibility, 'tally'>;
|
|
7
|
+
};
|
|
8
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
9
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
10
|
+
export declare function slugify(text: string): string;
|
|
11
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
12
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
13
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
14
|
+
export declare function headingsOf(markdown: string): Heading[];
|
|
15
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
16
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
17
|
+
export declare function scanWidgets(body: string, offset: number): {
|
|
18
|
+
ok: true;
|
|
19
|
+
notes: NotesWidget | null;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
25
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
26
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
27
|
+
export declare function mergeWidgetState(state: StateConfig | undefined, notes: NotesWidget | null): {
|
|
28
|
+
ok: true;
|
|
29
|
+
state: StateConfig | undefined;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
35
|
+
export declare function hasNotesWidget(markdown: string): boolean;
|
|
36
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
37
|
+
* the one that counts. */
|
|
38
|
+
export declare function checkNoteValue(v: unknown): string | null;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
|
|
2
|
+
// the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
|
|
3
|
+
//
|
|
4
|
+
// This version draws ONE widget, `notes`: a small note control beside every heading, kept in the
|
|
5
|
+
// `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
|
|
6
|
+
// so a republish that renames or removes the heading keeps the note and shows it under "notes
|
|
7
|
+
// on earlier versions" instead of losing it or attaching it to the wrong section.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is pure and server-safe: parsing, publish-time validation, the heading slugs
|
|
10
|
+
// the renderer and the stored notes agree on, and where each note is placed. The drawing lives
|
|
11
|
+
// in ../widgets/notes.tsx.
|
|
12
|
+
import { unified } from 'unified';
|
|
13
|
+
import remarkParse from 'remark-parse';
|
|
14
|
+
import remarkGfm from 'remark-gfm';
|
|
15
|
+
import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
|
|
16
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
|
|
17
|
+
/** Widgets the design names and this version does not draw yet. A fence using one is refused at
|
|
18
|
+
* publish, so a page never ships a code block that turns into a live widget on a later update. */
|
|
19
|
+
const NOT_YET = ['poll', 'form', 'checklist'];
|
|
20
|
+
const MAX_HEADING_CHARS = 300;
|
|
21
|
+
const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
22
|
+
function inline(nodes) {
|
|
23
|
+
let out = '';
|
|
24
|
+
for (const n of nodes) {
|
|
25
|
+
if (n.type === 'text' || n.type === 'inlineCode')
|
|
26
|
+
out += n.value;
|
|
27
|
+
else if (n.type === 'break')
|
|
28
|
+
out += ' ';
|
|
29
|
+
else if ('children' in n)
|
|
30
|
+
out += inline(n.children);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function walk(nodes, visit) {
|
|
35
|
+
for (const n of nodes) {
|
|
36
|
+
visit(n);
|
|
37
|
+
if ('children' in n && n.type !== 'heading' && n.type !== 'paragraph')
|
|
38
|
+
walk(n.children, visit);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
42
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
43
|
+
export function slugify(text) {
|
|
44
|
+
const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
|
|
45
|
+
return s || 'section';
|
|
46
|
+
}
|
|
47
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
48
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
49
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
50
|
+
export function headingsOf(markdown) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = new Map();
|
|
53
|
+
walk(parse(markdown).children, (n) => {
|
|
54
|
+
if (n.type !== 'heading')
|
|
55
|
+
return;
|
|
56
|
+
const text = inline(n.children).replace(/\s+/g, ' ').trim();
|
|
57
|
+
const base = slugify(text);
|
|
58
|
+
const k = seen.get(base) ?? 0;
|
|
59
|
+
seen.set(base, k + 1);
|
|
60
|
+
out.push({ depth: n.depth, line: n.position?.start.line ?? 0, text, slug: k ? `${base}-${k}` : base });
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
65
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
66
|
+
export function scanWidgets(body, offset) {
|
|
67
|
+
const fences = [];
|
|
68
|
+
walk(parse(body).children, (n) => { if (n.type === 'code')
|
|
69
|
+
fences.push(n); });
|
|
70
|
+
let notes = null;
|
|
71
|
+
for (const f of fences) {
|
|
72
|
+
const line = (f.position?.start.line ?? 1) + offset;
|
|
73
|
+
if (f.lang && NOT_YET.includes(f.lang))
|
|
74
|
+
return { ok: false, error: `line ${line}: the ${f.lang} widget is not available in this version of the artifacts package` };
|
|
75
|
+
if (f.lang !== NOTES_SLOT)
|
|
76
|
+
continue;
|
|
77
|
+
if (notes)
|
|
78
|
+
return { ok: false, error: `line ${line}: a page takes at most one notes block` };
|
|
79
|
+
if (f.meta?.trim())
|
|
80
|
+
return { ok: false, error: `line ${line}: the notes block takes no name; it always writes to slot "notes"` };
|
|
81
|
+
const w = { line };
|
|
82
|
+
const rows = f.value ? f.value.split('\n') : [];
|
|
83
|
+
for (let i = 0; i < rows.length; i++) {
|
|
84
|
+
const raw = rows[i].replace(/#.*$/, '').trim();
|
|
85
|
+
if (!raw)
|
|
86
|
+
continue;
|
|
87
|
+
const at = line + 1 + i;
|
|
88
|
+
const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
|
|
89
|
+
if (!m)
|
|
90
|
+
return { ok: false, error: `line ${at}: the notes block takes lines like "visibility: shared"` };
|
|
91
|
+
if (m[1] !== 'visibility')
|
|
92
|
+
return { ok: false, error: `line ${at}: the notes block has an unknown key: ${m[1]}` };
|
|
93
|
+
if (m[2] !== 'private' && m[2] !== 'shared')
|
|
94
|
+
return { ok: false, error: `line ${at}: notes visibility must be private or shared` };
|
|
95
|
+
w.visibility = m[2];
|
|
96
|
+
}
|
|
97
|
+
notes = w;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, notes };
|
|
100
|
+
}
|
|
101
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
102
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
103
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
104
|
+
export function mergeWidgetState(state, notes) {
|
|
105
|
+
if (!notes)
|
|
106
|
+
return { ok: true, state };
|
|
107
|
+
const had = state?.slots[NOTES_SLOT];
|
|
108
|
+
if (had && had.shape !== 'many')
|
|
109
|
+
return { ok: false, error: `line ${notes.line}: the notes block writes to slot "notes" as shape many, and state: declares it shape ${had.shape}; rename that slot` };
|
|
110
|
+
if (had?.visibility && notes.visibility && had.visibility !== notes.visibility)
|
|
111
|
+
return { ok: false, error: `line ${notes.line}: the notes block says visibility ${notes.visibility} and state: says ${had.visibility} for slot "notes"; say it once` };
|
|
112
|
+
const slot = had
|
|
113
|
+
? (notes.visibility && !had.visibility ? { ...had, visibility: notes.visibility } : had)
|
|
114
|
+
: { shape: 'many', ...(notes.visibility ? { visibility: notes.visibility } : {}) };
|
|
115
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
116
|
+
if (slot === had)
|
|
117
|
+
return { ok: true, state: base };
|
|
118
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [NOTES_SLOT]: slot } } };
|
|
119
|
+
}
|
|
120
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
121
|
+
export function hasNotesWidget(markdown) {
|
|
122
|
+
const r = scanWidgets(markdown, 0);
|
|
123
|
+
return r.ok && r.notes !== null;
|
|
124
|
+
}
|
|
125
|
+
const SLUG = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,120}$/u;
|
|
126
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
127
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
128
|
+
* the one that counts. */
|
|
129
|
+
export function checkNoteValue(v) {
|
|
130
|
+
if (!isMap(v) || Object.keys(v).some((k) => !['slug', 'heading', 'note'].includes(k)))
|
|
131
|
+
return 'a note is { slug, heading, note }';
|
|
132
|
+
if (typeof v.slug !== 'string' || !SLUG.test(v.slug))
|
|
133
|
+
return 'a note needs the slug of the heading it is under';
|
|
134
|
+
if (typeof v.heading !== 'string' || v.heading.length > MAX_HEADING_CHARS)
|
|
135
|
+
return 'a note needs the heading it is under';
|
|
136
|
+
if (typeof v.note !== 'string' || !v.note.trim())
|
|
137
|
+
return 'a note needs some text';
|
|
138
|
+
if (v.note.length > MAX_NOTE_CHARS)
|
|
139
|
+
return `a note is at most ${MAX_NOTE_CHARS} characters`;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -88,9 +88,15 @@ export function createArtifactRoutes(config) {
|
|
|
88
88
|
// The same cookie again on the page's state API: a cookie scoped to the page path is never
|
|
89
89
|
// sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
|
|
90
90
|
const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
91
|
+
// The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
|
|
92
|
+
// cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
|
|
93
|
+
// before its API cookie existed would otherwise be refused every answer until they reopened
|
|
94
|
+
// the ?key= link.
|
|
95
|
+
const paths = !open || !a.password ? [] : [
|
|
96
|
+
...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
|
|
97
|
+
...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
|
|
98
|
+
];
|
|
99
|
+
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
94
100
|
if (!open) {
|
|
95
101
|
return (_jsxs(BrandGround, { pack: brand, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
|
|
96
102
|
}
|
|
@@ -107,7 +113,7 @@ export function createArtifactRoutes(config) {
|
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
110
|
-
return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), 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] }));
|
|
116
|
+
return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined }) })] }), 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] }));
|
|
111
117
|
}
|
|
112
118
|
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
113
119
|
* everyone else gets the title, the summary, and a door. */
|
|
@@ -146,7 +152,7 @@ export function createArtifactRoutes(config) {
|
|
|
146
152
|
}
|
|
147
153
|
}
|
|
148
154
|
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
149
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown }) })] }), 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] }));
|
|
155
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined }) })] }), 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] }));
|
|
150
156
|
}
|
|
151
157
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
152
158
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
@@ -16,6 +16,7 @@ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../arti
|
|
|
16
16
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
17
17
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
|
|
18
18
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
19
|
+
import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
|
|
19
20
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
20
21
|
export const ANON_COOKIE = 'artifact_anon';
|
|
21
22
|
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
@@ -168,13 +169,15 @@ export function createStateRoutes(ctx) {
|
|
|
168
169
|
await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
|
|
169
170
|
}
|
|
170
171
|
else {
|
|
171
|
-
const bad = checkValue(b.value);
|
|
172
|
+
const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
|
|
172
173
|
if (bad)
|
|
173
174
|
return json({ error: bad }, 400);
|
|
174
175
|
// The page-wide cap per slot. Counted before the write and not atomically with it, so a
|
|
175
176
|
// burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
|
|
176
177
|
if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
|
|
177
|
-
const replacing = op === 'set' && (
|
|
178
|
+
const replacing = op === 'set' && (ctx.state.hasOne
|
|
179
|
+
? await ctx.state.hasOne(id, slot, writer.key)
|
|
180
|
+
: (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key));
|
|
178
181
|
if (!replacing)
|
|
179
182
|
return json({ error: 'this page is not taking more answers here' }, 409);
|
|
180
183
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export declare function NotesProvider({ artifactId, headings, accent, children }: {
|
|
3
|
+
artifactId: string;
|
|
4
|
+
headings: {
|
|
5
|
+
slug: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
accent?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
/** The small control inside a heading. */
|
|
12
|
+
export declare function NoteToggle({ slug }: {
|
|
13
|
+
slug: string;
|
|
14
|
+
}): import("react").JSX.Element | null;
|
|
15
|
+
/** After each heading: its notes, and the box when its control is open. */
|
|
16
|
+
export declare function HeadingNotes({ slug, text }: {
|
|
17
|
+
slug: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}): import("react").JSX.Element | null;
|
|
20
|
+
/** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
|
|
21
|
+
export declare function NotesEarlier(): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The notes widget in the reader's browser. One provider per page reads the page's state once
|
|
4
|
+
// (GET /api/artifacts/<id>/state), places every note under the heading it was left under, and
|
|
5
|
+
// sets aside the ones whose heading is gone. A small "note" control sits in each heading; the
|
|
6
|
+
// panel after the heading lists its notes and, when open, takes a new one.
|
|
7
|
+
//
|
|
8
|
+
// Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
|
|
9
|
+
// the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
|
|
10
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
11
|
+
import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
|
|
12
|
+
const NotesContext = createContext(null);
|
|
13
|
+
const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
|
|
14
|
+
typeof v.heading === 'string' && typeof v.note === 'string';
|
|
15
|
+
function notesFrom(body) {
|
|
16
|
+
const slot = body?.slots?.[NOTES_SLOT];
|
|
17
|
+
if (!slot)
|
|
18
|
+
return [];
|
|
19
|
+
const rows = Array.isArray(slot.shared) ? slot.shared : Array.isArray(slot.mine) ? slot.mine.map((e) => ({ ...e, mine: true })) : [];
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const r of rows) {
|
|
22
|
+
if (!isNote(r.value))
|
|
23
|
+
continue;
|
|
24
|
+
out.push({
|
|
25
|
+
id: String(r.id ?? ''), slug: r.value.slug, heading: r.value.heading, note: r.value.note,
|
|
26
|
+
name: r.mine ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', at: String(r.at ?? ''), mine: r.mine === true,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
return out;
|
|
30
|
+
}
|
|
31
|
+
export function NotesProvider({ artifactId, headings, accent, children }) {
|
|
32
|
+
const endpoint = `/api/artifacts/${artifactId}/state`;
|
|
33
|
+
const [notes, setNotes] = useState([]);
|
|
34
|
+
const [off, setOff] = useState(false);
|
|
35
|
+
const [canWrite, setCanWrite] = useState(false);
|
|
36
|
+
const [signIn, setSignIn] = useState(null);
|
|
37
|
+
const [open, setOpen] = useState(null);
|
|
38
|
+
const [error, setError] = useState(null);
|
|
39
|
+
const [busy, setBusy] = useState(false);
|
|
40
|
+
const apply = useCallback((status, body) => {
|
|
41
|
+
if (status === 200) {
|
|
42
|
+
setNotes(notesFrom(body));
|
|
43
|
+
setCanWrite(body.canWrite === true);
|
|
44
|
+
setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
|
|
45
|
+
setError(null);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
if (status === 401) {
|
|
49
|
+
setCanWrite(false);
|
|
50
|
+
setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
// No state store, no such page, or no slot: the page takes no notes here, so draw nothing.
|
|
54
|
+
if (status === 404 || status === 501) {
|
|
55
|
+
setOff(true);
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
setError(typeof body.error === 'string' ? body.error : 'that did not save; try again');
|
|
59
|
+
return false;
|
|
60
|
+
}, []);
|
|
61
|
+
const call = useCallback(async (init) => {
|
|
62
|
+
try {
|
|
63
|
+
const r = await fetch(endpoint, { credentials: 'same-origin', cache: 'no-store', ...init });
|
|
64
|
+
const body = (await r.json().catch(() => ({})));
|
|
65
|
+
return apply(r.status, body);
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
setError('that did not reach the page; try again');
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
}, [endpoint, apply]);
|
|
72
|
+
useEffect(() => { void call(); }, [call]);
|
|
73
|
+
const post = useCallback(async (slug, heading, note) => {
|
|
74
|
+
setBusy(true);
|
|
75
|
+
const done = await call({
|
|
76
|
+
method: 'POST', headers: { 'content-type': 'application/json' },
|
|
77
|
+
body: JSON.stringify({ slot: NOTES_SLOT, op: 'append', value: { slug, heading, note } }),
|
|
78
|
+
});
|
|
79
|
+
setBusy(false);
|
|
80
|
+
if (done)
|
|
81
|
+
setOpen(null);
|
|
82
|
+
return done;
|
|
83
|
+
}, [call]);
|
|
84
|
+
const remove = useCallback(async (id) => {
|
|
85
|
+
await call({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: NOTES_SLOT, op: 'remove', entry: id }) });
|
|
86
|
+
}, [call]);
|
|
87
|
+
const placed = useMemo(() => placeNotes(headings, notes), [headings, notes]);
|
|
88
|
+
const value = { off, accent: accent ?? 'currentColor', ...placed, canWrite, signIn, open, setOpen, error, busy, post, remove };
|
|
89
|
+
return _jsx(NotesContext.Provider, { value: value, children: children });
|
|
90
|
+
}
|
|
91
|
+
/** The small control inside a heading. */
|
|
92
|
+
export function NoteToggle({ slug }) {
|
|
93
|
+
const c = useContext(NotesContext);
|
|
94
|
+
if (!c || c.off)
|
|
95
|
+
return null;
|
|
96
|
+
const n = c.bySlug[slug]?.length ?? 0;
|
|
97
|
+
return (_jsx("button", { type: "button", "data-nospeak": true, "data-note-toggle": slug, "aria-expanded": c.open === slug, onClick: () => c.setOpen(c.open === slug ? null : slug), className: "ml-3 inline-block rounded-full border px-2 py-0.5 align-middle font-sans text-[11px] font-medium uppercase tracking-[0.15em] opacity-70 transition-opacity hover:opacity-100", style: { borderColor: c.accent, color: c.accent }, children: n ? `note · ${n}` : 'note' }));
|
|
98
|
+
}
|
|
99
|
+
function NoteItem({ n, c, under }) {
|
|
100
|
+
return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-zinc-100", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
|
|
101
|
+
}
|
|
102
|
+
function NoteForm({ slug, heading, c }) {
|
|
103
|
+
const [text, setText] = useState('');
|
|
104
|
+
const submit = async (e) => {
|
|
105
|
+
e.preventDefault();
|
|
106
|
+
const note = text.trim();
|
|
107
|
+
if (!note)
|
|
108
|
+
return;
|
|
109
|
+
if (await c.post(slug, heading, note))
|
|
110
|
+
setText('');
|
|
111
|
+
};
|
|
112
|
+
if (!c.canWrite) {
|
|
113
|
+
return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
|
|
114
|
+
}
|
|
115
|
+
return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-white/15 bg-white/[0.04] p-3 text-[15px] text-zinc-100" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: '#111' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
116
|
+
}
|
|
117
|
+
/** After each heading: its notes, and the box when its control is open. */
|
|
118
|
+
export function HeadingNotes({ slug, text }) {
|
|
119
|
+
const c = useContext(NotesContext);
|
|
120
|
+
if (!c || c.off)
|
|
121
|
+
return null;
|
|
122
|
+
const list = c.bySlug[slug] ?? [];
|
|
123
|
+
const isOpen = c.open === slug;
|
|
124
|
+
if (!list.length && !isOpen)
|
|
125
|
+
return null;
|
|
126
|
+
return (_jsxs("div", { "data-nospeak": true, "data-heading-notes": slug, className: "my-3", children: [list.length ? _jsx("ul", { className: "m-0 grid list-none gap-2 p-0", children: list.map((n) => _jsx(NoteItem, { n: n, c: c }, n.id)) }) : null, isOpen ? _jsx(NoteForm, { slug: slug, heading: text, c: c }) : null] }));
|
|
127
|
+
}
|
|
128
|
+
/** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
|
|
129
|
+
export function NotesEarlier() {
|
|
130
|
+
const c = useContext(NotesContext);
|
|
131
|
+
if (!c || c.off)
|
|
132
|
+
return null;
|
|
133
|
+
return (_jsxs("section", { "data-nospeak": true, "data-artifact-notes": true, className: "my-8 text-sm", children: [_jsx("p", { className: "opacity-60", children: "Leave a note beside any heading." }), c.earlier.length ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "mt-4 text-[11px] uppercase tracking-[0.2em]", style: { color: c.accent }, children: "Notes on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 p-0", children: c.earlier.map((n) => _jsx(NoteItem, { n: n, c: c, under: true }, n.id)) })] })) : null] }));
|
|
134
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|