@supersuit/artifacts 0.41.0 → 0.43.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 +75 -0
- package/README.md +52 -1
- package/lib/artifacts/front-matter.d.ts +8 -0
- package/lib/artifacts/front-matter.js +10 -1
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/recipients-store.d.ts +4 -0
- package/lib/artifacts/recipients-store.js +1 -1
- package/lib/artifacts/store.d.ts +2 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/routes/artifacts.js +12 -6
- package/lib/routes/recipients-routes.js +1 -1
- package/lib/routes/state-routes.js +21 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,81 @@
|
|
|
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.43.0
|
|
7
|
+
|
|
8
|
+
**A comment's id per reader in `/views`, the comments feed for one page, and a limit on new browsers
|
|
9
|
+
through a group link.** A consumer that keeps a local copy of a page's comments could not tell exactly
|
|
10
|
+
which ones it lacked: `/views` listed each reader's comments as `{ at, text }` with no id, and
|
|
11
|
+
`GET /api/comments` is the whole host's feed. Everything here is additive; a caller that ignores the
|
|
12
|
+
new fields and the new parameter sees 0.42.0.
|
|
13
|
+
|
|
14
|
+
- `GET /api/artifacts/<id>/views`: each comment gains `id` (the entry id the feed calls `entryId`) and
|
|
15
|
+
`parent` (the entry id it replies to, or null). Under `comment_name: required` every browser on a
|
|
16
|
+
group link is still listed under the link, each with its own id. Publisher replies stay out.
|
|
17
|
+
- `GET /api/comments?artifact=<page id>`: only that page's rows, read from that page's entries rather
|
|
18
|
+
than a scan of the host. `since`, `open`, `cursor` and `more` work over that page's comments. A value
|
|
19
|
+
that is not a page id is a 400; a well-formed id with no page is an empty feed. Without the parameter
|
|
20
|
+
the feed is unchanged.
|
|
21
|
+
- On a `comment_name: required` page, the first stored note of each new browser through one group
|
|
22
|
+
link (a reader key with no entry on the page yet, with or without an `artifact_anon` cookie) is
|
|
23
|
+
counted against the anonymous write limit, keyed by the link, and answers
|
|
24
|
+
`429 { error: "too many new readers through this link; try again in a minute" }` past 30 a minute.
|
|
25
|
+
Counted after validation, so a refused request spends nothing. A browser's later notes, a named link
|
|
26
|
+
and a page without the line are not counted.
|
|
27
|
+
- `summarizeViews` (a public export) takes comments with `id` and `parent` OPTIONAL: a caller passing
|
|
28
|
+
`{ readerKey, at, text }` compiles and runs as before, and gets `id: null, parent: null` on each item.
|
|
29
|
+
- README: the `id` and `parent` shapes, the `artifact` parameter, and the note that removing
|
|
30
|
+
`comment_name: required` later turns a group link back into one shared reader and hides every
|
|
31
|
+
per-browser note from its writer.
|
|
32
|
+
|
|
33
|
+
DETECTOR: a consumer that reads `comments[]` from `/views` and rebuilds each item field by field, or
|
|
34
|
+
that rejects unknown keys, meets two new ones: `rg -n "\.comments" <your consumer>`. A consumer that
|
|
35
|
+
paged the host-wide feed to find one page's comments can ask for that page alone. A group link handed
|
|
36
|
+
to a crowd that arrives all at once (more than 30 new browsers writing inside a minute) now sees 429 for
|
|
37
|
+
the rest of that minute. A caller already sending a stray `artifact=` parameter on the feed, ignored
|
|
38
|
+
until now, gets a filtered feed, or a 400 if it is not a page id.
|
|
39
|
+
|
|
40
|
+
REMEDY: read `id` and `parent` where present and tolerate them where not; add `&artifact=<id>` to a
|
|
41
|
+
per-page poll. Nothing is needed for the limit: the same people write again the next minute.
|
|
42
|
+
|
|
43
|
+
Tests: `src/routes/comment-ids.test.tsx` (ids equal the feed's `entryId`; a reply's `parent`; two
|
|
44
|
+
browsers on one group link under the link with distinct ids; no publisher reply; `artifact=` returns
|
|
45
|
+
one page's rows and composes with `since` and `open=1`; paging 300 comments of one page among 450 in
|
|
46
|
+
pages of 200 never returns another page's row; the unfiltered feed unchanged; a malformed id is 400
|
|
47
|
+
and an unknown page is empty; the limit trips for cookieless writes through one link, not for a
|
|
48
|
+
returning browser, a named link or a page without the line). Every test builds its own stores.
|
|
49
|
+
|
|
50
|
+
## 0.42.0
|
|
51
|
+
|
|
52
|
+
**One page can say who sees its version number and past versions: `history: everyone | admins` in
|
|
53
|
+
front matter.** 0.41.0 made the number follow the host's `history` setting, which is one answer for
|
|
54
|
+
every page on a host. A host carries both kinds of page: a letter to one person, where a version
|
|
55
|
+
number is noise, and a spec its readers follow from draft to draft, where it is the point. The
|
|
56
|
+
page's own line overrides the host's for that page; absent, nothing changes.
|
|
57
|
+
|
|
58
|
+
- `history: everyone` shows that page's readers "Version N · Updated <minute>", Version history in
|
|
59
|
+
the page menu and every `/<id>/v/<n>`. `history: admins` keeps all three to admins on a host
|
|
60
|
+
configured `history: 'everyone'`. Admins always see them.
|
|
61
|
+
- Content, like `toc:`: a republish without the line goes back to the host's setting.
|
|
62
|
+
- Refused with `links: per-reader` (and so on a broadcast), because a per-reader page serves no past
|
|
63
|
+
versions and the list would be dead links. A page made per-reader by an earlier publish, whose
|
|
64
|
+
file no longer repeats the line, has its `history: everyone` ignored at render for the same reason.
|
|
65
|
+
- `ArtifactMeta` and `ArtifactRecord` gain `history`; `PageHistory` and `PAGE_HISTORY` are exported
|
|
66
|
+
from the front matter module. `CoverProps.showVersion` follows the page's line.
|
|
67
|
+
|
|
68
|
+
DETECTOR: a publisher that validates front matter keys itself, before the host does, refuses
|
|
69
|
+
`history:` as unknown until it learns the key: `rg -n "comment_name" <your publisher>` finds such a
|
|
70
|
+
list. A custom `ArtifactStore` that copies named fields drops `history` until it carries it.
|
|
71
|
+
|
|
72
|
+
REMEDY: add `history` to such a list and to a custom store's saved fields. Nothing else: a page
|
|
73
|
+
without the line behaves exactly as under 0.41.0.
|
|
74
|
+
|
|
75
|
+
Tests: `src/artifacts/front-matter.test.ts` (both values, an unknown value, the per-reader and
|
|
76
|
+
broadcast refusals), `src/artifacts/store.test.ts` (rides through; a republish without it clears
|
|
77
|
+
it), `src/routes/page-menu.test.tsx` (a page's `everyone` on a default host, a page's `admins` on
|
|
78
|
+
an `everyone` host, and who opens a past version). The three route tests fail when the route
|
|
79
|
+
ignores the page's line.
|
|
80
|
+
|
|
6
81
|
## 0.41.0
|
|
7
82
|
|
|
8
83
|
**A reader who may not see a page's history is no longer told its version number.** The line under
|
package/README.md
CHANGED
|
@@ -216,6 +216,14 @@ versions sees only "Updated <minute>" under the summary; admins, and every reade
|
|
|
216
216
|
`history: 'everyone'`, see "Version N · Updated <minute>". A pack's `Cover` is handed
|
|
217
217
|
`showVersion` alongside `version`, and draws the number itself only when it is true.
|
|
218
218
|
|
|
219
|
+
**One page can choose for itself (0.42.0).** `history: everyone` in a page's front matter shows
|
|
220
|
+
that page's readers the number, Version history and its past versions, on a host that keeps them
|
|
221
|
+
to admins; `history: admins` does the reverse on a host that shows everyone. Use `everyone` for a
|
|
222
|
+
document its readers follow from draft to draft (a spec, a plan, a paper under revision), and
|
|
223
|
+
leave the line out of anything that reads as a message to someone. It is content, like `toc:`: a
|
|
224
|
+
republish without the line goes back to the host's setting. A per-reader page serves no past
|
|
225
|
+
versions, so `history: everyone` is refused there.
|
|
226
|
+
|
|
219
227
|
```ts
|
|
220
228
|
createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
|
|
221
229
|
```
|
|
@@ -261,6 +269,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
261
269
|
| `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
|
|
262
270
|
| `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
|
|
263
271
|
| `comment_name` | `required`: on a per-reader page, a comment through a group link needs a typed first name, and each browser on a group link is its own reader (see Per-reader links). Needs `links: per-reader` |
|
|
272
|
+
| `history` | `everyone` or `admins`: who sees this page's version number and past versions, overriding the host's `history` for this one page (see Version history). Absent, the host decides. Refused as `everyone` on a per-reader page |
|
|
264
273
|
| `change` | One line saying what this version changed (see Version history) |
|
|
265
274
|
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
266
275
|
|
|
@@ -842,6 +851,19 @@ Authorization: Bearer <publish key>
|
|
|
842
851
|
quote, region, body, transcript, audioUrl, at, resolved, link } ] }
|
|
843
852
|
```
|
|
844
853
|
|
|
854
|
+
**One page's feed (0.43.0).** `&artifact=<page id>` returns only that page's rows. `since`, `open`,
|
|
855
|
+
`cursor` and `more` behave as above, over that page's comments alone, so a caller paging one page
|
|
856
|
+
reaches its end without reading another page's rows. The feed is read from that page's own entries,
|
|
857
|
+
not from a scan of the host. A value that is not a page id is a `400` ("artifact must be a page id,
|
|
858
|
+
like abcd2345"); a well-formed id with no page, or no comments, answers an empty feed
|
|
859
|
+
(`{ now, comments: [], cursor: null, more: false }`), as an empty host does. Without the parameter
|
|
860
|
+
the feed is what it was.
|
|
861
|
+
|
|
862
|
+
```
|
|
863
|
+
GET /api/comments?since=2026-09-28T12:00:00Z&artifact=abcd2345&open=1
|
|
864
|
+
Authorization: Bearer <publish key>
|
|
865
|
+
```
|
|
866
|
+
|
|
845
867
|
Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
|
|
846
868
|
a 400). `name` is null for an anonymous reader; `reader` is `{ rid, label }` for a comment left through a per-reader link, else null; `by` is the name its writer typed, else null; `publisher` is true for a reply written through the reply route; `quote` is the text the comment is pinned to, or
|
|
847
869
|
null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
|
|
@@ -902,7 +924,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
902
924
|
|---|---|---|
|
|
903
925
|
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
904
926
|
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window |
|
|
905
|
-
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
927
|
+
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
906
928
|
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
907
929
|
|
|
908
930
|
Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
|
|
@@ -969,6 +991,35 @@ the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A name
|
|
|
969
991
|
reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
|
|
970
992
|
stay per link: every browser on a group link shares them.
|
|
971
993
|
|
|
994
|
+
### Comment ids in `/views` (0.43.0)
|
|
995
|
+
|
|
996
|
+
Each comment in a reader's `comments` array carries the entry id the comments feed calls
|
|
997
|
+
`entryId`, and the id of the comment it replies to:
|
|
998
|
+
|
|
999
|
+
```json
|
|
1000
|
+
{ "rid": "abcdefghijkl", "label": "Anyone with the link", "group": true, "comments": [
|
|
1001
|
+
{ "id": "kq3vN8xTz2LwPd5mRa", "parent": null, "at": "2026-10-11T10:01:00.000Z", "text": "Which river?" },
|
|
1002
|
+
{ "id": "Jm7cHy4bQe9sUf1xWk", "parent": "kq3vN8xTz2LwPd5mRa", "at": "2026-10-11T10:04:00.000Z", "text": "The north one." } ] }
|
|
1003
|
+
```
|
|
1004
|
+
|
|
1005
|
+
`at` and `text` are as before. Under `comment_name: required` the notes of every browser on a
|
|
1006
|
+
group link are listed under that link's row, each with its own `id`. The publisher's replies are not
|
|
1007
|
+
listed. A caller that keeps a local copy of a page's comments compares these ids with
|
|
1008
|
+
`GET /api/comments?artifact=<id>` to see exactly which ones it lacks.
|
|
1009
|
+
|
|
1010
|
+
**Limit on new browsers.** On a page with `comment_name: required`, the first stored note of each new
|
|
1011
|
+
browser through one group link is counted against that link, per minute, on the anonymous write limit
|
|
1012
|
+
(30). A browser is new when its reader key has no entry on the page yet, whether or not it arrived with
|
|
1013
|
+
an `artifact_anon` cookie. Past the limit the state route answers
|
|
1014
|
+
`429 { error: "too many new readers through this link; try again in a minute" }`. Only a stored note is
|
|
1015
|
+
counted: a request refused for a missing name, a closed window or a malformed body spends nothing. A
|
|
1016
|
+
browser's later notes, a named link and a page without the line are never counted. "Note" here means
|
|
1017
|
+
any stored entry in any slot, and a browser that removes its only entry and writes again is new again.
|
|
1018
|
+
|
|
1019
|
+
**Removing the line later.** Taking `comment_name: required` off a page turns its group link back into
|
|
1020
|
+
one shared reader, and every note left per browser stops being shown to its writer: the notes stay with
|
|
1021
|
+
the publisher, under their per-browser keys.
|
|
1022
|
+
|
|
972
1023
|
## Broadcasts (`template: broadcast`)
|
|
973
1024
|
|
|
974
1025
|
A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
|
|
@@ -63,7 +63,15 @@ export type ArtifactMeta = {
|
|
|
63
63
|
/** `comment_name: required` (0.40.0): on a per-reader page, a comment through a group link must
|
|
64
64
|
* carry a typed first name, and a named link is never asked for one. Opt-in; content, like `toc:`. */
|
|
65
65
|
commentName?: 'required';
|
|
66
|
+
/** `history: everyone | admins` (0.42.0): who sees this page's version number and past versions,
|
|
67
|
+
* overriding the host's `history` for this one page. `everyone` suits a document its readers
|
|
68
|
+
* follow from draft to draft; a page that reads as a message leaves the line out. Content, like
|
|
69
|
+
* `toc:`: a republish without the line goes back to the host's setting. */
|
|
70
|
+
history?: PageHistory;
|
|
66
71
|
};
|
|
72
|
+
/** Who sees a page's version number and past versions. */
|
|
73
|
+
export type PageHistory = 'admins' | 'everyone';
|
|
74
|
+
export declare const PAGE_HISTORY: readonly PageHistory[];
|
|
67
75
|
/** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
|
|
68
76
|
* message card, opens only through per-reader links, keeps its media private, has comments off
|
|
69
77
|
* and no narration by default, and expires 90 days after publish unless kept. */
|
|
@@ -7,6 +7,7 @@ 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';
|
|
10
|
+
export const PAGE_HISTORY = ['admins', 'everyone'];
|
|
10
11
|
export const ARTIFACT_KINDS = ['document', 'broadcast'];
|
|
11
12
|
/** Any media in a body: an image, a video or an audio clip, written as markdown image syntax. */
|
|
12
13
|
const MEDIA_IN_BODY = /!\[[^\]]*\]\(\s*<?([^)\s>]+)/g;
|
|
@@ -19,7 +20,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
|
|
|
19
20
|
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
20
21
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
21
22
|
export const MAX_TO_CHARS = 60;
|
|
22
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name']);
|
|
23
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name', 'history']);
|
|
23
24
|
export function parseArtifactSource(text,
|
|
24
25
|
/** The kind a file that leaves `template:` out is read as: the stored page's kind on a
|
|
25
26
|
* republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
|
|
@@ -102,6 +103,11 @@ opts = {}) {
|
|
|
102
103
|
return { ok: false, error: 'pack must be a pack name: lowercase letters, digits and dashes, up to 40 characters' };
|
|
103
104
|
meta.pack = d.pack;
|
|
104
105
|
}
|
|
106
|
+
if (d.history !== undefined && d.history !== null) {
|
|
107
|
+
if (!PAGE_HISTORY.includes(d.history))
|
|
108
|
+
return { ok: false, error: `history must be one of: ${PAGE_HISTORY.join(', ')}` };
|
|
109
|
+
meta.history = d.history;
|
|
110
|
+
}
|
|
105
111
|
if (d.toc !== undefined) {
|
|
106
112
|
if (typeof d.toc !== 'boolean')
|
|
107
113
|
return { ok: false, error: 'toc must be true or false' };
|
|
@@ -150,6 +156,9 @@ opts = {}) {
|
|
|
150
156
|
// People on one broadcast never see each other's comments, so a per-reader page cannot share them.
|
|
151
157
|
if (meta.links === 'per-reader' && meta.commentsVisible === 'readers')
|
|
152
158
|
return { ok: false, error: 'a per-reader page keeps each reader\'s comments to the owner: drop comments_visible: readers' };
|
|
159
|
+
// /<id>/v/<n> of a per-reader page is never served, so the history list would be dead links.
|
|
160
|
+
if (meta.history === 'everyone' && meta.links === 'per-reader')
|
|
161
|
+
return { ok: false, error: 'a per-reader page serves no past versions: drop history: everyone' };
|
|
153
162
|
if (meta.commentName && meta.links !== 'per-reader')
|
|
154
163
|
return { ok: false, error: 'comment_name: required needs links: per-reader' };
|
|
155
164
|
// Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { parseArtifactSource, ARTIFACT_KINDS, type ArtifactMeta, type ArtifactKind } from './front-matter.js';
|
|
1
|
+
export { parseArtifactSource, ARTIFACT_KINDS, PAGE_HISTORY, type ArtifactMeta, type ArtifactKind, type PageHistory } from './front-matter.js';
|
|
2
2
|
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS, type ArtifactListItem, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { parseArtifactSource, ARTIFACT_KINDS } from './front-matter.js';
|
|
1
|
+
export { parseArtifactSource, ARTIFACT_KINDS, PAGE_HISTORY } from './front-matter.js';
|
|
2
2
|
export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
@@ -38,6 +38,8 @@ export type RecipientSummary = {
|
|
|
38
38
|
plays: number;
|
|
39
39
|
devices: number;
|
|
40
40
|
comments: {
|
|
41
|
+
id: string | null;
|
|
42
|
+
parent: string | null;
|
|
41
43
|
at: string;
|
|
42
44
|
text: string;
|
|
43
45
|
}[];
|
|
@@ -78,6 +80,8 @@ export declare function createRecipientsStore(db: Firestore, base: string): Reci
|
|
|
78
80
|
/** One line per reader (never the owner), in the order they were added. */
|
|
79
81
|
export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[], comments: {
|
|
80
82
|
readerKey: string;
|
|
83
|
+
id?: string | null;
|
|
84
|
+
parent?: string | null;
|
|
81
85
|
at: string;
|
|
82
86
|
text: string;
|
|
83
87
|
}[]): RecipientSummary[];
|
|
@@ -179,7 +179,7 @@ export function summarizeViews(rec, views, comments) {
|
|
|
179
179
|
activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
|
|
180
180
|
plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
|
|
181
181
|
devices: new Set(mine.map((v) => v.device)).size,
|
|
182
|
-
comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ at: c.at, text: c.text })),
|
|
182
|
+
comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ id: c.id ?? null, parent: c.parent ?? null, at: c.at, text: c.text })),
|
|
183
183
|
};
|
|
184
184
|
});
|
|
185
185
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -33,6 +33,8 @@ export type ArtifactRecord = {
|
|
|
33
33
|
pack?: string;
|
|
34
34
|
toc?: boolean;
|
|
35
35
|
commentName?: 'required';
|
|
36
|
+
/** The page's own `history:` line; absent, the host's setting decides. */
|
|
37
|
+
history?: 'admins' | 'everyone';
|
|
36
38
|
definitions?: Definition[];
|
|
37
39
|
/** Who may leave shared comments; absent is off. Personal notes need nothing. */
|
|
38
40
|
comments?: CommentsMode;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -164,6 +164,7 @@ async function saveArtifact(col, input) {
|
|
|
164
164
|
...(input.meta.pack ? { pack: input.meta.pack } : {}),
|
|
165
165
|
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
166
166
|
...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
|
|
167
|
+
...(input.meta.history ? { history: input.meta.history } : {}),
|
|
167
168
|
...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
|
|
168
169
|
};
|
|
169
170
|
if (input.id) {
|
|
@@ -217,6 +218,7 @@ async function saveArtifact(col, input) {
|
|
|
217
218
|
...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
|
|
218
219
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
219
220
|
...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
|
|
221
|
+
...(input.meta.history ? {} : { history: FieldValue.delete() }),
|
|
220
222
|
...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
|
|
221
223
|
};
|
|
222
224
|
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -86,8 +86,14 @@ export function createArtifactRoutes(config) {
|
|
|
86
86
|
const owner = config.owner ?? brand.name;
|
|
87
87
|
const ownerEmail = config.ownerEmail?.trim().toLowerCase();
|
|
88
88
|
const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
|
|
89
|
-
/** Whether this reader may see
|
|
90
|
-
|
|
89
|
+
/** Whether this reader may see this page's version number and past versions: the page's own
|
|
90
|
+
* `history:` line when it has one, else the host's (config `history`). Admins always may. */
|
|
91
|
+
const seesHistory = (a, reader) => {
|
|
92
|
+
// A per-reader page serves no past versions, and `links:` is sticky host-side state a later
|
|
93
|
+
// file need not repeat, so the parser's refusal cannot catch every case: ignore the line here.
|
|
94
|
+
const page = a.history === 'everyone' && a.links === 'per-reader' ? undefined : a.history;
|
|
95
|
+
return (page ?? config.history) === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
|
|
96
|
+
};
|
|
91
97
|
const readerSecret = () => config.readerSecret?.();
|
|
92
98
|
/** Who this reader is to this host's pages: the page menu's role. Owner and admins share a menu. */
|
|
93
99
|
const roleOf = (reader) => {
|
|
@@ -230,7 +236,7 @@ export function createArtifactRoutes(config) {
|
|
|
230
236
|
: a.password
|
|
231
237
|
? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
|
|
232
238
|
: { url: pageUrl(a.id), title: a.title };
|
|
233
|
-
return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null, settings: admin ? settings ?? null : null }));
|
|
239
|
+
return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(a, reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null, settings: admin ? settings ?? null : null }));
|
|
234
240
|
}
|
|
235
241
|
/** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
|
|
236
242
|
* whole paragraph is data-nospeak, and narrationText has no `to` to read. */
|
|
@@ -434,7 +440,7 @@ export function createArtifactRoutes(config) {
|
|
|
434
440
|
async function VersionBody(a, n, { top, reader }) {
|
|
435
441
|
const { pack, brand } = lookOf(a);
|
|
436
442
|
// Past versions are for admins unless the host says otherwise: to anyone else none exists.
|
|
437
|
-
if (!seesHistory(reader))
|
|
443
|
+
if (!seesHistory(a, reader))
|
|
438
444
|
notFound();
|
|
439
445
|
const v = store.version ? await store.version(a.id, n) : null;
|
|
440
446
|
if (!v)
|
|
@@ -484,8 +490,8 @@ export function createArtifactRoutes(config) {
|
|
|
484
490
|
const current = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
485
491
|
return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, comments: mode !== 'off', history: history, viewing: current, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
|
|
486
492
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
487
|
-
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt, numbered: seesHistory(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] }));
|
|
488
|
-
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, showVersion: seesHistory(reader), 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] }));
|
|
493
|
+
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt, numbered: seesHistory(a, 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] }));
|
|
494
|
+
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, showVersion: seesHistory(a, reader), 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] }));
|
|
489
495
|
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, 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,
|
|
490
496
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
491
497
|
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, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
|
|
@@ -136,7 +136,7 @@ export function createRecipientsRoutes(given) {
|
|
|
136
136
|
// A page nobody has been added to yet has no readers, which is an answer and not a missing page.
|
|
137
137
|
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
138
138
|
const entries = ctx.state ? await ctx.state.entries(id) : [];
|
|
139
|
-
const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, at: e.at, text: String(e.value?.body ?? '') }));
|
|
139
|
+
const comments = entries.filter((e) => e.slot === COMMENTS_SLOT && e.readerKey.startsWith('r:')).map((e) => ({ readerKey: e.readerKey, id: e.id, parent: typeof e.value?.parent === 'string' ? e.value.parent : null, at: e.at, text: String(e.value?.body ?? '') }));
|
|
140
140
|
return json({ id, expireAt: rec.expireAt, keep: rec.keep, recipients: summarizeViews(rec, await ctx.recipients.views(id), comments) });
|
|
141
141
|
}
|
|
142
142
|
/** Reader text goes with the page: every comment and answer on it, whoever wrote it. */
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// GET /api/artifacts/<id>/responses publish key; every answer with who (?format=csv)
|
|
6
6
|
// DELETE /api/artifacts/<id>/responses?reader=<key> publish key; one reader's answers
|
|
7
7
|
// GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
|
|
8
|
-
// (&open=1: leave out threads that are resolved
|
|
8
|
+
// (&open=1: leave out threads that are resolved;
|
|
9
|
+
// &artifact=<id>: only that page's comments)
|
|
9
10
|
// POST /api/artifacts/<id>/resolve publish key, or the owner or an admin signed in; resolve or
|
|
10
11
|
// reopen one comment thread { entry, resolved?, note?, by? }
|
|
11
12
|
//
|
|
@@ -368,6 +369,16 @@ export function createStateRoutes(ctx) {
|
|
|
368
369
|
if (!replacing)
|
|
369
370
|
return json({ error: 'this page is not taking more answers here' }, 409);
|
|
370
371
|
}
|
|
372
|
+
// A group link under `comment_name: required` makes each browser its own reader, so one link
|
|
373
|
+
// could start any number of them. The FIRST STORED write of a reader key the page has not seen
|
|
374
|
+
// is counted against the link, whether it came with a cookie or not (any 24 characters pass as
|
|
375
|
+
// one), and only here, after validation, so a refused request spends nothing. A key that
|
|
376
|
+
// already has an entry is never counted again.
|
|
377
|
+
if (recipient?.perBrowser && !(await ctx.state.entries(id)).some((e) => e.readerKey === writer.key)) {
|
|
378
|
+
const n = await ctx.state.countAnonWrite(id, `gl.${recipient.rid}`, Math.floor(Date.now() / 60000));
|
|
379
|
+
if (n > ANON_WRITES_PER_MINUTE)
|
|
380
|
+
return json({ error: 'too many new readers through this link; try again in a minute' }, 429);
|
|
381
|
+
}
|
|
371
382
|
const r = op === 'set'
|
|
372
383
|
? await ctx.state.set({ artifactId: id, slot, writer, value })
|
|
373
384
|
: await ctx.state.append({ artifactId: id, slot, writer, value });
|
|
@@ -424,10 +435,17 @@ export function createStateRoutes(ctx) {
|
|
|
424
435
|
const openOnly = openParam === '1' || openParam === 'true';
|
|
425
436
|
if (!ctx.state)
|
|
426
437
|
return json({ error: 'this host keeps no answers' }, 501);
|
|
427
|
-
|
|
438
|
+
// One page's feed (0.43.0): read that page's entries alone, so a caller paging a single page
|
|
439
|
+
// never reads, or is handed, another page's comments.
|
|
440
|
+
const only = req.nextUrl.searchParams.get('artifact');
|
|
441
|
+
if (only !== null && !ARTIFACT_ID_RE.test(only))
|
|
442
|
+
return json({ error: 'artifact must be a page id, like abcd2345' }, 400);
|
|
443
|
+
if (only === null && !ctx.state.slotSince)
|
|
428
444
|
return json({ error: 'this host cannot list comments by time' }, 501);
|
|
429
445
|
const now = new Date().toISOString();
|
|
430
|
-
const entries =
|
|
446
|
+
const entries = only !== null
|
|
447
|
+
? (await ctx.state.entries(only)).filter((e) => e.slot === COMMENTS_SLOT && e.at > since).sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id)).slice(0, FEED_MAX)
|
|
448
|
+
: await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
|
|
431
449
|
const pages = new Map();
|
|
432
450
|
const pageOf = (id) => { if (!pages.has(id))
|
|
433
451
|
pages.set(id, ctx.store.get(id)); return pages.get(id); };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.43.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",
|