@supersuit/artifacts 0.12.1 → 0.14.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 +34 -0
- package/README.md +40 -1
- package/lib/artifacts/front-matter.d.ts +6 -0
- package/lib/artifacts/front-matter.js +9 -1
- package/lib/artifacts/state-store.d.ts +5 -0
- package/lib/artifacts/state-store.js +9 -0
- package/lib/artifacts/store.d.ts +3 -0
- package/lib/artifacts/store.js +6 -3
- package/lib/brand/share-card.d.ts +5 -2
- package/lib/brand/share-card.js +5 -4
- package/lib/reader/comment-layer.js +52 -1
- package/lib/routes/artifacts.d.ts +1 -0
- package/lib/routes/artifacts.js +15 -8
- package/lib/routes/state-routes.d.ts +7 -0
- package/lib/routes/state-routes.js +54 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,40 @@
|
|
|
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.14.0 (2026-09-28)
|
|
7
|
+
|
|
8
|
+
**A page can say who it is for.** `to: Isaiah` in front matter reads "For Isaiah" in three
|
|
9
|
+
places: after the kicker on the page (not spoken), in the link preview's title ("For Isaiah:
|
|
10
|
+
<title>", og:title, twitter:title and <title>), and as a line above the title on the share card.
|
|
11
|
+
1 to 60 characters, one line; a republish without it clears it. Past versions keep the name they
|
|
12
|
+
were published with. `audience:` is unchanged: stored, never shown.
|
|
13
|
+
|
|
14
|
+
- DETECTOR: a page with `to:` whose preview does not name the reader.
|
|
15
|
+
- REMEDY: bump; publishers must forward `to`.
|
|
16
|
+
- Tests: parse and refusals, store round trip and clear-on-omit, the kicker on open, password,
|
|
17
|
+
gated and version pages, the three metadata titles, the share card markup and PNG, narration
|
|
18
|
+
leaving the name out. Metadata title, share card prop and clear-on-omit mutation-checked.
|
|
19
|
+
|
|
20
|
+
## 0.13.0 (2026-09-28)
|
|
21
|
+
|
|
22
|
+
**A publisher can hear about new comments, and every comment has a link to itself.**
|
|
23
|
+
|
|
24
|
+
- `COMMENTS_FEED`: `GET /api/comments?since=<ISO with zone>`, publish key only. Shared comments
|
|
25
|
+
across the tenant's pages, oldest first, at most 200, `at > since`: `{ now, comments: [{
|
|
26
|
+
artifactId, title, entryId, parent, name, quote, region, body, transcript, audioUrl, at, link }] }`.
|
|
27
|
+
`name` null means an anonymous reader. Personal notes never appear (the privacy test calls it).
|
|
28
|
+
Comments on deleted pages are left out. Callers keep the last row's `at` as their cursor and
|
|
29
|
+
use `now` only on a first run.
|
|
30
|
+
- Deep link: `/<id>#comment-<entryId>` scrolls to the comment and opens its thread, on load and
|
|
31
|
+
on `hashchange`; a reply opens its parent's thread; an earlier-versions comment is highlighted.
|
|
32
|
+
- New optional `StateStore.slotSince(slot, after, limit)`; a store without it answers 501.
|
|
33
|
+
- DETECTOR: a host with no `/api/comments` route.
|
|
34
|
+
- REMEDY: bump; add `app/api/comments/route.ts` delegating GET to `COMMENTS_FEED`; create the
|
|
35
|
+
Firestore composite index on `<base>State` (collection scope): `slot` ascending, `at` ascending.
|
|
36
|
+
- Tests: `comments-feed.test.tsx`, five deep-link tests, the privacy test, and the packed
|
|
37
|
+
fixture reading a comment back through the feed. Since-exclusivity, the slot filter, the
|
|
38
|
+
personal-note exclusion, reply-to-parent and the hashchange listener are mutation-checked.
|
|
39
|
+
|
|
6
40
|
## 0.12.1 (2026-09-28)
|
|
7
41
|
|
|
8
42
|
**Tapping into a comment box no longer zooms the page on iPhone.** iOS Safari zooms the page
|
package/README.md
CHANGED
|
@@ -80,6 +80,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
|
|
|
80
80
|
| `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
|
|
81
81
|
| `app/api/reader/ack/route.ts` | `POST` = `ACK` |
|
|
82
82
|
| `app/api/reader/track/route.ts` | `POST` = `TRACK` |
|
|
83
|
+
| `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
|
|
83
84
|
|
|
84
85
|
Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
|
|
85
86
|
the page at `app/a/[id]/`. One deployment can serve many sites: build one set of routes per
|
|
@@ -148,9 +149,10 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
148
149
|
| `title` (required) | The large line, and the unfurl title |
|
|
149
150
|
| `summary` (required) | The italic teaser under it, and the unfurl description |
|
|
150
151
|
| `subtitle` | A line between the two. A republish without it removes it |
|
|
152
|
+
| `to` | Who the page is for, e.g. `to: Marisol`. Shows as "For Marisol" after the kicker, leads the unfurl title ("For Marisol: <title>") and gets its own line on the share card. Never read aloud, and not an access control. One line, 1 to 60 characters; a republish without it removes it |
|
|
153
|
+
| `audience` | Free text stored with the page, never shown |
|
|
151
154
|
| `id` | Republish in place instead of minting a new page |
|
|
152
155
|
| `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
|
|
153
|
-
| `audience` | Free text stored with the page |
|
|
154
156
|
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent (or the timings will not load), the page is read by the browser's own voice instead, lighting the same words; a browser with no `speechSynthesis` shows no player |
|
|
155
157
|
| `password` | Shuts the body behind a door (below) |
|
|
156
158
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
@@ -486,6 +488,43 @@ The bucket also needs CORS allowing `PUT` from the site's origin with the `conte
|
|
|
486
488
|
`cache-control` and `x-goog-content-length-range` headers, since the reader's browser uploads
|
|
487
489
|
directly.
|
|
488
490
|
|
|
491
|
+
### The comments feed
|
|
492
|
+
|
|
493
|
+
So the publisher hears about a shared comment without opening every page, one route lists every
|
|
494
|
+
shared comment on any of the host's pages after a time. Publish key only; personal notes never
|
|
495
|
+
appear (the privacy test covers this handler like every other).
|
|
496
|
+
|
|
497
|
+
```
|
|
498
|
+
GET /api/comments?since=2026-09-28T12:00:00Z
|
|
499
|
+
Authorization: Bearer <publish key>
|
|
500
|
+
|
|
501
|
+
200 { now, comments: [ { artifactId, title, entryId, parent, name, quote, region, body,
|
|
502
|
+
transcript, audioUrl, at, link } ] }
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
|
|
506
|
+
a 400). `name` is null for an anonymous reader; `quote` is the text the comment is pinned to, or
|
|
507
|
+
null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
|
|
508
|
+
URL to the recording, when there is one and the host can sign it. `link` is the page with
|
|
509
|
+
`#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
|
|
510
|
+
parent's; a comment on an earlier version is highlighted in that list). A caller keeps a cursor
|
|
511
|
+
at the last row's `at` and leaves it where it was when nothing came back; `now` is where a first
|
|
512
|
+
run starts, so it does not replay history.
|
|
513
|
+
|
|
514
|
+
The path sits outside `/api/artifacts/` because `comments` is itself a legal page id there.
|
|
515
|
+
|
|
516
|
+
```ts
|
|
517
|
+
// app/api/comments/route.ts
|
|
518
|
+
import type { NextRequest } from 'next/server'
|
|
519
|
+
import { artifacts } from '@/lib/artifacts'
|
|
520
|
+
export function GET(request: NextRequest) { return artifacts.COMMENTS_FEED(request) }
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
On Firestore (`createStateStore`) the query is one range scan over `<base>State` and needs a
|
|
524
|
+
composite index: **`slot` ascending, `at` ascending**, collection scope. Without it Firestore
|
|
525
|
+
refuses the query and the error names a link that creates the index. A host with its own
|
|
526
|
+
`StateStore` implements `slotSince(slot, after, limit)`; without it the feed answers 501.
|
|
527
|
+
|
|
489
528
|
## Brand packs
|
|
490
529
|
|
|
491
530
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -10,6 +10,10 @@ export type ArtifactMeta = {
|
|
|
10
10
|
/** A line under the title. Title, subtitle and summary are three separate fields; the summary
|
|
11
11
|
* is the teaser, and the unfurl carries the title and the summary. */
|
|
12
12
|
subtitle?: string;
|
|
13
|
+
/** Who the page is for ("For <name>" in the kicker, the unfurl title and the share card). A
|
|
14
|
+
* label, not an access control: a republish without it removes it. Never spoken. Distinct from
|
|
15
|
+
* `audience`, which is stored and never shown. */
|
|
16
|
+
to?: string;
|
|
13
17
|
audience?: string;
|
|
14
18
|
cover?: string;
|
|
15
19
|
id?: string;
|
|
@@ -47,6 +51,8 @@ export type ArtifactMeta = {
|
|
|
47
51
|
/** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
|
|
48
52
|
commentsVisible?: CommentsVisible;
|
|
49
53
|
};
|
|
54
|
+
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
55
|
+
export declare const MAX_TO_CHARS = 60;
|
|
50
56
|
export declare function parseArtifactSource(text: string): {
|
|
51
57
|
ok: true;
|
|
52
58
|
meta: ArtifactMeta;
|
|
@@ -7,7 +7,9 @@ import { 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
|
-
|
|
10
|
+
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
11
|
+
export const MAX_TO_CHARS = 60;
|
|
12
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
|
|
11
13
|
export function parseArtifactSource(text) {
|
|
12
14
|
const { data, content } = matter(text);
|
|
13
15
|
const d = data;
|
|
@@ -33,6 +35,12 @@ export function parseArtifactSource(text) {
|
|
|
33
35
|
if (typeof v === 'string' && v)
|
|
34
36
|
meta[k] = v;
|
|
35
37
|
}
|
|
38
|
+
if (d.to !== undefined && d.to !== null) {
|
|
39
|
+
const to = typeof d.to === 'string' ? d.to.trim() : '';
|
|
40
|
+
if (!to || to.length > MAX_TO_CHARS || /[\r\n]/.test(to))
|
|
41
|
+
return { ok: false, error: `to must be one line naming who the page is for, 1 to ${MAX_TO_CHARS} characters` };
|
|
42
|
+
meta.to = to;
|
|
43
|
+
}
|
|
36
44
|
if (d.change !== undefined && d.change !== null) {
|
|
37
45
|
if (typeof d.change !== 'string')
|
|
38
46
|
return { ok: false, error: 'change must be one line of text' };
|
|
@@ -62,6 +62,11 @@ export interface StateStore {
|
|
|
62
62
|
entryId: string;
|
|
63
63
|
value: unknown;
|
|
64
64
|
}): Promise<StateEntry | null>;
|
|
65
|
+
/** Every entry in one slot across ALL of this tenant's pages written strictly after `after` (an
|
|
66
|
+
* ISO time, compared as the stored `at` string), oldest first, at most `limit`. The comments
|
|
67
|
+
* feed reads it. Optional so a host's own store keeps working; without it the feed answers 501.
|
|
68
|
+
* The Firestore query needs a composite index on <base>State: slot ASC, at ASC. */
|
|
69
|
+
slotSince?(slot: string, after: string, limit: number): Promise<StateEntry[]>;
|
|
65
70
|
}
|
|
66
71
|
export declare const readerKeyFor: {
|
|
67
72
|
signedIn: (uid: string) => string;
|
|
@@ -101,6 +101,10 @@ export function createStateStore(db, base) {
|
|
|
101
101
|
await ref.update({ json: JSON.stringify(value) });
|
|
102
102
|
return { ...e, value };
|
|
103
103
|
},
|
|
104
|
+
// One indexed range query over the tenant's whole collection: equality on slot, range and
|
|
105
|
+
// order on at. Needs the composite index (slot ASC, at ASC) on <base>State, or Firestore
|
|
106
|
+
// refuses the query with FAILED_PRECONDITION and a link that creates it.
|
|
107
|
+
slotSince: (slot, after, limit) => load(col().where('slot', '==', slot).where('at', '>', after).orderBy('at').limit(limit)),
|
|
104
108
|
};
|
|
105
109
|
return store;
|
|
106
110
|
}
|
|
@@ -168,5 +172,10 @@ export function createMemoryStateStore() {
|
|
|
168
172
|
docs.set(entryId, next);
|
|
169
173
|
return structuredClone(next);
|
|
170
174
|
},
|
|
175
|
+
slotSince: async (slot, after, limit) => [...docs.values()]
|
|
176
|
+
.filter((e) => e.slot === slot && e.at > after)
|
|
177
|
+
.sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
|
|
178
|
+
.slice(0, limit)
|
|
179
|
+
.map((e) => structuredClone(e)),
|
|
171
180
|
};
|
|
172
181
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -11,6 +11,8 @@ export type ArtifactRecord = {
|
|
|
11
11
|
summary: string;
|
|
12
12
|
template: 'document';
|
|
13
13
|
subtitle?: string;
|
|
14
|
+
/** Who the page is for; see ArtifactMeta.to. */
|
|
15
|
+
to?: string;
|
|
14
16
|
audience?: string;
|
|
15
17
|
cover?: string;
|
|
16
18
|
voice?: string;
|
|
@@ -54,6 +56,7 @@ export type VersionRecord = VersionEntry & {
|
|
|
54
56
|
markdown: string;
|
|
55
57
|
title?: string;
|
|
56
58
|
subtitle?: string;
|
|
59
|
+
to?: string;
|
|
57
60
|
summary?: string;
|
|
58
61
|
};
|
|
59
62
|
/** A change note is one line: whitespace collapsed, capped, empty means none. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -76,7 +76,7 @@ async function versionOf(col, id, n) {
|
|
|
76
76
|
const cur = currentOf(a);
|
|
77
77
|
if (n === cur) {
|
|
78
78
|
return { version: n, at: a.updatedAt, markdown: a.markdown, title: a.title, summary: a.summary, current: true,
|
|
79
|
-
...(a.subtitle ? { subtitle: a.subtitle } : {}), ...(a.note ? { note: a.note } : {}) };
|
|
79
|
+
...(a.subtitle ? { subtitle: a.subtitle } : {}), ...(a.to ? { to: a.to } : {}), ...(a.note ? { note: a.note } : {}) };
|
|
80
80
|
}
|
|
81
81
|
if (n > cur)
|
|
82
82
|
return null;
|
|
@@ -123,6 +123,7 @@ async function saveArtifact(col, input) {
|
|
|
123
123
|
summary: input.meta.summary,
|
|
124
124
|
template: input.meta.template,
|
|
125
125
|
...(input.meta.subtitle ? { subtitle: input.meta.subtitle } : {}),
|
|
126
|
+
...(input.meta.to ? { to: input.meta.to } : {}),
|
|
126
127
|
...(input.meta.audience ? { audience: input.meta.audience } : {}),
|
|
127
128
|
...(input.meta.cover ? { cover: input.meta.cover } : {}),
|
|
128
129
|
...(input.meta.voice ? { voice: input.meta.voice } : {}),
|
|
@@ -161,7 +162,7 @@ async function saveArtifact(col, input) {
|
|
|
161
162
|
await history.doc(vid(current)).set({
|
|
162
163
|
version: current, markdown: existing.markdown, at: existing.updatedAt,
|
|
163
164
|
title: existing.title, summary: existing.summary,
|
|
164
|
-
...(existing.subtitle ? { subtitle: existing.subtitle } : {}), ...(existing.note ? { note: existing.note } : {}),
|
|
165
|
+
...(existing.subtitle ? { subtitle: existing.subtitle } : {}), ...(existing.to ? { to: existing.to } : {}), ...(existing.note ? { note: existing.note } : {}),
|
|
165
166
|
});
|
|
166
167
|
}
|
|
167
168
|
const next = fresh ? current + 1 : current;
|
|
@@ -170,6 +171,8 @@ async function saveArtifact(col, input) {
|
|
|
170
171
|
const password = input.meta.password ? {} : { password: FieldValue.delete() };
|
|
171
172
|
// A subtitle the file no longer carries is gone from the page, like any other content.
|
|
172
173
|
const subtitle = input.meta.subtitle ? {} : { subtitle: FieldValue.delete() };
|
|
174
|
+
// Who it is for is a label on the content, never an access control: absent clears it.
|
|
175
|
+
const to = input.meta.to ? {} : { to: FieldValue.delete() };
|
|
173
176
|
// Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
|
|
174
177
|
// `access: public` opens the page. Reopening a confidential page must never be a side effect.
|
|
175
178
|
const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
|
|
@@ -186,7 +189,7 @@ async function saveArtifact(col, input) {
|
|
|
186
189
|
...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
|
|
187
190
|
};
|
|
188
191
|
await ref.update({
|
|
189
|
-
...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, version: next,
|
|
192
|
+
...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, markdown: input.markdown, version: next,
|
|
190
193
|
// `updatedAt` is when this version went live; finishing its own publish does not move it.
|
|
191
194
|
...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
|
|
192
195
|
note: nextNote ?? FieldValue.delete(),
|
|
@@ -3,11 +3,14 @@ export declare const SHARE_W = 1200;
|
|
|
3
3
|
export declare const SHARE_H = 630;
|
|
4
4
|
/** Titles get smaller as they get longer, so three lines always fit above the mark. */
|
|
5
5
|
export declare function titleSize(title: string): number;
|
|
6
|
-
export declare function ShareCard({ pack, title, fontName, backdrop }: {
|
|
6
|
+
export declare function ShareCard({ pack, title, to, fontName, backdrop }: {
|
|
7
7
|
pack: BrandPack;
|
|
8
8
|
title: string;
|
|
9
|
+
to?: string;
|
|
9
10
|
fontName: string;
|
|
10
11
|
backdrop?: string;
|
|
11
12
|
}): import("react").JSX.Element;
|
|
12
13
|
/** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
|
|
13
|
-
export declare function renderShareCard(pack: BrandPack, title: string
|
|
14
|
+
export declare function renderShareCard(pack: BrandPack, title: string, opts?: {
|
|
15
|
+
to?: string;
|
|
16
|
+
}): Promise<Response>;
|
package/lib/brand/share-card.js
CHANGED
|
@@ -24,8 +24,9 @@ export function titleSize(title) {
|
|
|
24
24
|
return 62;
|
|
25
25
|
return 52;
|
|
26
26
|
}
|
|
27
|
-
export function ShareCard({ pack, title, fontName, backdrop }) {
|
|
27
|
+
export function ShareCard({ pack, title, to, fontName, backdrop }) {
|
|
28
28
|
const size = titleSize(title);
|
|
29
|
+
const kicker = { display: 'flex', fontSize: 22, letterSpacing: 5, textTransform: 'uppercase', color: pack.accent, fontWeight: 500 };
|
|
29
30
|
return (_jsxs("div", { style: {
|
|
30
31
|
display: 'flex',
|
|
31
32
|
flexDirection: 'column',
|
|
@@ -36,12 +37,12 @@ export function ShareCard({ pack, title, fontName, backdrop }) {
|
|
|
36
37
|
...(backdrop ? { backgroundImage: `url(${backdrop})`, backgroundSize: `${SHARE_W}px ${SHARE_H}px` } : {}),
|
|
37
38
|
color: pack.ink,
|
|
38
39
|
fontFamily: fontName,
|
|
39
|
-
}, children: [_jsx("div", { style:
|
|
40
|
+
}, children: [_jsx("div", { style: kicker, children: pack.kicker }), to ? _jsx("div", { style: { ...kicker, marginTop: 12, lineHeight: 1.4 }, children: `For ${to}` }) : null, _jsx("div", { style: { display: 'flex', marginTop: 28, fontSize: size, lineHeight: 1.18, maxWidth: 980, lineClamp: 3 }, children: title })] }));
|
|
40
41
|
}
|
|
41
42
|
/** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
|
|
42
|
-
export async function renderShareCard(pack, title) {
|
|
43
|
+
export async function renderShareCard(pack, title, opts = {}) {
|
|
43
44
|
const { font, backdrop } = await assetsFor(pack);
|
|
44
|
-
return new ImageResponse(_jsx(ShareCard, { pack: pack, title: title, fontName: font.name, backdrop: backdrop }), {
|
|
45
|
+
return new ImageResponse(_jsx(ShareCard, { pack: pack, title: title, to: opts.to, fontName: font.name, backdrop: backdrop }), {
|
|
45
46
|
width: SHARE_W,
|
|
46
47
|
height: SHARE_H,
|
|
47
48
|
fonts: [{ name: font.name, data: font.data, weight: font.weight, style: 'normal' }],
|
|
@@ -16,6 +16,10 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
16
16
|
// deep and only on shared comments. Everything drawn here is data-nospeak and data-comment-ui, so
|
|
17
17
|
// the read-aloud never reads it and the text index never counts it.
|
|
18
18
|
//
|
|
19
|
+
// A link to `#comment-<entryId>` (what the publisher's notification carries) lands on the comment:
|
|
20
|
+
// on load and on every hashchange the pin is scrolled into view and its thread opened; a reply's
|
|
21
|
+
// id opens its parent's thread; a comment on an earlier version is scrolled to and highlighted.
|
|
22
|
+
//
|
|
19
23
|
// Rendered empty on the server and on the first client render so the markup always hydrates; the
|
|
20
24
|
// overlay is portalled onto document.body so its coordinates are page coordinates.
|
|
21
25
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
@@ -32,6 +36,9 @@ import { addDeviceNote, deviceNotes, removeDeviceNote, replaceDeviceNote } from
|
|
|
32
36
|
import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from './comment-place.js';
|
|
33
37
|
const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
|
|
34
38
|
const LONG_PRESS_MS = 400;
|
|
39
|
+
const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
|
|
40
|
+
/** The entry id a `#comment-<id>` hash names, or null. */
|
|
41
|
+
const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
|
|
35
42
|
const DRAG_PX = 8;
|
|
36
43
|
/** Where a drag never starts a box: controls, and anything that is somebody's words. */
|
|
37
44
|
const NOT_FOR_BOXES = 'a, button, input, textarea, select, audio, video[controls], summary, label, [data-comment-ui], [data-note-toggle], [data-heading-notes], [data-artifact-notes], .artifact-defined-term, .artifact-word';
|
|
@@ -85,6 +92,11 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
85
92
|
const [drag, setDrag] = useState(null);
|
|
86
93
|
const [tick, setTick] = useState(0);
|
|
87
94
|
const [theme, setTheme] = useState({});
|
|
95
|
+
/** The comment the address names, until it has been found and shown. */
|
|
96
|
+
const [target, setTarget] = useState(null);
|
|
97
|
+
const [highlight, setHighlight] = useState(null);
|
|
98
|
+
/** A selector to scroll into view once the render that draws it has committed. */
|
|
99
|
+
const [scrollTo, setScrollTo] = useState(null);
|
|
88
100
|
const call = useCallback(async (url, init) => {
|
|
89
101
|
try {
|
|
90
102
|
const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
|
|
@@ -282,6 +294,14 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
282
294
|
root.removeEventListener('dragstart', noImageDrag);
|
|
283
295
|
};
|
|
284
296
|
}, [rootId]);
|
|
297
|
+
// The address names a comment: on load, and every time the hash changes while the page is open.
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
const read = () => { const id = commentHashId(window.location.hash); if (id)
|
|
300
|
+
setTarget(id); };
|
|
301
|
+
read();
|
|
302
|
+
window.addEventListener('hashchange', read);
|
|
303
|
+
return () => window.removeEventListener('hashchange', read);
|
|
304
|
+
}, []);
|
|
285
305
|
const items = useMemo(() => [...shared, ...serverNotes, ...device], [shared, serverNotes, device]);
|
|
286
306
|
const tops = useMemo(() => items.filter((i) => !i.value.parent || !items.some((p) => p.id === i.value.parent)), [items]);
|
|
287
307
|
// Where each top-level comment sits on this version, or that it is on an earlier one.
|
|
@@ -318,6 +338,37 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
318
338
|
return { placed, earlier };
|
|
319
339
|
// `tick` re-measures after a resize.
|
|
320
340
|
}, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
341
|
+
// Show the comment the address names once it has loaded: a reply is shown in its parent's thread.
|
|
342
|
+
// Held until the comments arrive; an id that never arrives (deleted, or not this reader's to see)
|
|
343
|
+
// simply does nothing.
|
|
344
|
+
useEffect(() => {
|
|
345
|
+
if (!target || !mounted)
|
|
346
|
+
return;
|
|
347
|
+
const item = items.find((i) => i.id === target);
|
|
348
|
+
if (!item)
|
|
349
|
+
return;
|
|
350
|
+
const parent = item.value.parent ? items.find((i) => i.id === item.value.parent) : undefined;
|
|
351
|
+
const id = (parent ?? item).id;
|
|
352
|
+
setTarget(null);
|
|
353
|
+
if (layout.placed.some((p) => p.item.id === id)) {
|
|
354
|
+
setShowPins(true);
|
|
355
|
+
setHighlight(null);
|
|
356
|
+
setThread(id);
|
|
357
|
+
setScrollTo(`[data-comment-pin][data-comment-id="${id}"]`);
|
|
358
|
+
}
|
|
359
|
+
else if (layout.earlier.some((i) => i.id === id)) {
|
|
360
|
+
setThread(null);
|
|
361
|
+
setHighlight(id);
|
|
362
|
+
setScrollTo(`[data-comment-earlier-id="${id}"]`);
|
|
363
|
+
}
|
|
364
|
+
}, [target, mounted, items, layout]);
|
|
365
|
+
// Effects run after the DOM is committed, so the pin or list item drawn for the target is there.
|
|
366
|
+
useEffect(() => {
|
|
367
|
+
if (!scrollTo)
|
|
368
|
+
return;
|
|
369
|
+
document.querySelector(scrollTo)?.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
370
|
+
setScrollTo(null);
|
|
371
|
+
}, [scrollTo]);
|
|
321
372
|
/** The recording's path for where it is being saved: reused when the card already uploaded it
|
|
322
373
|
* there, uploaded now when the reader switched between Share and Just for me after recording. */
|
|
323
374
|
const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
|
|
@@ -414,7 +465,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
414
465
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
415
466
|
const count = tops.length;
|
|
416
467
|
const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (_jsx("button", { type: "button", "data-comment-chip": true, className: "absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg", style: { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
417
|
-
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid' }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
468
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
418
469
|
}
|
|
419
470
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
|
420
471
|
const personal = item.kind === 'personal';
|
|
@@ -112,6 +112,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
112
112
|
id: string;
|
|
113
113
|
}>;
|
|
114
114
|
}) => Promise<NextResponse<unknown>>;
|
|
115
|
+
COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
115
116
|
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
116
117
|
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
117
118
|
VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -66,6 +66,11 @@ export function createArtifactRoutes(config) {
|
|
|
66
66
|
store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
|
|
67
67
|
readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
|
|
68
68
|
});
|
|
69
|
+
/** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
|
|
70
|
+
* whole paragraph is data-nospeak, and narrationText has no `to` to read. */
|
|
71
|
+
function Kicker({ to }) {
|
|
72
|
+
return (_jsxs("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: [brand.kicker, to ? _jsx("span", { "data-nospeak": true, "data-to": true, children: ` · For ${to}` }) : null] }));
|
|
73
|
+
}
|
|
69
74
|
async function generateMetadata({ params }) {
|
|
70
75
|
const { id } = await params;
|
|
71
76
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
@@ -73,13 +78,15 @@ export function createArtifactRoutes(config) {
|
|
|
73
78
|
return { title: 'Not found', robots: { index: false, follow: false } };
|
|
74
79
|
const url = pageUrl(a.id);
|
|
75
80
|
const image = absolute(a.cover) ?? (brand.share ? shareCardUrl(a.id, a.updatedAt) : absolute(config.defaultShareImage));
|
|
81
|
+
// Who it is for leads the unfurl, so a thread shows it before the reader opens anything.
|
|
82
|
+
const title = a.to ? `For ${a.to}: ${a.title}` : a.title;
|
|
76
83
|
return {
|
|
77
|
-
title
|
|
84
|
+
title,
|
|
78
85
|
description: a.summary,
|
|
79
86
|
alternates: { canonical: pagePath(a.id) },
|
|
80
87
|
robots: { index: false, follow: false },
|
|
81
88
|
openGraph: {
|
|
82
|
-
title
|
|
89
|
+
title,
|
|
83
90
|
description: a.summary,
|
|
84
91
|
url,
|
|
85
92
|
siteName: brand.name,
|
|
@@ -88,7 +95,7 @@ export function createArtifactRoutes(config) {
|
|
|
88
95
|
},
|
|
89
96
|
twitter: {
|
|
90
97
|
card: image ? 'summary_large_image' : 'summary',
|
|
91
|
-
title
|
|
98
|
+
title,
|
|
92
99
|
description: a.summary,
|
|
93
100
|
...(image ? { images: [image] } : {}),
|
|
94
101
|
},
|
|
@@ -145,7 +152,7 @@ export function createArtifactRoutes(config) {
|
|
|
145
152
|
];
|
|
146
153
|
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
147
154
|
if (!open) {
|
|
148
|
-
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(
|
|
155
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _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 })] }));
|
|
149
156
|
}
|
|
150
157
|
// Who is reading an open page, when they happen to be signed in: their comments follow them.
|
|
151
158
|
// Only a host that signs readers in can know one; the read never fails the page.
|
|
@@ -164,7 +171,7 @@ export function createArtifactRoutes(config) {
|
|
|
164
171
|
const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
|
|
165
172
|
const allow = config.readers && reader ? await config.readers.allowList(id) : [];
|
|
166
173
|
const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
|
|
167
|
-
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(
|
|
174
|
+
const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _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 })] }));
|
|
168
175
|
if (!d.open) {
|
|
169
176
|
if (d.why === 'not-allowed' && config.readers)
|
|
170
177
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
@@ -205,7 +212,7 @@ export function createArtifactRoutes(config) {
|
|
|
205
212
|
const title = v.title ?? a.title;
|
|
206
213
|
const summary = v.summary ?? a.summary;
|
|
207
214
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
208
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(
|
|
215
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
|
|
209
216
|
}
|
|
210
217
|
/** The page itself, shared by open and gated pages. */
|
|
211
218
|
async function Body(a, { top, reader }) {
|
|
@@ -223,7 +230,7 @@ export function createArtifactRoutes(config) {
|
|
|
223
230
|
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
224
231
|
// Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
|
|
225
232
|
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
226
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _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(
|
|
233
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _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(Kicker, { to: a.to }), _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(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
|
|
227
234
|
// No recorded narration, or its timings would not load: the browser reads the page.
|
|
228
235
|
_jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
|
|
229
236
|
}
|
|
@@ -497,7 +504,7 @@ export function createArtifactRoutes(config) {
|
|
|
497
504
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
498
505
|
if (!a)
|
|
499
506
|
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
500
|
-
return renderShareCard(pack, a.title);
|
|
507
|
+
return renderShareCard(pack, a.title, { to: a.to });
|
|
501
508
|
}
|
|
502
509
|
/** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
|
|
503
510
|
* version after the fact. POST body: { version: number, note: string | null }. */
|
|
@@ -3,6 +3,12 @@ import { type StateStore } from '../artifacts/state-store.js';
|
|
|
3
3
|
import type { ArtifactStore } from '../artifacts/store.js';
|
|
4
4
|
import type { ReadersStore } from '../artifacts/readers-store.js';
|
|
5
5
|
export declare const ANON_COOKIE = "artifact_anon";
|
|
6
|
+
/** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
|
|
7
|
+
* last row's `at` and asks again. */
|
|
8
|
+
export declare const FEED_MAX = 200;
|
|
9
|
+
/** `since` as the stored `at` form (UTC, milliseconds), or null. A `+` offset left unencoded in a
|
|
10
|
+
* query string arrives as a space, so that one spelling is read back as the `+` it was. */
|
|
11
|
+
export declare function feedSince(raw: string | null): string | null;
|
|
6
12
|
/** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
|
|
7
13
|
* keyed by the reader secret when the host has one (so the stored value cannot be reversed by
|
|
8
14
|
* hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
|
|
@@ -37,5 +43,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
|
|
|
37
43
|
STATE_GET: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
38
44
|
STATE_POST: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown> | undefined>;
|
|
39
45
|
RESPONSES: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
46
|
+
COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
40
47
|
};
|
|
41
48
|
export {};
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// POST /api/artifacts/<id>/state { slot, op: set|append|remove|replace, value?, entry? }
|
|
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
|
+
// GET /api/comments?since=<ISO> publish key; every shared comment on any page after a time
|
|
7
8
|
//
|
|
8
9
|
// Who is writing: the grant a gated page already uses, or on a page with `writers: anyone`, an
|
|
9
10
|
// anonymous id in an HttpOnly cookie. When both are present the anonymous answers move to the
|
|
@@ -23,6 +24,21 @@ import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js
|
|
|
23
24
|
export const ANON_COOKIE = 'artifact_anon';
|
|
24
25
|
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
25
26
|
const MAX_BODY = 16 * 1024;
|
|
27
|
+
/** The most comments one feed answer carries. A caller that gets this many moves its cursor to the
|
|
28
|
+
* last row's `at` and asks again. */
|
|
29
|
+
export const FEED_MAX = 200;
|
|
30
|
+
// An ISO 8601 time with a zone: 2026-09-28T12:00Z, 2026-09-28T12:00:00.123+02:00. A date alone, or
|
|
31
|
+
// a time with no zone, is refused rather than read in the server's own zone.
|
|
32
|
+
const ISO_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:?\d{2})$/;
|
|
33
|
+
/** `since` as the stored `at` form (UTC, milliseconds), or null. A `+` offset left unencoded in a
|
|
34
|
+
* query string arrives as a space, so that one spelling is read back as the `+` it was. */
|
|
35
|
+
export function feedSince(raw) {
|
|
36
|
+
const v = (raw ?? '').trim().replace(/ (\d{2}:?\d{2})$/, '+$1');
|
|
37
|
+
if (!ISO_TIME.test(v))
|
|
38
|
+
return null;
|
|
39
|
+
const d = new Date(v);
|
|
40
|
+
return Number.isNaN(d.getTime()) ? null : d.toISOString();
|
|
41
|
+
}
|
|
26
42
|
/** The id an anonymous writer's rate counter is stored under. An HMAC over the site and the IP,
|
|
27
43
|
* keyed by the reader secret when the host has one (so the stored value cannot be reversed by
|
|
28
44
|
* hashing the IPv4 space), else by the site URL. The site is in the message either way, so two
|
|
@@ -295,5 +311,42 @@ export function createStateRoutes(ctx) {
|
|
|
295
311
|
await withAudioUrls(id, rows.filter((r) => r.slot === COMMENTS_SLOT));
|
|
296
312
|
return json({ id, title: a.title, responses: rows });
|
|
297
313
|
}
|
|
298
|
-
|
|
314
|
+
/** GET /api/comments?since=<ISO>: every SHARED comment on any of this host's pages written after
|
|
315
|
+
* `since` (exclusive), oldest first, at most FEED_MAX, each with a link that opens it on the
|
|
316
|
+
* page. It reads the `comments` slot of the state core only; personal notes live in a store no
|
|
317
|
+
* publisher route reads, so they cannot appear here. The publisher's own notifier polls it. */
|
|
318
|
+
async function COMMENTS_FEED(req) {
|
|
319
|
+
if (!isPublishAuthed(req, ctx.publishKey()))
|
|
320
|
+
return json({ error: 'Unauthorized' }, 401);
|
|
321
|
+
const since = feedSince(req.nextUrl.searchParams.get('since'));
|
|
322
|
+
if (!since)
|
|
323
|
+
return json({ error: 'since must be an ISO 8601 time with a zone, like 2026-09-28T12:00:00Z' }, 400);
|
|
324
|
+
if (!ctx.state)
|
|
325
|
+
return json({ error: 'this host keeps no answers' }, 501);
|
|
326
|
+
if (!ctx.state.slotSince)
|
|
327
|
+
return json({ error: 'this host cannot list comments by time' }, 501);
|
|
328
|
+
const now = new Date().toISOString();
|
|
329
|
+
const entries = await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
|
|
330
|
+
const pages = new Map();
|
|
331
|
+
const pageOf = (id) => { if (!pages.has(id))
|
|
332
|
+
pages.set(id, ctx.store.get(id)); return pages.get(id); };
|
|
333
|
+
const comments = [];
|
|
334
|
+
for (const e of entries) {
|
|
335
|
+
const a = await pageOf(e.artifactId);
|
|
336
|
+
// A page deleted since the comment was left has nowhere for the link to land.
|
|
337
|
+
if (!a)
|
|
338
|
+
continue;
|
|
339
|
+
const v = e.value;
|
|
340
|
+
const audio = isCommentAudioPath(v.audio) && ctx.audioUrl ? await ctx.audioUrl(e.artifactId, v.audio) : null;
|
|
341
|
+
comments.push({
|
|
342
|
+
artifactId: e.artifactId, title: a.title, entryId: e.id, parent: v.parent ?? null,
|
|
343
|
+
name: e.writer.anonymous ? null : e.writer.name?.trim() || 'a signed-in reader',
|
|
344
|
+
quote: v.anchor?.kind === 'text' ? v.anchor.quote : null, region: v.anchor?.kind === 'region',
|
|
345
|
+
body: v.body, transcript: v.transcript ?? null, audioUrl: audio, at: e.at,
|
|
346
|
+
link: `${ctx.pageUrl(e.artifactId)}#comment-${e.id}`,
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
return json({ now, comments });
|
|
350
|
+
}
|
|
351
|
+
return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED };
|
|
299
352
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.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",
|