@supersuit/artifacts 0.19.0 → 0.21.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 +45 -0
- package/README.md +37 -0
- package/lib/artifacts/index.d.ts +2 -1
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/read-alerts.d.ts +87 -0
- package/lib/artifacts/read-alerts.js +75 -0
- package/lib/artifacts/readers-store.d.ts +8 -0
- package/lib/artifacts/readers-store.js +7 -1
- package/lib/routes/artifacts.d.ts +21 -1
- package/lib/routes/artifacts.js +81 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,51 @@
|
|
|
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.21.0 (2026-10-08)
|
|
7
|
+
|
|
8
|
+
**A page's past versions are for its admins.** Every reader who could open a page could also open
|
|
9
|
+
its History and read every earlier version at `/<id>/v/<n>`, so a passage edited out of a page
|
|
10
|
+
(a name taken out, a figure corrected) stayed readable one click away.
|
|
11
|
+
|
|
12
|
+
- New config `admins` (sign-in emails) and `history: 'admins' | 'everyone'`. The default,
|
|
13
|
+
`'admins'`, shows the History control and serves `/<id>/v/<n>` only to a signed-in reader whose
|
|
14
|
+
email is `ownerEmail` or in `admins`, compared without case. To everyone else the control is
|
|
15
|
+
absent and a past version is not found. "Version N · Updated <minute>" still shows to everyone.
|
|
16
|
+
- DETECTOR: after the bump, a reader who is not an admin no longer sees "History" under a page's
|
|
17
|
+
summary. A host that configures neither `ownerEmail` nor `admins` hides history from everyone.
|
|
18
|
+
- REMEDY: pass `admins` (and `ownerEmail`) for the people who should keep seeing history, or
|
|
19
|
+
`history: 'everyone'` to keep the 0.20.0 behaviour.
|
|
20
|
+
- Tests: signed out, a signed-in non-admin, the owner in another case and a listed admin, a past
|
|
21
|
+
version 404ing for non-admins, nobody configured, and `'everyone'` restoring the old page. The
|
|
22
|
+
earlier version and golden tests pin `history: 'everyone'`, so they still prove 0.20.0's page.
|
|
23
|
+
|
|
24
|
+
## 0.20.0 (2026-10-07)
|
|
25
|
+
|
|
26
|
+
**A host can hear when a confidential page is read.** The reader record answered `GET /reads`
|
|
27
|
+
when asked and told nobody when it changed. One generic hook and one pure function close that,
|
|
28
|
+
and the package still sends nothing anywhere.
|
|
29
|
+
|
|
30
|
+
- New config `onReaderEvent(event)`: called after a reading heartbeat is recorded (`visit`),
|
|
31
|
+
after a signed-in person is refused at the door (`refused`, after the refusal is on the
|
|
32
|
+
record), and for every listed reader of every gated page when the host calls the new
|
|
33
|
+
`routes.sweepReaders()` (`sweep`, for a daily job). The event carries the page, the reader,
|
|
34
|
+
their `summary`, their list `entry` (with `addedAt`), and the page's `words` and `readSeconds`.
|
|
35
|
+
Awaited; anything it throws is swallowed.
|
|
36
|
+
- New pure `readAlertsDue(...)` and helpers (`readSecondsFor`, `wordCount`, `localDay`, the
|
|
37
|
+
thresholds as constants): first-open, refused, finished (90% scroll AND a third of the reading
|
|
38
|
+
time at 230 wpm), came-back (a later local day), quiet (listed 3 to 14 days ago, never opened),
|
|
39
|
+
each once per reader per page, never for `ignore`d addresses. First-open and came-back only
|
|
40
|
+
fire within a day of the visit, so a host turning alerts on over old history is not flooded.
|
|
41
|
+
- `ReaderSummary` gains `lastStarted`; `ReadersStore` gains optional `listed()` (the Firestore
|
|
42
|
+
store implements it), so a store written earlier still type-checks.
|
|
43
|
+
- DETECTOR: a host that passes `onReaderEvent` and gets a type error is on 0.19.x.
|
|
44
|
+
- REMEDY: bump. Nothing changes for a host that does not pass the hook: no extra reads, and a
|
|
45
|
+
refusal is recorded exactly as before.
|
|
46
|
+
- Tests: every alert rule, its once-only guard, the ignore list, the local-day zone, the quiet
|
|
47
|
+
window; the hook's payload for a beat, a refusal (ordered after the flag) and a sweep; a
|
|
48
|
+
throwing or rejecting sink costs the reader nothing; no extra reads without the hook. Each rule
|
|
49
|
+
was broken on purpose and went red.
|
|
50
|
+
|
|
6
51
|
## 0.19.0 (2026-09-30)
|
|
7
52
|
|
|
8
53
|
**A code block has a copy button, and it wraps.** Both were found on one page: an operator
|
package/README.md
CHANGED
|
@@ -123,6 +123,17 @@ phone, a side panel on a desk), so opening it moves no text. Each past version i
|
|
|
123
123
|
the same password or confidential door as the page itself. A version that does not exist is a
|
|
124
124
|
404; on a shut page the door comes first, so it never says which versions exist.
|
|
125
125
|
|
|
126
|
+
**Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
|
|
127
|
+
one was edited to remove, so only the page's admins see the History control or open
|
|
128
|
+
`/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
|
|
129
|
+
matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
|
|
130
|
+
them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
|
|
131
|
+
behaviour, where every reader who can open the page reads its history.
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
createArtifactRoutes({ ..., ownerEmail: 'owner@example.com', admins: ['editor@example.com'] })
|
|
135
|
+
```
|
|
136
|
+
|
|
126
137
|
**Do not write a "Version history" section in the markdown.** Say what changed when you publish:
|
|
127
138
|
|
|
128
139
|
- `POST /api/artifacts?id=<id>¬e=<one line>`, or a `change:` line in the front matter. The query
|
|
@@ -215,6 +226,32 @@ name, says why they can read it, tiles their address as a faint watermark, and r
|
|
|
215
226
|
time, scroll depth, and attempts to print, copy at length, or save. Refused sign-ins are
|
|
216
227
|
recorded too, which is how a forwarded link shows up.
|
|
217
228
|
|
|
229
|
+
### Knowing when it is read (`onReaderEvent`, `readAlertsDue`)
|
|
230
|
+
|
|
231
|
+
The record answers when asked; `onReaderEvent` is how a host hears that it changed. It is called
|
|
232
|
+
after a reading heartbeat is recorded (`kind: 'visit'`), after a signed-in person is refused at
|
|
233
|
+
the door (`'refused'`, with the account they tried), and for every listed reader when the host
|
|
234
|
+
calls `routes.sweepReaders()` from a daily job (`'sweep'`, so a host can notice a page nobody
|
|
235
|
+
opened). Each event carries the page (id, title, url), the reader, their totals (`summary`, or
|
|
236
|
+
null), their list entry (with `addedAt`), and the page's `words` and `readSeconds` at 230 words a
|
|
237
|
+
minute. It is awaited and anything it throws is swallowed, so a broken sink never costs a reader
|
|
238
|
+
their page. Without it nothing extra is read.
|
|
239
|
+
|
|
240
|
+
The package sends nothing anywhere. `readAlertsDue({ sent, email, summary, refusedAttempt,
|
|
241
|
+
readSeconds, now, addedAt, timeZone, ignore })` turns one event into the alerts worth sending,
|
|
242
|
+
each at most once per reader per page (pass back what you already sent):
|
|
243
|
+
|
|
244
|
+
| Kind | When |
|
|
245
|
+
| --- | --- |
|
|
246
|
+
| `first-open` | their first visit, within the last day |
|
|
247
|
+
| `refused` | a refusal at the door, with the account tried |
|
|
248
|
+
| `finished` | scrolled to 90% AND read for a third of the page's reading time |
|
|
249
|
+
| `came-back` | a visit that began on a later local day (in `timeZone`) than the first |
|
|
250
|
+
| `quiet` | listed 3 to 14 days ago and never opened |
|
|
251
|
+
|
|
252
|
+
Addresses in `ignore` (the publisher's own) never alert. Where an alert goes, and how a host
|
|
253
|
+
remembers what it sent, is the host's business.
|
|
254
|
+
|
|
218
255
|
### The reader pass (the contract with your sign-in side)
|
|
219
256
|
|
|
220
257
|
This package does not sign anyone in. It sends readers to your sign-in authority and trusts a
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -7,7 +7,8 @@ export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, typ
|
|
|
7
7
|
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
10
|
-
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
|
|
10
|
+
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
|
|
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';
|
|
11
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';
|
|
12
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
13
14
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -8,6 +8,7 @@ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAM
|
|
|
8
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
9
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
10
10
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
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';
|
|
11
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';
|
|
12
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
13
14
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { ReaderSummary } from './readers-store.js';
|
|
2
|
+
export declare const READ_ALERT_KINDS: readonly ["first-open", "refused", "finished", "came-back", "quiet"];
|
|
3
|
+
export type ReadAlertKind = (typeof READ_ALERT_KINDS)[number];
|
|
4
|
+
/** A normal adult reading pace, used to turn a page's word count into its reading time. */
|
|
5
|
+
export declare const WORDS_PER_MINUTE = 230;
|
|
6
|
+
/** How far down counts as reaching the end, in percent of the page. */
|
|
7
|
+
export declare const FINISHED_SCROLL = 90;
|
|
8
|
+
/** The share of the page's reading time a reader must actually spend for "finished". */
|
|
9
|
+
export declare const FINISHED_SHARE: number;
|
|
10
|
+
/** Days after a reader is listed before an unopened page earns one nudge. */
|
|
11
|
+
export declare const QUIET_AFTER_DAYS = 3;
|
|
12
|
+
/** Past this many days after listing, a never-opened page stays silent: a sweep run for the first
|
|
13
|
+
* time over an old list must not nudge about pages everyone has long forgotten. */
|
|
14
|
+
export declare const QUIET_WINDOW_DAYS = 14;
|
|
15
|
+
/** First-open and came-back describe something that just happened. A record older than this
|
|
16
|
+
* (a host turning alerts on over months of history) is not news, and does not alert. */
|
|
17
|
+
export declare const FRESH_HOURS = 24;
|
|
18
|
+
/** Words a reader reads: whitespace-separated tokens. */
|
|
19
|
+
export declare function wordCount(text: string): number;
|
|
20
|
+
/** Seconds a page takes to read at `wpm`. */
|
|
21
|
+
export declare function readSecondsFor(words: number, wpm?: number): number;
|
|
22
|
+
/** The calendar day an instant falls on in `timeZone`, as YYYY-MM-DD. An unknown zone reads as UTC
|
|
23
|
+
* rather than throwing, because a bad zone must never swallow an alert. */
|
|
24
|
+
export declare function localDay(at: string | Date, timeZone?: string): string;
|
|
25
|
+
export type ReadAlertInput = {
|
|
26
|
+
/** The kinds already sent for this reader on this page. */
|
|
27
|
+
sent: Iterable<ReadAlertKind>;
|
|
28
|
+
/** The reader the record is about: the account that read, or the account that was refused. */
|
|
29
|
+
email: string;
|
|
30
|
+
/** Their totals on this page (from `summarize`), or null when they have never opened it. */
|
|
31
|
+
summary?: Pick<ReaderSummary, 'sessions' | 'activeSeconds' | 'maxScroll' | 'firstSeen' | 'lastSeen'> & {
|
|
32
|
+
lastStarted?: string;
|
|
33
|
+
} | null;
|
|
34
|
+
/** True when this evaluation is for a refusal at the door that just happened. */
|
|
35
|
+
refusedAttempt?: boolean;
|
|
36
|
+
/** The page's reading time in seconds (`readSecondsFor`). Zero or absent: reaching the end is enough. */
|
|
37
|
+
readSeconds?: number;
|
|
38
|
+
now: string | Date;
|
|
39
|
+
/** When the reader was put on the page's list (AllowEntry.addedAt). Needed for quiet. */
|
|
40
|
+
addedAt?: string;
|
|
41
|
+
/** The zone "a later day" is judged in. Default UTC. */
|
|
42
|
+
timeZone?: string;
|
|
43
|
+
/** Addresses whose reads never alert: the publisher, the owners. Compared case-insensitively. */
|
|
44
|
+
ignore?: Iterable<string>;
|
|
45
|
+
quietAfterDays?: number;
|
|
46
|
+
};
|
|
47
|
+
export type ReadAlert = {
|
|
48
|
+
kind: ReadAlertKind;
|
|
49
|
+
email: string;
|
|
50
|
+
sessions: number;
|
|
51
|
+
activeSeconds: number;
|
|
52
|
+
maxScroll: number;
|
|
53
|
+
/** quiet only: whole days since the reader was listed. */
|
|
54
|
+
daysSinceAdded?: number;
|
|
55
|
+
};
|
|
56
|
+
/** The alerts due for one reader on one page, given what was already sent. Pure. */
|
|
57
|
+
export declare function readAlertsDue(input: ReadAlertInput): ReadAlert[];
|
|
58
|
+
/** What `onReaderEvent` hands a host: one reader's state on one gated page, just after it changed.
|
|
59
|
+
*
|
|
60
|
+
* - `visit`: a reading heartbeat was recorded (`summary` is their record so far).
|
|
61
|
+
* - `refused`: a signed-in person was turned away at the door (`reader` is the account tried).
|
|
62
|
+
* - `sweep`: a listed reader, visited by `sweepReaders()` (a daily job) whether or not anything
|
|
63
|
+
* changed, so a host can notice what did NOT happen (quiet). */
|
|
64
|
+
export type ReaderEvent = {
|
|
65
|
+
kind: 'visit' | 'refused' | 'sweep';
|
|
66
|
+
artifactId: string;
|
|
67
|
+
title: string;
|
|
68
|
+
/** The page's address, e.g. https://artifacts.example.com/abc23456. */
|
|
69
|
+
url: string;
|
|
70
|
+
reader: {
|
|
71
|
+
email: string;
|
|
72
|
+
name: string | null;
|
|
73
|
+
};
|
|
74
|
+
/** Their totals on this page, or null when they have never opened it. */
|
|
75
|
+
summary: ReaderSummary | null;
|
|
76
|
+
/** Their list entry on this page, or null (a member reading by membership, or a refusal). */
|
|
77
|
+
entry: {
|
|
78
|
+
email: string;
|
|
79
|
+
name?: string;
|
|
80
|
+
addedAt?: string;
|
|
81
|
+
} | null;
|
|
82
|
+
/** Words a reader reads on the page, and the seconds that takes at WORDS_PER_MINUTE. */
|
|
83
|
+
words: number;
|
|
84
|
+
readSeconds: number;
|
|
85
|
+
/** When the event happened, ISO. */
|
|
86
|
+
at: string;
|
|
87
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export const READ_ALERT_KINDS = ['first-open', 'refused', 'finished', 'came-back', 'quiet'];
|
|
2
|
+
/** A normal adult reading pace, used to turn a page's word count into its reading time. */
|
|
3
|
+
export const WORDS_PER_MINUTE = 230;
|
|
4
|
+
/** How far down counts as reaching the end, in percent of the page. */
|
|
5
|
+
export const FINISHED_SCROLL = 90;
|
|
6
|
+
/** The share of the page's reading time a reader must actually spend for "finished". */
|
|
7
|
+
export const FINISHED_SHARE = 1 / 3;
|
|
8
|
+
/** Days after a reader is listed before an unopened page earns one nudge. */
|
|
9
|
+
export const QUIET_AFTER_DAYS = 3;
|
|
10
|
+
/** Past this many days after listing, a never-opened page stays silent: a sweep run for the first
|
|
11
|
+
* time over an old list must not nudge about pages everyone has long forgotten. */
|
|
12
|
+
export const QUIET_WINDOW_DAYS = 14;
|
|
13
|
+
/** First-open and came-back describe something that just happened. A record older than this
|
|
14
|
+
* (a host turning alerts on over months of history) is not news, and does not alert. */
|
|
15
|
+
export const FRESH_HOURS = 24;
|
|
16
|
+
const DAY_MS = 86_400_000;
|
|
17
|
+
const HOUR_MS = 3_600_000;
|
|
18
|
+
/** Words a reader reads: whitespace-separated tokens. */
|
|
19
|
+
export function wordCount(text) {
|
|
20
|
+
const t = text.trim();
|
|
21
|
+
return t ? t.split(/\s+/).length : 0;
|
|
22
|
+
}
|
|
23
|
+
/** Seconds a page takes to read at `wpm`. */
|
|
24
|
+
export function readSecondsFor(words, wpm = WORDS_PER_MINUTE) {
|
|
25
|
+
return words > 0 ? Math.round((words / wpm) * 60) : 0;
|
|
26
|
+
}
|
|
27
|
+
/** The calendar day an instant falls on in `timeZone`, as YYYY-MM-DD. An unknown zone reads as UTC
|
|
28
|
+
* rather than throwing, because a bad zone must never swallow an alert. */
|
|
29
|
+
export function localDay(at, timeZone = 'UTC') {
|
|
30
|
+
const d = typeof at === 'string' ? new Date(at) : at;
|
|
31
|
+
try {
|
|
32
|
+
return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return d.toISOString().slice(0, 10);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** The alerts due for one reader on one page, given what was already sent. Pure. */
|
|
39
|
+
export function readAlertsDue(input) {
|
|
40
|
+
const email = input.email.trim().toLowerCase();
|
|
41
|
+
const ignore = new Set([...(input.ignore ?? [])].map((e) => e.trim().toLowerCase()).filter(Boolean));
|
|
42
|
+
if (!email || ignore.has(email))
|
|
43
|
+
return [];
|
|
44
|
+
const sent = new Set(input.sent);
|
|
45
|
+
const now = (typeof input.now === 'string' ? new Date(input.now) : input.now).getTime();
|
|
46
|
+
const s = input.summary && input.summary.sessions > 0 ? input.summary : null;
|
|
47
|
+
const base = { email, sessions: s?.sessions ?? 0, activeSeconds: s?.activeSeconds ?? 0, maxScroll: s?.maxScroll ?? 0 };
|
|
48
|
+
const fresh = (at) => !!at && now - new Date(at).getTime() <= FRESH_HOURS * HOUR_MS;
|
|
49
|
+
const out = [];
|
|
50
|
+
if (input.refusedAttempt) {
|
|
51
|
+
if (!sent.has('refused'))
|
|
52
|
+
out.push({ kind: 'refused', ...base });
|
|
53
|
+
return out;
|
|
54
|
+
}
|
|
55
|
+
if (s) {
|
|
56
|
+
if (!sent.has('first-open') && fresh(s.firstSeen))
|
|
57
|
+
out.push({ kind: 'first-open', ...base });
|
|
58
|
+
const started = s.lastStarted;
|
|
59
|
+
const zone = input.timeZone ?? 'UTC';
|
|
60
|
+
if (!sent.has('came-back') && started && fresh(started) && localDay(started, zone) > localDay(s.firstSeen, zone))
|
|
61
|
+
out.push({ kind: 'came-back', ...base });
|
|
62
|
+
const need = (input.readSeconds ?? 0) * FINISHED_SHARE;
|
|
63
|
+
if (!sent.has('finished') && s.maxScroll >= FINISHED_SCROLL && s.activeSeconds >= need)
|
|
64
|
+
out.push({ kind: 'finished', ...base });
|
|
65
|
+
return out;
|
|
66
|
+
}
|
|
67
|
+
// Never opened. One nudge, once, inside the window, and never after any open has been told.
|
|
68
|
+
if (input.addedAt && !sent.has('quiet') && !sent.has('first-open')) {
|
|
69
|
+
const days = (now - new Date(input.addedAt).getTime()) / DAY_MS;
|
|
70
|
+
const after = input.quietAfterDays ?? QUIET_AFTER_DAYS;
|
|
71
|
+
if (days >= after && days < QUIET_WINDOW_DAYS)
|
|
72
|
+
out.push({ kind: 'quiet', ...base, daysSinceAdded: Math.floor(days) });
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
@@ -61,6 +61,12 @@ export interface ReadersStore {
|
|
|
61
61
|
}): Promise<void>;
|
|
62
62
|
sessions(artifactId: string): Promise<SessionDoc[]>;
|
|
63
63
|
flags(artifactId: string): Promise<FlagDoc[]>;
|
|
64
|
+
/** Every page that has a list, with its list: what a daily sweep walks. Optional, so a store
|
|
65
|
+
* written before 0.20.0 still satisfies the interface; without it the sweep finds nothing. */
|
|
66
|
+
listed?(): Promise<{
|
|
67
|
+
artifactId: string;
|
|
68
|
+
readers: AllowEntry[];
|
|
69
|
+
}[]>;
|
|
64
70
|
}
|
|
65
71
|
export declare function createReadersStore(db: Firestore, base: string): ReadersStore;
|
|
66
72
|
export type ReaderSummary = {
|
|
@@ -72,6 +78,8 @@ export type ReaderSummary = {
|
|
|
72
78
|
maxScroll: number;
|
|
73
79
|
firstSeen: string;
|
|
74
80
|
lastSeen: string;
|
|
81
|
+
/** When their most recent visit began. Later than `firstSeen`'s day means they came back. */
|
|
82
|
+
lastStarted: string;
|
|
75
83
|
flags: {
|
|
76
84
|
kind: FlagKind;
|
|
77
85
|
at: string;
|
|
@@ -104,6 +104,10 @@ export function createReadersStore(db, base) {
|
|
|
104
104
|
const snap = await flags().where('artifactId', '==', artifactId).get();
|
|
105
105
|
return snap.docs.map((d) => d.data());
|
|
106
106
|
},
|
|
107
|
+
async listed() {
|
|
108
|
+
const snap = await access().get();
|
|
109
|
+
return snap.docs.map((d) => ({ artifactId: d.id, readers: listOf(d.data()) })).filter((p) => p.readers.length > 0);
|
|
110
|
+
},
|
|
107
111
|
};
|
|
108
112
|
}
|
|
109
113
|
/** Per-reader totals for one page, most recently seen first, plus everyone refused at the door. */
|
|
@@ -112,7 +116,7 @@ export function summarize(sessionDocs, flagDocs) {
|
|
|
112
116
|
for (const s of sessionDocs) {
|
|
113
117
|
const r = by.get(s.email) ?? {
|
|
114
118
|
email: s.email, name: s.name, member: s.member, sessions: 0, activeSeconds: 0, maxScroll: 0,
|
|
115
|
-
firstSeen: s.startedAt, lastSeen: s.lastAt, flags: [],
|
|
119
|
+
firstSeen: s.startedAt, lastSeen: s.lastAt, lastStarted: s.startedAt, flags: [],
|
|
116
120
|
};
|
|
117
121
|
r.sessions += 1;
|
|
118
122
|
r.activeSeconds += s.activeSeconds ?? 0;
|
|
@@ -121,6 +125,8 @@ export function summarize(sessionDocs, flagDocs) {
|
|
|
121
125
|
r.firstSeen = s.startedAt;
|
|
122
126
|
if (s.lastAt > r.lastSeen)
|
|
123
127
|
r.lastSeen = s.lastAt;
|
|
128
|
+
if (s.startedAt > r.lastStarted)
|
|
129
|
+
r.lastStarted = s.startedAt;
|
|
124
130
|
by.set(s.email, r);
|
|
125
131
|
}
|
|
126
132
|
const refused = new Map();
|
|
@@ -6,6 +6,7 @@ import { type ArtifactAssets } from '../artifacts/assets.js';
|
|
|
6
6
|
import type { BrandPack } from '../brand/pack.js';
|
|
7
7
|
import { type Access } from '../artifacts/reader.js';
|
|
8
8
|
import { type ReadersStore } from '../artifacts/readers-store.js';
|
|
9
|
+
import { type ReaderEvent } from '../artifacts/read-alerts.js';
|
|
9
10
|
import type { PersonalStore } from '../artifacts/personal-store.js';
|
|
10
11
|
import type { Transcriber } from '../artifacts/transcribe.js';
|
|
11
12
|
export type ArtifactRoutesConfig = {
|
|
@@ -63,11 +64,26 @@ export type ArtifactRoutesConfig = {
|
|
|
63
64
|
* its writer's name, whatever `comments_visible:` says. Without it nobody is the owner, and
|
|
64
65
|
* the publisher reads comments through /responses. */
|
|
65
66
|
ownerEmail?: string;
|
|
67
|
+
/** Sign-in emails, besides `ownerEmail`, of the people who administer this host's pages. They
|
|
68
|
+
* see a page's version history; with `history: 'admins'` (the default) nobody else does. */
|
|
69
|
+
admins?: readonly string[];
|
|
70
|
+
/** Who sees a page's past versions: the History control under the summary and every
|
|
71
|
+
* `/<id>/v/<n>`. `'admins'` (default): only `ownerEmail` and `admins`, signed in; to everyone
|
|
72
|
+
* else the History control is absent and a past version is not found, because an earlier draft
|
|
73
|
+
* can hold what the current one was edited to remove. `'everyone'`: every reader who can open
|
|
74
|
+
* the page, which was the only behaviour before 0.21.0. */
|
|
75
|
+
history?: 'admins' | 'everyone';
|
|
66
76
|
/** Turns a reader's voice memo into text on the server, so the service's key never reaches a
|
|
67
77
|
* browser: `deepgramTranscriber(key)` or `openaiTranscriber(key)` from `./artifacts`, or your
|
|
68
78
|
* own. Without it the transcribe route answers 404 and readers get the browser's live
|
|
69
79
|
* transcript, or an empty box to type into. */
|
|
70
80
|
transcribe?: Transcriber;
|
|
81
|
+
/** Told when a reader's state on a gated page changes: after a reading heartbeat is recorded,
|
|
82
|
+
* after a signed-in person is refused at the door, and for every listed reader when the host
|
|
83
|
+
* runs `sweepReaders()`. The host decides what, if anything, to do (`readAlertsDue` turns an
|
|
84
|
+
* event into the alerts worth sending). Awaited, and anything it throws is swallowed: a broken
|
|
85
|
+
* sink never costs a reader their page or their record. Without it nothing extra is read. */
|
|
86
|
+
onReaderEvent?: (event: ReaderEvent) => void | Promise<void>;
|
|
71
87
|
};
|
|
72
88
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
73
89
|
export declare const ARTIFACT_ID: RegExp;
|
|
@@ -193,7 +209,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
193
209
|
name: string | null;
|
|
194
210
|
at: string;
|
|
195
211
|
}[];
|
|
196
|
-
readers: import("../
|
|
212
|
+
readers: import("../index.js").ReaderSummary[];
|
|
197
213
|
refused: {
|
|
198
214
|
email: string;
|
|
199
215
|
name: string | null;
|
|
@@ -205,6 +221,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
205
221
|
access: Access | null;
|
|
206
222
|
views: number;
|
|
207
223
|
}>>;
|
|
224
|
+
sweepReaders: () => Promise<{
|
|
225
|
+
pages: number;
|
|
226
|
+
readers: number;
|
|
227
|
+
}>;
|
|
208
228
|
TRANSCRIBE: (req: NextRequest, { params }: {
|
|
209
229
|
params: Promise<{
|
|
210
230
|
id: string;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -23,6 +23,7 @@ import { UpdatedTime } from '../reader/updated-time.js';
|
|
|
23
23
|
import { VersionHistory } from '../reader/version-history.js';
|
|
24
24
|
import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
|
|
25
25
|
import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
26
|
+
import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
|
|
26
27
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
27
28
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
28
29
|
import { createStateRoutes } from './state-routes.js';
|
|
@@ -59,6 +60,9 @@ export function createArtifactRoutes(config) {
|
|
|
59
60
|
const signInOrigin = config.signInOrigin;
|
|
60
61
|
const owner = config.owner ?? brand.name;
|
|
61
62
|
const ownerEmail = config.ownerEmail?.trim().toLowerCase();
|
|
63
|
+
const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
|
|
64
|
+
/** Whether this reader may see a page's past versions (config `history`). */
|
|
65
|
+
const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
|
|
62
66
|
const readerSecret = () => config.readerSecret?.();
|
|
63
67
|
const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
|
|
64
68
|
// Signing out alone cannot change the account: the authority still holds the Google session
|
|
@@ -80,6 +84,58 @@ export function createArtifactRoutes(config) {
|
|
|
80
84
|
store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
|
|
81
85
|
readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
|
|
82
86
|
});
|
|
87
|
+
// ---- Reader events (onReaderEvent). Nothing here runs, and nothing extra is read, without it.
|
|
88
|
+
const wordsCache = new Map();
|
|
89
|
+
/** Words a reader reads on the page, cached per version so a heartbeat does not re-parse it. */
|
|
90
|
+
function wordsOf(a) {
|
|
91
|
+
const c = wordsCache.get(a.id);
|
|
92
|
+
if (c && c.updatedAt === a.updatedAt)
|
|
93
|
+
return c.words;
|
|
94
|
+
const words = wordCount(narrationText(a));
|
|
95
|
+
wordsCache.set(a.id, { updatedAt: a.updatedAt, words });
|
|
96
|
+
return words;
|
|
97
|
+
}
|
|
98
|
+
const same = (x, y) => x.trim().toLowerCase() === y.trim().toLowerCase();
|
|
99
|
+
async function emitReaderEvent(e, a) {
|
|
100
|
+
if (!config.onReaderEvent)
|
|
101
|
+
return;
|
|
102
|
+
try {
|
|
103
|
+
const words = wordsOf(a);
|
|
104
|
+
await config.onReaderEvent({ ...e, artifactId: a.id, title: a.title, url: pageUrl(a.id), words, readSeconds: readSecondsFor(words), at: new Date().toISOString() });
|
|
105
|
+
}
|
|
106
|
+
catch {
|
|
107
|
+
// A broken sink never costs a reader their page or their record.
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
/** One reader's record on one page, read fresh, for an event. */
|
|
111
|
+
async function readerSummary(id, email) {
|
|
112
|
+
const [sessions, flags] = await Promise.all([config.readers.sessions(id), config.readers.flags(id)]);
|
|
113
|
+
return summarize(sessions, flags).readers.find((r) => same(r.email, email)) ?? null;
|
|
114
|
+
}
|
|
115
|
+
/** Tell `onReaderEvent` about every listed reader of every gated page, opened or not, so a host
|
|
116
|
+
* can notice what has NOT happened (a page nobody opened). Run it from a daily job. Needs a
|
|
117
|
+
* readers store with `listed()`; without one, or without the hook, it does nothing. */
|
|
118
|
+
async function sweepReaders() {
|
|
119
|
+
const readers = config.readers;
|
|
120
|
+
if (!config.onReaderEvent || !readers?.listed)
|
|
121
|
+
return { pages: 0, readers: 0 };
|
|
122
|
+
let pages = 0;
|
|
123
|
+
let count = 0;
|
|
124
|
+
for (const p of await readers.listed()) {
|
|
125
|
+
const a = ARTIFACT_ID.test(p.artifactId) ? await store.get(p.artifactId) : null;
|
|
126
|
+
if (!a?.access)
|
|
127
|
+
continue;
|
|
128
|
+
pages += 1;
|
|
129
|
+
const [sessions, flags] = await Promise.all([readers.sessions(a.id), readers.flags(a.id)]);
|
|
130
|
+
const sums = summarize(sessions, flags).readers;
|
|
131
|
+
for (const entry of p.readers) {
|
|
132
|
+
const summary = sums.find((r) => same(r.email, entry.email)) ?? null;
|
|
133
|
+
await emitReaderEvent({ kind: 'sweep', reader: { email: entry.email, name: entry.name ?? summary?.name ?? null }, summary, entry }, a);
|
|
134
|
+
count += 1;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return { pages, readers: count };
|
|
138
|
+
}
|
|
83
139
|
/** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
|
|
84
140
|
* whole paragraph is data-nospeak, and narrationText has no `to` to read. */
|
|
85
141
|
function Kicker({ to, brand }) {
|
|
@@ -142,7 +198,7 @@ export function createArtifactRoutes(config) {
|
|
|
142
198
|
if (!a)
|
|
143
199
|
notFound();
|
|
144
200
|
const { pack, brand } = lookOf(a);
|
|
145
|
-
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top }));
|
|
201
|
+
const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
|
|
146
202
|
if (a.access)
|
|
147
203
|
return GatedPage(a, id, render, n);
|
|
148
204
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
@@ -190,8 +246,14 @@ export function createArtifactRoutes(config) {
|
|
|
190
246
|
const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
|
|
191
247
|
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, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
|
|
192
248
|
if (!d.open) {
|
|
193
|
-
if (d.why === 'not-allowed' && config.readers)
|
|
194
|
-
|
|
249
|
+
if (d.why === 'not-allowed' && config.readers) {
|
|
250
|
+
const flagged = config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
251
|
+
// Told after the refusal is on the record, so a sink that reads it back finds it.
|
|
252
|
+
if (config.onReaderEvent)
|
|
253
|
+
await flagged.then(() => emitReaderEvent({ kind: 'refused', reader: { email: d.reader.email, name: d.reader.name }, summary: null, entry: null }, a));
|
|
254
|
+
else
|
|
255
|
+
void flagged;
|
|
256
|
+
}
|
|
195
257
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
196
258
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
|
|
197
259
|
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
|
|
@@ -214,8 +276,8 @@ export function createArtifactRoutes(config) {
|
|
|
214
276
|
return store.history(id).catch(() => null);
|
|
215
277
|
}
|
|
216
278
|
/** "Version N · Updated <minute> · History": the line under the summary. */
|
|
217
|
-
function VersionLine({ a, n, at, history }) {
|
|
218
|
-
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
|
|
279
|
+
function VersionLine({ a, n, at, history, reader }) {
|
|
280
|
+
return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 && seesHistory(reader) ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
|
|
219
281
|
}
|
|
220
282
|
/** The title block and the body, in the pack's Article when it has one. Without one this is
|
|
221
283
|
* exactly the markup every page had before packs could frame it. */
|
|
@@ -225,8 +287,11 @@ export function createArtifactRoutes(config) {
|
|
|
225
287
|
return (_jsxs(_Fragment, { children: [cover, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc, children] })] }));
|
|
226
288
|
}
|
|
227
289
|
/** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
|
|
228
|
-
async function VersionBody(a, n, { top }) {
|
|
290
|
+
async function VersionBody(a, n, { top, reader }) {
|
|
229
291
|
const { pack, brand } = lookOf(a);
|
|
292
|
+
// Past versions are for admins unless the host says otherwise: to anyone else none exists.
|
|
293
|
+
if (!seesHistory(reader))
|
|
294
|
+
notFound();
|
|
230
295
|
const v = store.version ? await store.version(a.id, n) : null;
|
|
231
296
|
if (!v)
|
|
232
297
|
notFound();
|
|
@@ -237,7 +302,7 @@ export function createArtifactRoutes(config) {
|
|
|
237
302
|
const title = v.title ?? a.title;
|
|
238
303
|
const summary = v.summary ?? a.summary;
|
|
239
304
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
240
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
|
|
305
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, Section: pack.Section, figure: pack.figure }) })] }));
|
|
241
306
|
}
|
|
242
307
|
/** The page itself, shared by open and gated pages. */
|
|
243
308
|
async function Body(a, { top, reader }) {
|
|
@@ -258,7 +323,7 @@ export function createArtifactRoutes(config) {
|
|
|
258
323
|
const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
|
|
259
324
|
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
|
|
260
325
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
261
|
-
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
326
|
+
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id), reader: reader }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
262
327
|
const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
|
|
263
328
|
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined, Section: pack.Section, figure: pack.figure }) }));
|
|
264
329
|
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
@@ -337,7 +402,8 @@ export function createArtifactRoutes(config) {
|
|
|
337
402
|
const a = await store.get(id);
|
|
338
403
|
if (!a?.access)
|
|
339
404
|
return new NextResponse(null, { status: 404 });
|
|
340
|
-
|
|
405
|
+
const allow = await config.readers.allowList(id);
|
|
406
|
+
if (!decide(a.access, reader, allow).open)
|
|
341
407
|
return new NextResponse(null, { status: 403 });
|
|
342
408
|
const country = request.headers.get('x-vercel-ip-country') ?? undefined;
|
|
343
409
|
if (b.kind === 'beat') {
|
|
@@ -345,6 +411,11 @@ export function createArtifactRoutes(config) {
|
|
|
345
411
|
const scroll = Math.max(0, Math.min(100, Math.round(Number(b.scroll) || 0)));
|
|
346
412
|
const device = /Mobi|Android|iPhone|iPad/i.test(request.headers.get('user-agent') ?? '') ? 'mobile' : 'desktop';
|
|
347
413
|
await config.readers.touchSession({ artifactId: id, session, reader, addSeconds: add, scroll, device, country });
|
|
414
|
+
if (config.onReaderEvent) {
|
|
415
|
+
const summary = await readerSummary(id, reader.email).catch(() => null);
|
|
416
|
+
const entry = allow.find((e) => same(e.email, reader.email)) ?? null;
|
|
417
|
+
await emitReaderEvent({ kind: 'visit', reader: { email: reader.email, name: reader.name }, summary, entry }, a);
|
|
418
|
+
}
|
|
348
419
|
return new NextResponse(null, { status: 204 });
|
|
349
420
|
}
|
|
350
421
|
if (b.kind === 'flag' && FLAG_KINDS.includes(b.flag) && b.flag !== 'refused') {
|
|
@@ -572,5 +643,5 @@ export function createArtifactRoutes(config) {
|
|
|
572
643
|
revalidatePath(pagePath(id));
|
|
573
644
|
return NextResponse.json({ deleted: true });
|
|
574
645
|
}
|
|
575
|
-
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
646
|
+
return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
576
647
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.21.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",
|