@supersuit/artifacts 0.49.0 → 0.52.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 +89 -0
- package/README.md +57 -4
- package/lib/artifacts/firestore-indexes.d.ts +23 -0
- package/lib/artifacts/firestore-indexes.js +40 -0
- package/lib/artifacts/index.d.ts +1 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/recipients-store.d.ts +17 -0
- package/lib/artifacts/recipients-store.js +41 -1
- package/lib/artifacts/toc-spy.d.ts +5 -0
- package/lib/artifacts/toc-spy.js +14 -6
- package/lib/reader/analytics-plan.d.ts +41 -0
- package/lib/reader/analytics-plan.js +33 -0
- package/lib/reader/artifact-reader.js +1 -1
- package/lib/reader/index.d.ts +2 -0
- package/lib/reader/index.js +2 -0
- package/lib/reader/page-analytics.d.ts +42 -0
- package/lib/reader/page-analytics.js +130 -0
- package/lib/routes/artifacts.d.ts +8 -0
- package/lib/routes/artifacts.js +26 -7
- package/lib/routes/recipients-routes.js +19 -2
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +37 -4
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,95 @@
|
|
|
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.52.0 (2026-10-25)
|
|
7
|
+
|
|
8
|
+
**A render before the send is never counted as the reader opening the page.** A per-reader page
|
|
9
|
+
reported its reader as having opened it (first open 20:32:34Z, 647 active seconds) a minute before
|
|
10
|
+
the text carrying his link was sent (20:33:35Z). The page had been rendered from his link on the
|
|
11
|
+
sender's side while the send waited on approval, in a real browser, so the unfurl filter could not
|
|
12
|
+
see it and the render token treated it as a reader.
|
|
13
|
+
|
|
14
|
+
- `POST /recipients` takes `sent: { rids: [rid] or '*', at?: ISO }` and answers `sent`, the rids
|
|
15
|
+
marked. Earliest mark wins; never the owner; a future time is refused (400).
|
|
16
|
+
- `GET /views` counts only sessions on devices that had not rendered the link before `sentAt`, and
|
|
17
|
+
reports the rest as `beforeSend: { sessions, devices, activeSeconds, firstAt }`; both lines and
|
|
18
|
+
`GET /recipients` carry `sentAt`. A reader with no `sentAt` reads exactly as before.
|
|
19
|
+
- `RecipientsStore.setSent` (optional; the memory and Firestore stores have it).
|
|
20
|
+
- DETECTOR: a reader whose `firstOpenAt` is earlier than the time the text carrying their link was
|
|
21
|
+
sent. Any host on 0.51.x has no way to tell such a render from the reader.
|
|
22
|
+
- REMEDY: bump, then have the sender mark each per-reader link sent once delivery is confirmed
|
|
23
|
+
(Supersuit's send-message does this from the next release). For a page already wrong, mark the
|
|
24
|
+
link with the true send time: the views reclassify, nothing is deleted.
|
|
25
|
+
- Tests: recipients-store.test.ts (setSent on both stores; summarizeViews leaves out a pre-send
|
|
26
|
+
session and every later session on its device, keeps the reader's own) and
|
|
27
|
+
recipients-api.test.tsx (the `sent` mark, its refusals, and the views it changes).
|
|
28
|
+
|
|
29
|
+
## 0.51.0 (2026-10-23)
|
|
30
|
+
|
|
31
|
+
**Every page's session replay now masks all text and all inputs, not only a gated page's.** Since
|
|
32
|
+
0.50.0 a public, password or per-reader page recorded its replays with every word readable, so a
|
|
33
|
+
page shared by key or by link sat in full in the analytics provider's copy. `posthog.init` now gets
|
|
34
|
+
`session_recording: { maskAllInputs: true, maskTextSelector: '*' }` on every kind (these client
|
|
35
|
+
options override the PostHog project's own masking setting), and `mask_all_text: true` so
|
|
36
|
+
autocapture stops collecting a clicked element's text. There is no opt-out. Replays still show where
|
|
37
|
+
the reader went, how far and how fast.
|
|
38
|
+
|
|
39
|
+
- `posthogInitOptions(plan)` and `RECORDING_MASK`, exported: the exact init options, pure.
|
|
40
|
+
- DETECTOR: open a replay of a public or per-reader page in PostHog; readable page text means the
|
|
41
|
+
host is on 0.50.x. Autocapture events carrying `$el_text` from page body are the same leak.
|
|
42
|
+
- REMEDY: bump. Replays recorded before the bump keep their text; delete them in PostHog (filter
|
|
43
|
+
recordings by the `artifact_id` / `page_kind` event properties, then delete), and consider
|
|
44
|
+
deleting old autocapture events' `$el_text` the same way.
|
|
45
|
+
- Tests: page-analytics-recording.test.tsx (mounts the component against a stand-in posthog-js and
|
|
46
|
+
holds every page kind's init options to the mask; red on 0.50.0 for public, password and
|
|
47
|
+
per-reader).
|
|
48
|
+
|
|
49
|
+
**A hub can no longer go live with a dead comments feed without being told.** The feed's Firestore
|
|
50
|
+
query needs a composite index (`<base>State`: `slot` ASC, `at` ASC) that 0.13.0 told hubs to create
|
|
51
|
+
by hand; a hub that never did answered every `GET /api/comments` with a bare 500 for five days.
|
|
52
|
+
|
|
53
|
+
- `firestoreIndexesFor(stateBases)`: every composite index the Firestore stores need, as the
|
|
54
|
+
contents of `firestore.indexes.json`, so a hub deploys it with `firebase deploy --only
|
|
55
|
+
firestore:indexes` and tests that its committed file still carries it.
|
|
56
|
+
- `COMMENTS_FEED` answers **503** `{ error: 'missing_firestore_index', index, create }` (create is
|
|
57
|
+
Firestore's link) instead of throwing. Any other error still throws.
|
|
58
|
+
- New route `HEALTH` (`GET /api/health`, no key): probes the feed query for one comment after now;
|
|
59
|
+
200 `{ ok: true, checks }`, or 503 naming the missing index. Another failure reports
|
|
60
|
+
`query_failed` without its message.
|
|
61
|
+
- `missingIndexOf(err)` and `COMMENTS_FEED_INDEX`, exported for a host's own checks.
|
|
62
|
+
- DETECTOR: `curl <hub>/api/health` is 404 (no route), or 503 with `missing_firestore_index`.
|
|
63
|
+
- REMEDY: bump; add `app/api/health/route.ts` delegating GET to `HEALTH`; commit
|
|
64
|
+
`firestore.indexes.json` from `firestoreIndexesFor([...your state bases])` with `firebase.json`
|
|
65
|
+
pointing at it, add a test that the file contains it, and run `firebase deploy --only
|
|
66
|
+
firestore:indexes --project <project>` once.
|
|
67
|
+
- Tests: firestore-indexes.test.ts, and six in comments-feed.test.tsx (503 with the link, other
|
|
68
|
+
errors still throw, HEALTH red/green/other-failure/no-store).
|
|
69
|
+
|
|
70
|
+
## 0.50.0 (2026-10-19)
|
|
71
|
+
|
|
72
|
+
**Product analytics, with video of each visit.** A host that passes `analytics: { posthogKey,
|
|
73
|
+
posthogHost?, tenant? }` to `createArtifactRoutes` gets PostHog on its pages: session replay (text
|
|
74
|
+
fields masked, PostHog's default), heatmaps, autocapture, and `$pageleave` with scroll depth, plus
|
|
75
|
+
two kinds of reading PostHog cannot see on its own: `section_viewed` (the heading's id and text and
|
|
76
|
+
the visible seconds spent in it, timed by the contents rail's own rule, now `currentSection` in
|
|
77
|
+
`toc-spy.tsx`) and `narration_play` / `narration_pause` / `narration_complete` (the narration
|
|
78
|
+
`<audio>` now carries `data-artifact-narration`). Every event carries `artifact_id`, `page_kind`
|
|
79
|
+
and `tenant`. The rules live in one pure function, `analyticsPlan` (`reader/analytics-plan.ts`):
|
|
80
|
+
no key, no script at all (posthog-js is imported only by `PageAnalytics`, after mount); never the
|
|
81
|
+
owner or an admin, signed in, previewing `?as=reader`, or on the owner's own per-reader link; never
|
|
82
|
+
a link-preview fetcher; never a past version. A reader is identified only where the page already
|
|
83
|
+
knows them: a gated page by the signed-in email, a per-reader link as `recipient:<rid>` with its
|
|
84
|
+
label. Public and password pages stay anonymous, even for a signed-in reader. A gated page also
|
|
85
|
+
masks all text in its replays, because its body is confidential and a replay is a copy of it.
|
|
86
|
+
|
|
87
|
+
- DETECTOR: a host that wants analytics has none; with no `analytics` config nothing changes and
|
|
88
|
+
no analytics code is downloaded.
|
|
89
|
+
- REMEDY: pass `analytics` per tenant (a public `phc_` project key). Turn on session replay and
|
|
90
|
+
heatmaps in the PostHog project's settings, which is where PostHog switches them on.
|
|
91
|
+
- Tests: analytics-plan.test.ts (no key, owner, bot, anonymous kinds, gated and per-reader
|
|
92
|
+
identity), page-analytics-mount.test.tsx (where it mounts on real routes, and as whom),
|
|
93
|
+
page-analytics.test.ts (section timing, narration events).
|
|
94
|
+
|
|
6
95
|
## 0.49.0 (2026-10-16)
|
|
7
96
|
|
|
8
97
|
**The default look is Supersuit's, the Maison.** The product the default pack is named for became
|
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ The rest, each exporting the named handler(s) from `artifacts`:
|
|
|
85
85
|
| `app/api/reader/track/route.ts` | `POST` = `TRACK` |
|
|
86
86
|
| `app/api/artifacts/[id]/resolve/route.ts` | `POST` = `RESOLVE` (see [resolving a thread](#resolving-a-thread)) |
|
|
87
87
|
| `app/api/comments/route.ts` | `GET` = `COMMENTS_FEED` (see [the comments feed](#the-comments-feed)) |
|
|
88
|
+
| `app/api/health/route.ts` | `GET` = `HEALTH` (see [the comments feed](#the-comments-feed)) |
|
|
88
89
|
|
|
89
90
|
Pages live at `/<id>` by default. Mounting inside a larger site, pass `pagePrefix: '/a/'` and put
|
|
90
91
|
the page at `app/a/[id]/`. One deployment can serve many sites: build one set of routes per
|
|
@@ -990,8 +991,21 @@ export function GET(request: NextRequest) { return artifacts.COMMENTS_FEED(reque
|
|
|
990
991
|
|
|
991
992
|
On Firestore (`createStateStore`) the query is one range scan over `<base>State` and needs a
|
|
992
993
|
composite index: **`slot` ascending, `at` ascending**, collection scope. Without it Firestore
|
|
993
|
-
refuses the query and the
|
|
994
|
-
`
|
|
994
|
+
refuses the query and the feed answers **503** `{ error: 'missing_firestore_index', index, create }`,
|
|
995
|
+
`create` being Firestore's link that makes the index. A host with its own `StateStore` implements
|
|
996
|
+
`slotSince(slot, after, limit)`; without it the feed answers 501.
|
|
997
|
+
|
|
998
|
+
**Ship the index as a file, not a step to remember.** `firestoreIndexesFor(bases)` returns every
|
|
999
|
+
composite the stores need, for the bases you pass `createStateStore`, in the shape of
|
|
1000
|
+
`firestore.indexes.json`. Commit it, point `firebase.json` at it
|
|
1001
|
+
(`{ "firestore": { "indexes": "firestore.indexes.json" } }`), deploy it with
|
|
1002
|
+
`firebase deploy --only firestore:indexes --project <project>`, and keep a test asserting the
|
|
1003
|
+
committed file still contains `firestoreIndexesFor(...)`, so a release that needs a new index
|
|
1004
|
+
fails your tests rather than your readers.
|
|
1005
|
+
|
|
1006
|
+
**`HEALTH`** (`GET /api/health`, no key) runs the feed's query for one comment after now and
|
|
1007
|
+
answers 200 `{ ok: true, checks }`, or 503 naming the missing index. Call it after every deploy;
|
|
1008
|
+
a hub with no index went five days answering every feed request with a bare 500.
|
|
995
1009
|
|
|
996
1010
|
## Per-reader links
|
|
997
1011
|
|
|
@@ -1023,8 +1037,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
1023
1037
|
| Route | Who | What |
|
|
1024
1038
|
|---|---|---|
|
|
1025
1039
|
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
1026
|
-
| `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 |
|
|
1027
|
-
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments
|
|
1040
|
+
| `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 }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
|
|
1041
|
+
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt` and `beforeSend`. 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 |
|
|
1028
1042
|
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
1029
1043
|
|
|
1030
1044
|
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.
|
|
@@ -1035,6 +1049,27 @@ The `Page` route must stay `force-dynamic` (the package's `dynamic` export says
|
|
|
1035
1049
|
|
|
1036
1050
|
A view is the page rendered in a real browser, visible, for at least 2 seconds. The page then sends one signal carrying a single-use render token minted for that render; the token is spent on first use and expires after a day. Known unfurl fetchers (and any request with no user agent) are ignored, and so is a signal for a revoked or expired reader. The owner's own opens record zero. After the first signal the page sends a beat every few seconds while it stays visible (and a final one on hide, by `sendBeacon`), and a play signal when a clip starts. A signed-in owner or admin opens the page with no link and no tracking, so test a reader's link in a signed-out browser, never in the browser you are signed in to.
|
|
1037
1051
|
|
|
1052
|
+
### A render before the send (0.52.0)
|
|
1053
|
+
|
|
1054
|
+
A view proves a browser rendered the link, not that the reader did. On 2026-10-25 a reader was
|
|
1055
|
+
reported as having opened a page a minute before the text carrying his link was sent: the link had
|
|
1056
|
+
been opened on the sender's side while the send waited on approval, in a real browser that ran the
|
|
1057
|
+
page, so no user-agent rule could have told it apart.
|
|
1058
|
+
|
|
1059
|
+
So the sender records the send: `POST /recipients` with `sent: { rids, at }` (`rids` a list or
|
|
1060
|
+
`'*'` for every reader but the owner; `at` an ISO time, default now on the host's clock, never in
|
|
1061
|
+
the future). The earliest mark stands, so a resend never hides opens between the two sends, and an
|
|
1062
|
+
earlier time corrects a mark written late. `sent` answers the reader ids it changed. A host with
|
|
1063
|
+
its own `RecipientsStore` needs `setSent`; without it the route answers 501 to a `sent` mark.
|
|
1064
|
+
|
|
1065
|
+
`GET /views` then counts, for that reader, only sessions on devices that had not rendered the link
|
|
1066
|
+
before `sentAt`: a session that began earlier was not the reader's, nor is any later session on the
|
|
1067
|
+
same device. Those are reported apart as `beforeSend: { sessions, devices, activeSeconds, firstAt }`
|
|
1068
|
+
(null when no send was recorded). Nothing is refused at record time, so marking a send after the
|
|
1069
|
+
fact reclassifies what is already there. A link with no `sentAt` is read exactly as before. The
|
|
1070
|
+
cost, stated: a reader who opens the link within the seconds between delivery and the sender's
|
|
1071
|
+
mark is counted as before-send; senders mark as soon as delivery is confirmed.
|
|
1072
|
+
|
|
1038
1073
|
### Expiry
|
|
1039
1074
|
|
|
1040
1075
|
`expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
|
|
@@ -1174,6 +1209,24 @@ export const config = { matcher: ['/:id([a-z0-9]{8})'] }
|
|
|
1174
1209
|
|
|
1175
1210
|
Any per-reader page served to a known unfurl fetcher (or a request with no user agent) carries its text only: no media, no signed URL, no reader's note, no cookie. `readHeader` on `createArtifactRoutes` reads the agent (default through `next/headers`). `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its readers, their notes and views and every comment, before the record, and keeps the record (502) if the files could not go, so it can be run again.
|
|
1176
1211
|
|
|
1212
|
+
## Product analytics (`analytics`)
|
|
1213
|
+
|
|
1214
|
+
Pass `analytics: { posthogKey: 'phc_...', posthogHost?: 'https://us.i.posthog.com', tenant?: 'name' }`
|
|
1215
|
+
to `createArtifactRoutes` and the host's pages record visits in PostHog: session replay, heatmaps,
|
|
1216
|
+
autocapture, scroll depth on leave, `section_viewed` (heading id, text, seconds) and
|
|
1217
|
+
`narration_play` / `narration_pause` / `narration_complete`. Without it no page loads any analytics
|
|
1218
|
+
code. Switch session replay and heatmaps on in the PostHog project as well.
|
|
1219
|
+
|
|
1220
|
+
Who is recorded, and as whom (`analyticsPlan`, `src/reader/analytics-plan.ts`):
|
|
1221
|
+
|
|
1222
|
+
- Never the owner or an admin (signed in, previewing `?as=reader`, or on the owner's own per-reader
|
|
1223
|
+
link), never a link-preview fetcher, never a past version.
|
|
1224
|
+
- A gated page identifies the signed-in reader by email.
|
|
1225
|
+
- A per-reader link identifies as `recipient:<rid>` with the label the publisher gave it.
|
|
1226
|
+
- A public or password page stays anonymous.
|
|
1227
|
+
- Every page, whatever its kind, records replays with all text and all inputs masked
|
|
1228
|
+
(`RECORDING_MASK`), and autocapture collects no element text. There is no opt-out.
|
|
1229
|
+
|
|
1177
1230
|
## Brand packs
|
|
1178
1231
|
|
|
1179
1232
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export type FirestoreIndex = {
|
|
2
|
+
collectionGroup: string;
|
|
3
|
+
queryScope: 'COLLECTION';
|
|
4
|
+
fields: {
|
|
5
|
+
fieldPath: string;
|
|
6
|
+
order: 'ASCENDING' | 'DESCENDING';
|
|
7
|
+
}[];
|
|
8
|
+
};
|
|
9
|
+
export type FirestoreIndexesFile = {
|
|
10
|
+
indexes: FirestoreIndex[];
|
|
11
|
+
fieldOverrides: [];
|
|
12
|
+
};
|
|
13
|
+
/** The comments feed's composite in words, for error bodies and health reports. */
|
|
14
|
+
export declare const COMMENTS_FEED_INDEX = "<base>State (collection scope): slot ASC, at ASC";
|
|
15
|
+
/** Every composite index the package's Firestore stores need, for the state-store bases a host
|
|
16
|
+
* uses, in the shape of firestore.indexes.json. A Firestore index is keyed by the collection's id
|
|
17
|
+
* (the last path segment), so tenants sharing a base under different parents share one entry. */
|
|
18
|
+
export declare function firestoreIndexesFor(stateBases: string[]): FirestoreIndexesFile;
|
|
19
|
+
/** Firestore's missing-index refusal, with the console link it carries to create the index, or
|
|
20
|
+
* null for any other error. */
|
|
21
|
+
export declare function missingIndexOf(err: unknown): {
|
|
22
|
+
create: string | null;
|
|
23
|
+
} | null;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
// The composite indexes the Firestore stores' queries need, and how to read the refusal Firestore
|
|
2
|
+
// throws when one is missing.
|
|
3
|
+
//
|
|
4
|
+
// Firestore serves equality-only queries by merging single-field indexes, so most of the stores'
|
|
5
|
+
// queries need nothing. The exception is an equality on one field plus a range or order on another:
|
|
6
|
+
// that needs a composite, and Firestore refuses the query (gRPC 9, FAILED_PRECONDITION) until it
|
|
7
|
+
// exists. Today there is one, the comments feed's `slotSince` on <base>State.
|
|
8
|
+
//
|
|
9
|
+
// A hub deploys these declaratively: commit `firestoreIndexesFor(<every base it passes to
|
|
10
|
+
// createStateStore>)` as firestore.indexes.json and run `firebase deploy --only firestore:indexes`.
|
|
11
|
+
// A hub that skipped this answered every GET /api/comments with a bare 500 for five days
|
|
12
|
+
// in October 2026, so the feed and HEALTH now name the index instead.
|
|
13
|
+
/** The comments feed's composite in words, for error bodies and health reports. */
|
|
14
|
+
export const COMMENTS_FEED_INDEX = '<base>State (collection scope): slot ASC, at ASC';
|
|
15
|
+
const collectionIdOf = (path) => path.split('/').filter(Boolean).pop() ?? path;
|
|
16
|
+
/** Every composite index the package's Firestore stores need, for the state-store bases a host
|
|
17
|
+
* uses, in the shape of firestore.indexes.json. A Firestore index is keyed by the collection's id
|
|
18
|
+
* (the last path segment), so tenants sharing a base under different parents share one entry. */
|
|
19
|
+
export function firestoreIndexesFor(stateBases) {
|
|
20
|
+
const groups = [...new Set(stateBases.map((b) => `${collectionIdOf(b)}State`))];
|
|
21
|
+
return {
|
|
22
|
+
indexes: groups.map((collectionGroup) => ({
|
|
23
|
+
collectionGroup, queryScope: 'COLLECTION',
|
|
24
|
+
fields: [{ fieldPath: 'slot', order: 'ASCENDING' }, { fieldPath: 'at', order: 'ASCENDING' }],
|
|
25
|
+
})),
|
|
26
|
+
fieldOverrides: [],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** Firestore's missing-index refusal, with the console link it carries to create the index, or
|
|
30
|
+
* null for any other error. */
|
|
31
|
+
export function missingIndexOf(err) {
|
|
32
|
+
if (!err || typeof err !== 'object')
|
|
33
|
+
return null;
|
|
34
|
+
const message = String(err.message ?? '');
|
|
35
|
+
const code = err.code;
|
|
36
|
+
const precondition = code === 9 || code === 'failed-precondition' || /FAILED_PRECONDITION/.test(message);
|
|
37
|
+
if (!precondition || !/requires an index/i.test(message))
|
|
38
|
+
return null;
|
|
39
|
+
return { create: message.match(/https:\/\/\S+/)?.[0] ?? null };
|
|
40
|
+
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
|
|
|
11
11
|
export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS, type ReadAlert, type ReadAlertKind, type ReadAlertInput, type ReaderEvent } from './read-alerts.js';
|
|
12
12
|
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
14
|
+
export { firestoreIndexesFor, missingIndexOf, COMMENTS_FEED_INDEX, type FirestoreIndex, type FirestoreIndexesFile } from './firestore-indexes.js';
|
|
14
15
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
15
16
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
16
17
|
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -11,6 +11,7 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
|
11
11
|
export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS } from './read-alerts.js';
|
|
12
12
|
export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
14
|
+
export { firestoreIndexesFor, missingIndexOf, COMMENTS_FEED_INDEX } from './firestore-indexes.js';
|
|
14
15
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
15
16
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
16
17
|
export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS } from './simulator.js';
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
/** `sentAt`: when the sender says the link reached the reader (the publish key marks it). A view
|
|
3
|
+
* that began before then was not the reader's, whoever rendered it. */
|
|
2
4
|
export type Recipient = {
|
|
3
5
|
rid: string;
|
|
4
6
|
label: string;
|
|
@@ -8,6 +10,7 @@ export type Recipient = {
|
|
|
8
10
|
addedAt: string;
|
|
9
11
|
revokedAt?: string;
|
|
10
12
|
closesAt?: string | null;
|
|
13
|
+
sentAt?: string;
|
|
11
14
|
};
|
|
12
15
|
export type RecipientsRecord = {
|
|
13
16
|
recipients: Record<string, Recipient>;
|
|
@@ -25,12 +28,22 @@ export type ViewDoc = {
|
|
|
25
28
|
activeSeconds: number;
|
|
26
29
|
plays: Record<string, number>;
|
|
27
30
|
};
|
|
31
|
+
/** What rendered the link before it was sent: never counted as the reader, shown so a publisher
|
|
32
|
+
* can see it happened (usually themselves, checking the page). Null when no send was recorded. */
|
|
33
|
+
export type BeforeSend = {
|
|
34
|
+
sessions: number;
|
|
35
|
+
devices: number;
|
|
36
|
+
activeSeconds: number;
|
|
37
|
+
firstAt: string | null;
|
|
38
|
+
} | null;
|
|
28
39
|
export type RecipientSummary = {
|
|
29
40
|
rid: string;
|
|
30
41
|
label: string;
|
|
31
42
|
group: boolean;
|
|
32
43
|
revoked: boolean;
|
|
33
44
|
closesAt: string | null;
|
|
45
|
+
sentAt: string | null;
|
|
46
|
+
beforeSend: BeforeSend;
|
|
34
47
|
opened: boolean;
|
|
35
48
|
firstOpenAt: string | null;
|
|
36
49
|
cameBack: boolean;
|
|
@@ -59,6 +72,10 @@ export interface RecipientsStore {
|
|
|
59
72
|
* with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
|
|
60
73
|
* working; without it the recipients route answers 501 to a `closes` change. */
|
|
61
74
|
setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
|
|
75
|
+
/** Mark the named readers' links (or every non-owner's, with '*') as sent at `sentAt`. The
|
|
76
|
+
* earliest mark stands: a resend never moves it later. Returns the rids changed. Optional so a
|
|
77
|
+
* host's own store keeps working; without it the recipients route answers 501 to a `sent` mark. */
|
|
78
|
+
setSent?(artifactId: string, rids: string[] | '*', sentAt: string): Promise<string[]>;
|
|
62
79
|
setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
|
|
63
80
|
useToken(key: string, expiresAt: number): Promise<boolean>;
|
|
64
81
|
recordView(v: {
|
|
@@ -41,6 +41,20 @@ function closeIn(rec, rids, closesAt) {
|
|
|
41
41
|
}
|
|
42
42
|
return hit;
|
|
43
43
|
}
|
|
44
|
+
function sentIn(rec, rids, sentAt) {
|
|
45
|
+
const hit = [];
|
|
46
|
+
for (const r of Object.values(rec.recipients)) {
|
|
47
|
+
if (r.owner || (rids !== '*' && !rids.includes(r.rid)))
|
|
48
|
+
continue;
|
|
49
|
+
// Earliest wins. A later resend cannot hide opens that happened after the first send, and an
|
|
50
|
+
// earlier time corrects a mark written late.
|
|
51
|
+
if (r.sentAt && Date.parse(r.sentAt) <= Date.parse(sentAt))
|
|
52
|
+
continue;
|
|
53
|
+
r.sentAt = sentAt;
|
|
54
|
+
hit.push(r.rid);
|
|
55
|
+
}
|
|
56
|
+
return hit;
|
|
57
|
+
}
|
|
44
58
|
function addView(prev, v) {
|
|
45
59
|
const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
|
|
46
60
|
doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
|
|
@@ -67,6 +81,7 @@ export function memoryRecipientsStore() {
|
|
|
67
81
|
},
|
|
68
82
|
async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
|
|
69
83
|
async setCloses(id, rids, closesAt) { const rec = recs.get(id); return rec ? closeIn(rec, rids, closesAt) : []; },
|
|
84
|
+
async setSent(id, rids, sentAt) { const rec = recs.get(id); return rec ? sentIn(rec, rids, sentAt) : []; },
|
|
70
85
|
async setExpiry(id, expireAt) {
|
|
71
86
|
const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
|
|
72
87
|
rec.expireAt = expireAt;
|
|
@@ -130,6 +145,16 @@ export function createRecipientsStore(db, base) {
|
|
|
130
145
|
await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { closesAt }])) }, { merge: true });
|
|
131
146
|
return hit;
|
|
132
147
|
},
|
|
148
|
+
async setSent(id, rids, sentAt) {
|
|
149
|
+
const rec = await read(id);
|
|
150
|
+
if (!rec)
|
|
151
|
+
return [];
|
|
152
|
+
const hit = sentIn(rec, rids, sentAt);
|
|
153
|
+
if (!hit.length)
|
|
154
|
+
return hit;
|
|
155
|
+
await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { sentAt }])) }, { merge: true });
|
|
156
|
+
return hit;
|
|
157
|
+
},
|
|
133
158
|
async setExpiry(id, expireAt) {
|
|
134
159
|
await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
|
|
135
160
|
},
|
|
@@ -169,10 +194,25 @@ export function createRecipientsStore(db, base) {
|
|
|
169
194
|
/** One line per reader (never the owner), in the order they were added. */
|
|
170
195
|
export function summarizeViews(rec, views, comments) {
|
|
171
196
|
return Object.values(rec.recipients).filter((r) => !r.owner).sort((a, b) => a.addedAt.localeCompare(b.addedAt)).map((r) => {
|
|
172
|
-
const
|
|
197
|
+
const all = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
|
|
198
|
+
// A RENDER BEFORE THE SEND IS NEVER THE READER (2026-10-25). The link was opened on the
|
|
199
|
+
// sender's side while the text waited on approval, and the reader was reported as having
|
|
200
|
+
// read a page that had not reached them. Nobody can have received a link before it was sent,
|
|
201
|
+
// so a session that began earlier is someone else's, and so is every session on that same
|
|
202
|
+
// device: the device had the link before the reader did. This decides by the send, not by
|
|
203
|
+
// guessing at user agents, so it holds for any renderer (a person checking the page, a
|
|
204
|
+
// preview that runs scripts) and costs a real reader nothing: their opens come after.
|
|
205
|
+
// Kept at read time, never by refusing to record, so a send marked late reclassifies.
|
|
206
|
+
const sentMs = r.sentAt ? Date.parse(r.sentAt) : NaN;
|
|
207
|
+
const pre = Number.isFinite(sentMs) ? all.filter((v) => Date.parse(v.firstAt) < sentMs) : [];
|
|
208
|
+
const preDevices = new Set(pre.map((v) => v.device));
|
|
209
|
+
const early = all.filter((v) => preDevices.has(v.device));
|
|
210
|
+
const mine = all.filter((v) => !preDevices.has(v.device));
|
|
173
211
|
const firsts = mine.map((v) => Date.parse(v.firstAt));
|
|
174
212
|
return {
|
|
175
213
|
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
|
|
214
|
+
sentAt: r.sentAt ?? null,
|
|
215
|
+
beforeSend: Number.isFinite(sentMs) ? { sessions: early.length, devices: preDevices.size, activeSeconds: early.reduce((n, v) => n + v.activeSeconds, 0), firstAt: early[0]?.firstAt ?? null } : null,
|
|
176
216
|
opened: mine.length > 0,
|
|
177
217
|
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
178
218
|
cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/** How far below the top of the screen a heading may sit and still be the section being read. */
|
|
2
|
+
export declare const READING_LINE = 140;
|
|
3
|
+
/** The section being read: the last heading at or above the reading line, else the first. The
|
|
4
|
+
* page analytics times sections by the same rule, so a section's dwell matches the rail. */
|
|
5
|
+
export declare function currentSection(ids: string[], doc?: Document): string | undefined;
|
|
1
6
|
export declare function TocSpy({ slugs }: {
|
|
2
7
|
slugs: string[];
|
|
3
8
|
}): null;
|
package/lib/artifacts/toc-spy.js
CHANGED
|
@@ -2,6 +2,19 @@
|
|
|
2
2
|
// Marks the contents entry for the section being read. Renders nothing: it only sets
|
|
3
3
|
// aria-current on the links the server drew, which is also what styles them.
|
|
4
4
|
import { useEffect } from 'react';
|
|
5
|
+
/** How far below the top of the screen a heading may sit and still be the section being read. */
|
|
6
|
+
export const READING_LINE = 140;
|
|
7
|
+
/** The section being read: the last heading at or above the reading line, else the first. The
|
|
8
|
+
* page analytics times sections by the same rule, so a section's dwell matches the rail. */
|
|
9
|
+
export function currentSection(ids, doc = document) {
|
|
10
|
+
let current = ids[0];
|
|
11
|
+
for (const id of ids) {
|
|
12
|
+
const el = doc.getElementById(id);
|
|
13
|
+
if (el && el.getBoundingClientRect().top <= READING_LINE)
|
|
14
|
+
current = id;
|
|
15
|
+
}
|
|
16
|
+
return current;
|
|
17
|
+
}
|
|
5
18
|
export function TocSpy({ slugs }) {
|
|
6
19
|
const key = slugs.join('|');
|
|
7
20
|
useEffect(() => {
|
|
@@ -9,12 +22,7 @@ export function TocSpy({ slugs }) {
|
|
|
9
22
|
let frame = 0;
|
|
10
23
|
const mark = () => {
|
|
11
24
|
frame = 0;
|
|
12
|
-
|
|
13
|
-
for (const id of ids) {
|
|
14
|
-
const el = document.getElementById(id);
|
|
15
|
-
if (el && el.getBoundingClientRect().top <= 140)
|
|
16
|
-
current = id;
|
|
17
|
-
}
|
|
25
|
+
const current = currentSection(ids);
|
|
18
26
|
document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
|
|
19
27
|
if (a.dataset.tocSlug === current)
|
|
20
28
|
a.setAttribute('aria-current', 'location');
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
export type AnalyticsConfig = {
|
|
2
|
+
/** A PostHog project API key (the public `phc_...` one, safe in a browser). */
|
|
3
|
+
posthogKey: string;
|
|
4
|
+
/** The ingestion host. Default PostHog's US cloud. */
|
|
5
|
+
posthogHost?: string;
|
|
6
|
+
/** Sent with every event as `tenant`, so one project can hold several hosts' pages. */
|
|
7
|
+
tenant?: string;
|
|
8
|
+
};
|
|
9
|
+
export type PageKind = 'public' | 'password' | 'gated' | 'per-reader';
|
|
10
|
+
export type AnalyticsPlan = {
|
|
11
|
+
key: string;
|
|
12
|
+
host: string;
|
|
13
|
+
pageId: string;
|
|
14
|
+
kind: PageKind;
|
|
15
|
+
tenant: string | null;
|
|
16
|
+
/** Who to identify the reader as, or null to keep them anonymous. */
|
|
17
|
+
identity: {
|
|
18
|
+
distinctId: string;
|
|
19
|
+
email?: string;
|
|
20
|
+
name?: string;
|
|
21
|
+
} | null;
|
|
22
|
+
};
|
|
23
|
+
export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
|
|
24
|
+
export declare function analyticsPlan(input: {
|
|
25
|
+
config: AnalyticsConfig | null | undefined;
|
|
26
|
+
pageId: string;
|
|
27
|
+
kind: PageKind;
|
|
28
|
+
/** The owner or an admin is looking (signed in, previewing as a reader, or on the owner's own link). */
|
|
29
|
+
owner: boolean;
|
|
30
|
+
bot: boolean;
|
|
31
|
+
/** A gated page's signed-in reader. */
|
|
32
|
+
reader?: {
|
|
33
|
+
email: string;
|
|
34
|
+
name?: string | null;
|
|
35
|
+
} | null;
|
|
36
|
+
/** A per-reader link's recipient. */
|
|
37
|
+
recipient?: {
|
|
38
|
+
rid: string;
|
|
39
|
+
label: string;
|
|
40
|
+
} | null;
|
|
41
|
+
}): AnalyticsPlan | null;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// Whether a page view is recorded in the host's product analytics, and as whom. Pure, so the route
|
|
2
|
+
// decides on the server and the browser is handed either nothing or exactly what to send.
|
|
3
|
+
//
|
|
4
|
+
// THE RULES (0.50.0):
|
|
5
|
+
// - A host with no analytics key records nothing, and its pages load no analytics script at all.
|
|
6
|
+
// - The owner and the host's admins are never recorded, signed in or on their own per-reader link.
|
|
7
|
+
// - A link-preview fetcher is never handed the script.
|
|
8
|
+
// - A reader is identified only where the page already knows who they are: a gated page by the
|
|
9
|
+
// signed-in email, a per-reader link by its opaque recipient id and the label the publisher gave
|
|
10
|
+
// it (never a phone or an email, which the recipients store does not hold). A public or password
|
|
11
|
+
// page stays anonymous, even when its reader happens to be signed in.
|
|
12
|
+
export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
|
|
13
|
+
export function analyticsPlan(input) {
|
|
14
|
+
const key = input.config?.posthogKey?.trim();
|
|
15
|
+
if (!key || input.owner || input.bot)
|
|
16
|
+
return null;
|
|
17
|
+
let identity = null;
|
|
18
|
+
if (input.kind === 'gated' && input.reader?.email) {
|
|
19
|
+
const email = input.reader.email.trim().toLowerCase();
|
|
20
|
+
identity = { distinctId: email, email, ...(input.reader.name ? { name: input.reader.name } : {}) };
|
|
21
|
+
}
|
|
22
|
+
else if (input.kind === 'per-reader' && input.recipient?.rid) {
|
|
23
|
+
identity = { distinctId: `recipient:${input.recipient.rid}`, name: input.recipient.label };
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
key,
|
|
27
|
+
host: input.config.posthogHost?.trim() || DEFAULT_POSTHOG_HOST,
|
|
28
|
+
pageId: input.pageId,
|
|
29
|
+
kind: input.kind,
|
|
30
|
+
tenant: input.config.tenant ?? null,
|
|
31
|
+
identity,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
@@ -275,7 +275,7 @@ export function ArtifactReader({ src, words, rootId, accent, ground, }) {
|
|
|
275
275
|
else
|
|
276
276
|
a.pause();
|
|
277
277
|
};
|
|
278
|
-
return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
|
|
278
|
+
return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, "data-artifact-narration": true, src: src, preload: "metadata", onPlay: () => {
|
|
279
279
|
startedRef.current = true;
|
|
280
280
|
playingRef.current = true;
|
|
281
281
|
setPlaying(true);
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -13,3 +13,5 @@ export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom
|
|
|
13
13
|
export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
|
|
14
14
|
export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
|
|
15
15
|
export { type SavedMemo } from './comment-card.js';
|
|
16
|
+
export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
|
|
17
|
+
export { analyticsPlan, DEFAULT_POSTHOG_HOST, type AnalyticsConfig, type AnalyticsPlan, type PageKind } from './analytics-plan.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -14,3 +14,5 @@ export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDe
|
|
|
14
14
|
export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
|
|
15
15
|
export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
|
|
16
16
|
export { deviceAudio, isDeviceAudio } from './device-audio.js';
|
|
17
|
+
export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS, posthogInitOptions, RECORDING_MASK } from './page-analytics.js';
|
|
18
|
+
export { analyticsPlan, DEFAULT_POSTHOG_HOST } from './analytics-plan.js';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { AnalyticsPlan } from './analytics-plan.js';
|
|
2
|
+
/** Sections read for less than this are passed over, not read. */
|
|
3
|
+
export declare const MIN_SECTION_SECONDS = 2;
|
|
4
|
+
type Capture = (event: string, props?: Record<string, unknown>) => void;
|
|
5
|
+
/** Times the section being read, by the contents rail's own rule, and reports each one as the
|
|
6
|
+
* reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
|
|
7
|
+
export declare function trackSections(ids: string[], capture: Capture, doc?: Document, now?: () => number): {
|
|
8
|
+
check: () => void;
|
|
9
|
+
stop: () => void;
|
|
10
|
+
};
|
|
11
|
+
/** The narration's events: play, pause with where it stopped, complete. */
|
|
12
|
+
export declare function narrationEvent(type: string, audio: {
|
|
13
|
+
currentTime: number;
|
|
14
|
+
duration: number;
|
|
15
|
+
ended?: boolean;
|
|
16
|
+
}): [string, Record<string, unknown>] | null;
|
|
17
|
+
/** How every replay is masked: every text node and every input, on every page kind. The options set
|
|
18
|
+
* in `posthog.init` override the PostHog project's own masking setting, so this is the whole rule. */
|
|
19
|
+
export declare const RECORDING_MASK: Readonly<{
|
|
20
|
+
readonly maskAllInputs: true;
|
|
21
|
+
readonly maskTextSelector: "*";
|
|
22
|
+
}>;
|
|
23
|
+
/** The options the page hands `posthog.init`. Pure, so a test can hold it to the mask. */
|
|
24
|
+
export declare function posthogInitOptions(plan: Pick<AnalyticsPlan, 'host'>): {
|
|
25
|
+
api_host: string;
|
|
26
|
+
person_profiles: "identified_only";
|
|
27
|
+
capture_pageview: boolean;
|
|
28
|
+
capture_pageleave: boolean;
|
|
29
|
+
autocapture: boolean;
|
|
30
|
+
mask_all_text: boolean;
|
|
31
|
+
enable_heatmaps: boolean;
|
|
32
|
+
disable_session_recording: boolean;
|
|
33
|
+
session_recording: {
|
|
34
|
+
maskAllInputs: true;
|
|
35
|
+
maskTextSelector: "*";
|
|
36
|
+
};
|
|
37
|
+
};
|
|
38
|
+
export declare function PageAnalytics({ plan, rootId }: {
|
|
39
|
+
plan: AnalyticsPlan;
|
|
40
|
+
rootId?: string;
|
|
41
|
+
}): null;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// The host's product analytics on a page: PostHog, with session replay, heatmaps, autocapture and
|
|
3
|
+
// scroll depth on leave, plus the reading this package knows about and PostHog cannot: which
|
|
4
|
+
// section was read and for how long, and the narration played, paused and finished.
|
|
5
|
+
//
|
|
6
|
+
// The server decides whether this mounts at all and as whom (analytics-plan.ts). posthog-js is
|
|
7
|
+
// imported only here, after mount, so a host with no key never downloads it.
|
|
8
|
+
//
|
|
9
|
+
// EVERY page's replay masks all text and all inputs, whatever its kind (since 0.51.0). A replay is a
|
|
10
|
+
// copy of the page held by a third party, and a page shared by key or by link is no less private
|
|
11
|
+
// than a gated one: the replay still shows where the reader went, how far and how fast, without the
|
|
12
|
+
// words. Autocapture is told not to collect element text for the same reason. There is no opt-out:
|
|
13
|
+
// the options come from posthogInitOptions, which a test holds to RECORDING_MASK for every kind.
|
|
14
|
+
import { useEffect } from 'react';
|
|
15
|
+
import { currentSection } from '../artifacts/toc-spy.js';
|
|
16
|
+
/** Sections read for less than this are passed over, not read. */
|
|
17
|
+
export const MIN_SECTION_SECONDS = 2;
|
|
18
|
+
/** Times the section being read, by the contents rail's own rule, and reports each one as the
|
|
19
|
+
* reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
|
|
20
|
+
export function trackSections(ids, capture, doc = document, now = Date.now) {
|
|
21
|
+
let current;
|
|
22
|
+
let since = null;
|
|
23
|
+
const visible = () => doc.visibilityState === 'visible';
|
|
24
|
+
const flush = () => {
|
|
25
|
+
if (!current || since === null)
|
|
26
|
+
return;
|
|
27
|
+
const seconds = Math.round((now() - since) / 1000);
|
|
28
|
+
if (seconds >= MIN_SECTION_SECONDS) {
|
|
29
|
+
const heading = doc.getElementById(current)?.textContent?.trim() ?? '';
|
|
30
|
+
capture('section_viewed', { section_id: current, heading, seconds });
|
|
31
|
+
}
|
|
32
|
+
since = null;
|
|
33
|
+
};
|
|
34
|
+
const check = () => {
|
|
35
|
+
if (!visible()) {
|
|
36
|
+
flush();
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const next = currentSection(ids, doc);
|
|
40
|
+
if (next !== current) {
|
|
41
|
+
flush();
|
|
42
|
+
current = next;
|
|
43
|
+
}
|
|
44
|
+
if (since === null)
|
|
45
|
+
since = now();
|
|
46
|
+
};
|
|
47
|
+
return { check, stop: flush };
|
|
48
|
+
}
|
|
49
|
+
/** The narration's events: play, pause with where it stopped, complete. */
|
|
50
|
+
export function narrationEvent(type, audio) {
|
|
51
|
+
const at = { position_seconds: Math.round(audio.currentTime), duration_seconds: Number.isFinite(audio.duration) ? Math.round(audio.duration) : null };
|
|
52
|
+
if (type === 'play')
|
|
53
|
+
return ['narration_play', at];
|
|
54
|
+
if (type === 'pause' && !audio.ended)
|
|
55
|
+
return ['narration_pause', at];
|
|
56
|
+
if (type === 'ended')
|
|
57
|
+
return ['narration_complete', at];
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
/** How every replay is masked: every text node and every input, on every page kind. The options set
|
|
61
|
+
* in `posthog.init` override the PostHog project's own masking setting, so this is the whole rule. */
|
|
62
|
+
export const RECORDING_MASK = Object.freeze({ maskAllInputs: true, maskTextSelector: '*' });
|
|
63
|
+
/** The options the page hands `posthog.init`. Pure, so a test can hold it to the mask. */
|
|
64
|
+
export function posthogInitOptions(plan) {
|
|
65
|
+
return {
|
|
66
|
+
api_host: plan.host,
|
|
67
|
+
person_profiles: 'identified_only',
|
|
68
|
+
capture_pageview: true,
|
|
69
|
+
capture_pageleave: true,
|
|
70
|
+
autocapture: true,
|
|
71
|
+
// Autocapture records a clicked element's text; on these pages that text is the page.
|
|
72
|
+
mask_all_text: true,
|
|
73
|
+
enable_heatmaps: true,
|
|
74
|
+
disable_session_recording: false,
|
|
75
|
+
session_recording: { ...RECORDING_MASK },
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
|
|
79
|
+
const { key, host, pageId, kind, tenant } = plan;
|
|
80
|
+
const identity = plan.identity ? JSON.stringify(plan.identity) : '';
|
|
81
|
+
useEffect(() => {
|
|
82
|
+
let cancelled = false;
|
|
83
|
+
let cleanup = () => { };
|
|
84
|
+
void import('posthog-js').then(({ default: posthog }) => {
|
|
85
|
+
if (cancelled)
|
|
86
|
+
return;
|
|
87
|
+
posthog.init(key, posthogInitOptions({ host }));
|
|
88
|
+
posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
|
|
89
|
+
if (identity) {
|
|
90
|
+
const who = JSON.parse(identity);
|
|
91
|
+
posthog.identify(who.distinctId, { ...(who.email ? { email: who.email } : {}), ...(who.name ? { name: who.name } : {}) });
|
|
92
|
+
}
|
|
93
|
+
const capture = (e, p) => posthog.capture(e, p);
|
|
94
|
+
const root = document.getElementById(rootId);
|
|
95
|
+
const ids = root ? Array.from(root.querySelectorAll('h2[id], h3[id]'), (h) => h.id) : [];
|
|
96
|
+
const sections = ids.length ? trackSections(ids, capture) : null;
|
|
97
|
+
let frame = 0;
|
|
98
|
+
const onScroll = () => { if (sections && !frame)
|
|
99
|
+
frame = requestAnimationFrame(() => { frame = 0; sections.check(); }); };
|
|
100
|
+
const onVisibility = () => sections?.check();
|
|
101
|
+
const onHide = () => sections?.stop();
|
|
102
|
+
const onMedia = (e) => {
|
|
103
|
+
const el = e.target;
|
|
104
|
+
if (!el?.hasAttribute?.('data-artifact-narration'))
|
|
105
|
+
return;
|
|
106
|
+
const ev = narrationEvent(e.type, el);
|
|
107
|
+
if (ev)
|
|
108
|
+
capture(...ev);
|
|
109
|
+
};
|
|
110
|
+
sections?.check();
|
|
111
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
112
|
+
document.addEventListener('visibilitychange', onVisibility);
|
|
113
|
+
window.addEventListener('pagehide', onHide);
|
|
114
|
+
for (const t of ['play', 'pause', 'ended'])
|
|
115
|
+
document.addEventListener(t, onMedia, true);
|
|
116
|
+
cleanup = () => {
|
|
117
|
+
sections?.stop();
|
|
118
|
+
if (frame)
|
|
119
|
+
cancelAnimationFrame(frame);
|
|
120
|
+
window.removeEventListener('scroll', onScroll);
|
|
121
|
+
document.removeEventListener('visibilitychange', onVisibility);
|
|
122
|
+
window.removeEventListener('pagehide', onHide);
|
|
123
|
+
for (const t of ['play', 'pause', 'ended'])
|
|
124
|
+
document.removeEventListener(t, onMedia, true);
|
|
125
|
+
};
|
|
126
|
+
}).catch(() => { });
|
|
127
|
+
return () => { cancelled = true; cleanup(); };
|
|
128
|
+
}, [key, host, pageId, kind, tenant, identity, rootId]);
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
@@ -10,6 +10,7 @@ import { type Access, type AllowEntry } from '../artifacts/reader.js';
|
|
|
10
10
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
11
11
|
import { type ReaderEvent } from '../artifacts/read-alerts.js';
|
|
12
12
|
import type { ResolveEvent } from '../artifacts/resolve-digest.js';
|
|
13
|
+
import { type AnalyticsConfig } from '../reader/analytics-plan.js';
|
|
13
14
|
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
14
15
|
import type { Transcriber } from '../artifacts/transcribe.js';
|
|
15
16
|
export type ArtifactRoutesConfig = {
|
|
@@ -121,6 +122,12 @@ export type ArtifactRoutesConfig = {
|
|
|
121
122
|
recipients?: RecipientsStore;
|
|
122
123
|
/** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
|
|
123
124
|
recipientSecret?: () => string | undefined;
|
|
125
|
+
/** Product analytics for this host's pages (0.50.0): PostHog with session replay, heatmaps,
|
|
126
|
+
* autocapture, scroll depth on leave, and section_viewed / narration_* events. Without a key no
|
|
127
|
+
* page loads any analytics script. Never records the owner or an admin, never a link-preview
|
|
128
|
+
* fetcher, never a past version; identifies a reader only on a gated page (their email) or a
|
|
129
|
+
* per-reader link (its recipient id and label). See reader/analytics-plan.ts. */
|
|
130
|
+
analytics?: AnalyticsConfig | null;
|
|
124
131
|
};
|
|
125
132
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
126
133
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -217,6 +224,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
217
224
|
id: string;
|
|
218
225
|
}>;
|
|
219
226
|
}) => Promise<NextResponse<unknown>>;
|
|
227
|
+
HEALTH: (_req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
220
228
|
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
221
229
|
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
222
230
|
VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -36,6 +36,8 @@ import { ARTIFACT_ID_RE } from './ids.js';
|
|
|
36
36
|
import { createStateRoutes } from './state-routes.js';
|
|
37
37
|
import { createRecipientsRoutes } from './recipients-routes.js';
|
|
38
38
|
import { QuietView } from '../reader/quiet-view.js';
|
|
39
|
+
import { PageAnalytics } from '../reader/page-analytics.js';
|
|
40
|
+
import { analyticsPlan } from '../reader/analytics-plan.js';
|
|
39
41
|
import { createPersonalRoutes } from './personal-routes.js';
|
|
40
42
|
import { createVoiceRoutes } from './voice-routes.js';
|
|
41
43
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
@@ -317,7 +319,7 @@ export function createArtifactRoutes(config) {
|
|
|
317
319
|
if (!a)
|
|
318
320
|
notFound();
|
|
319
321
|
const { pack, brand } = lookOf(a);
|
|
320
|
-
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
322
|
+
const render = (top, reader, untracked = false) => (n === null ? Body(a, { top, reader, untracked }) : VersionBody(a, n, { top, reader }));
|
|
321
323
|
if (a.access)
|
|
322
324
|
return GatedPage(a, id, render, n);
|
|
323
325
|
const broadcast = kindOf(a) === 'broadcast';
|
|
@@ -338,7 +340,7 @@ export function createArtifactRoutes(config) {
|
|
|
338
340
|
if (isAdmin(me)) {
|
|
339
341
|
const markdown = await media(a.markdown);
|
|
340
342
|
const viewer = wantsReaderView(sp) ? null : me;
|
|
341
|
-
const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot }) : await Body({ ...a, markdown }, { top: true, reader: viewer });
|
|
343
|
+
const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot, untracked: true }) : await Body({ ...a, markdown }, { top: true, reader: viewer, untracked: true });
|
|
342
344
|
return _jsxs(BrandGround, { pack: pack, mode: a.theme, children: [viewer ? null : _jsx(ViewingAsReader, { back: pagePath(id) }), page] });
|
|
343
345
|
}
|
|
344
346
|
const secret = usableSecret(config.recipientSecret?.());
|
|
@@ -389,7 +391,7 @@ export function createArtifactRoutes(config) {
|
|
|
389
391
|
// rendered for no one signed in, with a bar back to their own view. Anyone else asking gets the
|
|
390
392
|
// page they already get.
|
|
391
393
|
const asReader = isAdmin(reader) && wantsReaderView(sp);
|
|
392
|
-
const page = await render(true, asReader ? null : reader);
|
|
394
|
+
const page = await render(true, asReader ? null : reader, asReader);
|
|
393
395
|
if (n === null && !asReader)
|
|
394
396
|
void store.bumpViews(id);
|
|
395
397
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, asReader ? _jsx(ViewingAsReader, { back: n === null ? pagePath(id) : `${pagePath(id)}/v/${n}` }) : null, page] }));
|
|
@@ -481,7 +483,7 @@ export function createArtifactRoutes(config) {
|
|
|
481
483
|
* them, then this reader's own note. None of the document's furniture: no table of contents, no
|
|
482
484
|
* version line, no read-along (a voice note is its own audio), and no page menu for a reader.
|
|
483
485
|
* Comments only when the file turned them on; the quiet view beacon always, for a reader. */
|
|
484
|
-
async function BroadcastCard(a, { reader, perReader, bot }) {
|
|
486
|
+
async function BroadcastCard(a, { reader, perReader, bot, untracked = false }) {
|
|
485
487
|
const { brand } = lookOf(a);
|
|
486
488
|
const mode = !bot && config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
487
489
|
const admin = isAdmin(reader);
|
|
@@ -489,10 +491,10 @@ export function createArtifactRoutes(config) {
|
|
|
489
491
|
const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
|
|
490
492
|
return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
|
|
491
493
|
// eslint-disable-next-line @next/next/no-img-element
|
|
492
|
-
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
|
|
494
|
+
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
|
|
493
495
|
}
|
|
494
496
|
/** The page itself, shared by open and gated pages. */
|
|
495
|
-
async function Body(a, { top, reader, perReader }) {
|
|
497
|
+
async function Body(a, { top, reader, perReader, untracked = false }) {
|
|
496
498
|
const { pack, brand } = lookOf(a);
|
|
497
499
|
let words = [];
|
|
498
500
|
if (a.narration && a.timings) {
|
|
@@ -517,10 +519,27 @@ export function createArtifactRoutes(config) {
|
|
|
517
519
|
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,
|
|
518
520
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
519
521
|
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, embeds: embeds }) }));
|
|
520
|
-
})() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
522
|
+
})() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
521
523
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
522
524
|
recorded at publish, or there is none. */] }));
|
|
523
525
|
}
|
|
526
|
+
/** The page's analytics, or nothing (analytics-plan.ts has the rules). `untracked` is the owner or
|
|
527
|
+
* an admin previewing as a reader, who arrives here with no reader at all. */
|
|
528
|
+
async function Analytics(a, { reader, perReader, untracked, bot }) {
|
|
529
|
+
if (!config.analytics?.posthogKey)
|
|
530
|
+
return null;
|
|
531
|
+
const ua = bot === undefined ? await (config.readHeader ?? defaultReadHeader)('user-agent').catch(() => undefined) : undefined;
|
|
532
|
+
const plan = analyticsPlan({
|
|
533
|
+
config: config.analytics,
|
|
534
|
+
pageId: a.id,
|
|
535
|
+
kind: perReader ? 'per-reader' : a.access ? 'gated' : a.password ? 'password' : 'public',
|
|
536
|
+
owner: untracked || isAdmin(reader) || !!perReader?.owner,
|
|
537
|
+
bot: bot ?? (ua !== undefined && isUnfurlAgent(ua)),
|
|
538
|
+
reader: a.access ? reader : null,
|
|
539
|
+
recipient: perReader ? { rid: perReader.rid, label: perReader.label } : null,
|
|
540
|
+
});
|
|
541
|
+
return plan ? _jsx(PageAnalytics, { plan: plan }) : null;
|
|
542
|
+
}
|
|
524
543
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
525
544
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
526
545
|
* reader. Swaps the five-minute pass for this host's grant and lands them on the page, with
|
|
@@ -65,7 +65,7 @@ export function createRecipientsRoutes(given) {
|
|
|
65
65
|
const rec = (await ctx.recipients.get(id)) ?? { recipients: {}, expireAt: null, keep: false };
|
|
66
66
|
return {
|
|
67
67
|
id, expireAt: rec.expireAt, keep: rec.keep,
|
|
68
|
-
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
68
|
+
recipients: Object.values(rec.recipients).map((r) => ({ rid: r.rid, label: r.label, group: r.group === true, owner: r.owner === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null, sentAt: r.sentAt ?? null, url: ridLink(ctx.pageUrl(id), id, r.rid, secret) })),
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
71
|
/** GET and POST /api/artifacts/<id>/recipients: who a per-reader page was sent to (publish key). */
|
|
@@ -105,6 +105,22 @@ export function createRecipientsRoutes(given) {
|
|
|
105
105
|
if (!ctx.recipients.setCloses)
|
|
106
106
|
return json({ error: 'this host cannot set a feedback window' }, 501);
|
|
107
107
|
}
|
|
108
|
+
// `sent`: the sender's word that these links reached their readers, at `at` (default: now, on
|
|
109
|
+
// this host's clock). Views that began earlier are then not the reader's (summarizeViews). A
|
|
110
|
+
// time in the future is refused: it would hide a real reader's open.
|
|
111
|
+
const sent = b.sent;
|
|
112
|
+
let sentAt = null;
|
|
113
|
+
if (sent !== undefined) {
|
|
114
|
+
const ridsOk = !!sent && typeof sent === 'object' && (sent.rids === '*' || (Array.isArray(sent.rids) && sent.rids.every((x) => typeof x === 'string' && RID_RE.test(x))));
|
|
115
|
+
const atMs = sent && typeof sent === 'object' && sent.at !== undefined ? (typeof sent.at === 'string' && ISO.test(sent.at) ? Date.parse(sent.at) : NaN) : Date.now();
|
|
116
|
+
if (!ridsOk || !Number.isFinite(atMs))
|
|
117
|
+
return json({ error: 'sent is { rids: [rid] or "*", at: an ISO time (default now) }' }, 400);
|
|
118
|
+
if (atMs > Date.now() + 60_000)
|
|
119
|
+
return json({ error: 'sent.at must not be in the future' }, 400);
|
|
120
|
+
if (!ctx.recipients.setSent)
|
|
121
|
+
return json({ error: 'this host cannot record a send' }, 501);
|
|
122
|
+
sentAt = new Date(atMs).toISOString();
|
|
123
|
+
}
|
|
108
124
|
if (adds.length) {
|
|
109
125
|
const made = await ctx.recipients.add(id, adds.map((x) => ({
|
|
110
126
|
label: x.label,
|
|
@@ -117,11 +133,12 @@ export function createRecipientsRoutes(given) {
|
|
|
117
133
|
const revoked = b.revoke === '*' || Array.isArray(b.revoke)
|
|
118
134
|
? await ctx.recipients.revoke(id, b.revoke === '*' ? '*' : b.revoke.filter((x) => typeof x === 'string' && RID_RE.test(x)))
|
|
119
135
|
: [];
|
|
136
|
+
const marked = sentAt ? await ctx.recipients.setSent(id, b.sent.rids, sentAt) : [];
|
|
120
137
|
const windowed = win ? await ctx.recipients.setCloses(id, win.rids, win.at === null ? null : new Date(win.at).toISOString()) : [];
|
|
121
138
|
if (b.expires === null || typeof b.expires === 'string') {
|
|
122
139
|
await ctx.recipients.setExpiry(id, b.expires === null ? null : new Date(b.expires).toISOString());
|
|
123
140
|
}
|
|
124
|
-
return json({ ...(await listing(id)), added, revoked, windowed });
|
|
141
|
+
return json({ ...(await listing(id)), added, revoked, windowed, sent: marked });
|
|
125
142
|
}
|
|
126
143
|
/** GET /api/artifacts/<id>/views: one line per reader, with their comments (publish key). */
|
|
127
144
|
async function VIEWS(req, { params }) {
|
|
@@ -62,5 +62,6 @@ export declare function createStateRoutes(ctx: StateRoutesContext): {
|
|
|
62
62
|
COMMENTS_FEED: (req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
63
63
|
RESOLVE: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
64
64
|
REPLY: (req: NextRequest, { params }: Params) => Promise<NextResponse<unknown>>;
|
|
65
|
+
HEALTH: (_req: NextRequest) => Promise<NextResponse<unknown>>;
|
|
65
66
|
};
|
|
66
67
|
export {};
|
|
@@ -30,6 +30,7 @@ import { COMMENTS_SLOT, FEEDBACK_CLOSED, MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHA
|
|
|
30
30
|
import { MAX_QUOTE_CHARS } from '../artifacts/anchor.js';
|
|
31
31
|
import { isCommentAudioPath } from '../artifacts/audio.js';
|
|
32
32
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
33
|
+
import { COMMENTS_FEED_INDEX, missingIndexOf } from '../artifacts/firestore-indexes.js';
|
|
33
34
|
import { NOTES_SLOT, checkNoteValue, checklistAnswerIds, hasNotesWidget } from '../artifacts/widgets.js';
|
|
34
35
|
import { CHECKLIST_SLOT, checkChecklistValue } from '../artifacts/checklist.js';
|
|
35
36
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
@@ -443,9 +444,20 @@ export function createStateRoutes(ctx) {
|
|
|
443
444
|
if (only === null && !ctx.state.slotSince)
|
|
444
445
|
return json({ error: 'this host cannot list comments by time' }, 501);
|
|
445
446
|
const now = new Date().toISOString();
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
447
|
+
let entries;
|
|
448
|
+
try {
|
|
449
|
+
entries = only !== null
|
|
450
|
+
? (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)
|
|
451
|
+
: await ctx.state.slotSince(COMMENTS_SLOT, since, FEED_MAX);
|
|
452
|
+
}
|
|
453
|
+
catch (err) {
|
|
454
|
+
// Firestore refuses the cross-page query until its composite index exists. Say which index,
|
|
455
|
+
// rather than a bare 500 the publisher's notifier cannot act on (October 2026).
|
|
456
|
+
const missing = missingIndexOf(err);
|
|
457
|
+
if (!missing)
|
|
458
|
+
throw err;
|
|
459
|
+
return json({ error: 'missing_firestore_index', index: COMMENTS_FEED_INDEX, create: missing.create }, 503);
|
|
460
|
+
}
|
|
449
461
|
const pages = new Map();
|
|
450
462
|
const pageOf = (id) => { if (!pages.has(id))
|
|
451
463
|
pages.set(id, ctx.store.get(id)); return pages.get(id); };
|
|
@@ -639,5 +651,26 @@ export function createStateRoutes(ctx) {
|
|
|
639
651
|
return json({ error: 'this thread takes no more replies' }, 409);
|
|
640
652
|
return json({ id, entry: r.id, parent: root.id, existing: false });
|
|
641
653
|
}
|
|
642
|
-
|
|
654
|
+
/** GET /api/health: whether the queries this host's routes run will be served, today the
|
|
655
|
+
* comments feed's composite index. 200 with `ok: true`, or 503 naming what is missing. No key:
|
|
656
|
+
* it reveals nothing a public package does not already say, and a deploy check can call it. The
|
|
657
|
+
* probe asks for one comment after now, so it reads at most one document. */
|
|
658
|
+
async function HEALTH(_req) {
|
|
659
|
+
const checks = [];
|
|
660
|
+
if (ctx.state?.slotSince) {
|
|
661
|
+
try {
|
|
662
|
+
await ctx.state.slotSince(COMMENTS_SLOT, new Date().toISOString(), 1);
|
|
663
|
+
checks.push({ name: 'comments-feed-index', ok: true });
|
|
664
|
+
}
|
|
665
|
+
catch (err) {
|
|
666
|
+
const missing = missingIndexOf(err);
|
|
667
|
+
checks.push(missing
|
|
668
|
+
? { name: 'comments-feed-index', ok: false, error: 'missing_firestore_index', index: COMMENTS_FEED_INDEX, create: missing.create }
|
|
669
|
+
: { name: 'comments-feed-index', ok: false, error: 'query_failed' });
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
const ok = checks.every((c) => c.ok === true);
|
|
673
|
+
return json({ ok, checks }, ok ? 200 : 503);
|
|
674
|
+
}
|
|
675
|
+
return { STATE_GET, STATE_POST, RESPONSES, COMMENTS_FEED, RESOLVE, REPLY, HEALTH };
|
|
643
676
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.52.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",
|
|
@@ -80,6 +80,7 @@
|
|
|
80
80
|
},
|
|
81
81
|
"dependencies": {
|
|
82
82
|
"gray-matter": "^4.0.3",
|
|
83
|
+
"posthog-js": "1.438.6",
|
|
83
84
|
"react-markdown": "^9.0.3",
|
|
84
85
|
"remark-gfm": "^4.0.1",
|
|
85
86
|
"remark-parse": "^11.0.0",
|