@supersuit/artifacts 0.16.0 → 0.18.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 +44 -0
- package/README.md +26 -1
- package/lib/artifacts/auth.d.ts +12 -0
- package/lib/artifacts/auth.js +16 -2
- package/lib/artifacts/index.d.ts +2 -1
- package/lib/artifacts/index.js +2 -1
- package/lib/artifacts/publisher.d.ts +15 -0
- package/lib/artifacts/publisher.js +48 -0
- package/lib/artifacts/reader.d.ts +7 -0
- package/lib/artifacts/reader.js +22 -6
- package/lib/artifacts/store.js +7 -2
- package/lib/reader/index.d.ts +0 -2
- package/lib/reader/index.js +1 -3
- package/lib/routes/artifacts.d.ts +7 -0
- package/lib/routes/artifacts.js +16 -14
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +3 -3
- package/package.json +1 -1
- package/lib/reader/browser-reader.d.ts +0 -8
- package/lib/reader/browser-reader.js +0 -171
- package/lib/reader/speech-plan.d.ts +0 -20
- package/lib/reader/speech-plan.js +0 -67
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,50 @@
|
|
|
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.18.0 (2026-09-29)
|
|
7
|
+
|
|
8
|
+
**No page is ever read by the browser's own voice, and a page can turn its audio off.** 0.10.0
|
|
9
|
+
made a page with no recorded narration read itself with `speechSynthesis`, which on a phone is
|
|
10
|
+
Safari's built-in voice. The operator heard it on a letter going to a partner: *"NEVER PUT
|
|
11
|
+
DEFAULT SAFARI VOICE DICTATION. ALWAYS KOKORO OR IF I WANT TO SPEND MONEY 11LABS."* A page is
|
|
12
|
+
now read by the audio recorded at publish, or it has no player at all.
|
|
13
|
+
|
|
14
|
+
And *"add ability to disable audio"*: `voice: none` on a republish removes the page's stored
|
|
15
|
+
narration, timings and hash, so the player goes too. Before this, an update merged fields, so a
|
|
16
|
+
recording survived every republish that did not mention it and there was no way to take it off.
|
|
17
|
+
|
|
18
|
+
- Removed: `BrowserReader`, `planSentences`, `wordAt`, `sentenceOf`, `fromWord`,
|
|
19
|
+
`MAX_SENTENCE_WORDS` from `./reader`. Nothing in a host should have mounted them directly.
|
|
20
|
+
- DETECTOR: an un-narrated page that shows a player bar, or any `speechSynthesis` in `lib/`.
|
|
21
|
+
- REMEDY: bump. To silence a page, republish it with `voice: none` (a publisher that sends it;
|
|
22
|
+
Freedom's publish-artifact does from the release after this).
|
|
23
|
+
- Tests: route GUARDs that an un-narrated page, and one whose timings fail, mount no reader; store
|
|
24
|
+
tests that audio survives a silent republish and `voice: none` clears it; `check-packed` fails if
|
|
25
|
+
the tarball carries `speechSynthesis` or the fixture page mounts a player. The default-pages
|
|
26
|
+
golden was regenerated on purpose: the only change is the removed browser-reader element.
|
|
27
|
+
|
|
28
|
+
## 0.17.0 (2026-09-29)
|
|
29
|
+
|
|
30
|
+
**A host can accept a signed, hour-long publisher pass wherever it accepts the publish key.** A
|
|
31
|
+
shared key cannot be self-served and cannot be revoked for one person without rotating it for
|
|
32
|
+
everyone. New routes config `publisherSecret` and `publisherHost`: when both are set, every
|
|
33
|
+
publish-key route (publish, read, delete, versions, access, reads, assets, upload, responses, the
|
|
34
|
+
comments feed) also accepts `Authorization: Bearer p1.<payload>.<sig>`, a pass naming one person
|
|
35
|
+
and this host, signed with the reader pass's HMAC scheme under its own domain
|
|
36
|
+
(`artifact-publisher`). New exports `mintPublisherPass`, `verifyPublisherPass`,
|
|
37
|
+
`PUBLISHER_PASS_TTL_SECONDS` (3600), `isPublisherAuthed`. `isPublishAuthed` is unchanged.
|
|
38
|
+
|
|
39
|
+
- DETECTOR: none needed. A host that sets neither field refuses every pass and takes its key
|
|
40
|
+
exactly as on 0.16.0.
|
|
41
|
+
- REMEDY: nothing, unless you want admins to publish without the key: set both fields, and have
|
|
42
|
+
your sign-in side mint passes for the people it vouches for.
|
|
43
|
+
- Tests: the pass vector pinned byte for byte; a valid pass for the right host gets exactly what
|
|
44
|
+
the key gets on every publish-key handler; wrong host, expired, bad signature, a reader pass
|
|
45
|
+
(`a1`) and a grant (`g1`) are refused on every handler; a `p1` pass never verifies as a reader
|
|
46
|
+
pass or grant; a host missing either field refuses a pass and still takes its key. Broken on
|
|
47
|
+
purpose twice and failed: the host check removed (11 failures), and the comments feed left on
|
|
48
|
+
the bare key check (its pass case failed).
|
|
49
|
+
|
|
6
50
|
## 0.16.0 (2026-09-29)
|
|
7
51
|
|
|
8
52
|
**A brand pack can draw a page's structure, and one page can wear a pack of its own.** Four new
|
package/README.md
CHANGED
|
@@ -28,6 +28,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
28
28
|
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
29
29
|
| `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
|
|
30
30
|
| `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
|
|
31
|
+
| `mintPublisherPass`, `verifyPublisherPass` | The publisher pass: an hour-long stand-in for the publish key, for one person on one host (see below) |
|
|
31
32
|
| `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
|
|
32
33
|
|
|
33
34
|
Subpath imports: `@supersuit/artifacts/artifacts`, `/routes`, `/brand`, `/reader`, `/gate`.
|
|
@@ -153,7 +154,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
153
154
|
| `audience` | Free text stored with the page, never shown |
|
|
154
155
|
| `id` | Republish in place instead of minting a new page |
|
|
155
156
|
| `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
|
|
156
|
-
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent
|
|
157
|
+
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated (Kokoro by default, ElevenLabs on request). Absent, or the timings will not load, the page has no player: it is never read by the browser's own voice. `voice: none` turns audio off and removes any recording the page already had |
|
|
157
158
|
| `password` | Shuts the body behind a door (below) |
|
|
158
159
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
159
160
|
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
|
@@ -232,6 +233,29 @@ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
|
|
|
232
233
|
Without `signInOrigin`, a confidential page shows its door with no way through. It fails
|
|
233
234
|
closed, never open.
|
|
234
235
|
|
|
236
|
+
### The publisher pass (publishing without the shared key)
|
|
237
|
+
|
|
238
|
+
A shared publish key cannot be handed to one person and taken back from one person. A host can
|
|
239
|
+
also accept a **publisher pass** wherever it accepts the key: a signed, hour-long statement that
|
|
240
|
+
a named person may publish on that host, minted by an authority that knows who may.
|
|
241
|
+
|
|
242
|
+
```ts
|
|
243
|
+
createArtifactRoutes({
|
|
244
|
+
// ...
|
|
245
|
+
publisherSecret: () => process.env.ARTIFACT_PASS_SECRET, // shared with the minting side
|
|
246
|
+
publisherHost: 'artifacts.example.com', // the host a pass must name
|
|
247
|
+
})
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
Both fields are optional; without either, passes are refused and only the key works. The pass is
|
|
251
|
+
`p1.<payload>.<sig>`: `payload` is the base64url of
|
|
252
|
+
`{"u": uid, "e": email, "n": name|null, "h": host, "x": expiry-unix-seconds}`, and `sig` is the
|
|
253
|
+
first 32 hex characters of `HMAC-SHA256(secret, "artifact-publisher:p1.<payload>")`. It is sent as
|
|
254
|
+
`Authorization: Bearer <pass>`. A pass for another host is refused even under the same secret, and
|
|
255
|
+
a reader pass or grant never verifies as a publisher pass (nor the reverse), because the domain
|
|
256
|
+
and version differ. `mintPublisherPass(secret, { uid, email, name, host }, exp)` builds one;
|
|
257
|
+
`PUBLISHER_PASS_TTL_SECONDS` is 3600.
|
|
258
|
+
|
|
235
259
|
## Reader answers (`state:`)
|
|
236
260
|
|
|
237
261
|
A page can take answers from the people reading it: a vote, a reaction, a short response.
|
|
@@ -576,6 +600,7 @@ the page's pack.
|
|
|
576
600
|
|---|---|
|
|
577
601
|
| your publish key (named by you, read in `publishKey`) | Authorises publish, delete and access changes |
|
|
578
602
|
| `ARTIFACT_PASS_SECRET` (named by you, read in `readerSecret`) | Verifies reader passes and signs grants; shared with your sign-in side |
|
|
603
|
+
| a publisher-pass secret (named by you, read in `publisherSecret`; may be the same one) | Verifies publisher passes; shared with whatever mints them |
|
|
579
604
|
| Firebase Admin credentials | The store and readers store |
|
|
580
605
|
|
|
581
606
|
## Contributing
|
package/lib/artifacts/auth.d.ts
CHANGED
|
@@ -1 +1,13 @@
|
|
|
1
|
+
/** The Bearer is exactly this instance's publish key. */
|
|
1
2
|
export declare function isPublishAuthed(request: Request, key: string | undefined): boolean;
|
|
3
|
+
export type PublishAuth = {
|
|
4
|
+
/** The tenant's shared publish key. Read at request time. */
|
|
5
|
+
publishKey: () => string | undefined;
|
|
6
|
+
/** Verifies publisher passes. Absent, or returning nothing, means passes are refused. */
|
|
7
|
+
publisherSecret?: () => string | undefined;
|
|
8
|
+
/** The hostname a pass must name (`h`), e.g. artifacts.example.com. Absent means passes are refused. */
|
|
9
|
+
publisherHost?: string;
|
|
10
|
+
};
|
|
11
|
+
/** Every publish-key route's check: the tenant key, OR a valid publisher pass for this host
|
|
12
|
+
* when the host opted in. A host that configures neither pass field behaves exactly as before. */
|
|
13
|
+
export declare function isPublisherAuthed(request: Request, auth: PublishAuth, now?: number): boolean;
|
package/lib/artifacts/auth.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Who may publish. The site gate lets the artifacts API through precisely because the
|
|
2
|
+
// Authorization header is the whole authorization.
|
|
3
|
+
import { verifyPublisherPass } from './publisher.js';
|
|
4
|
+
/** The Bearer is exactly this instance's publish key. */
|
|
3
5
|
export function isPublishAuthed(request, key) {
|
|
4
6
|
if (!key)
|
|
5
7
|
return false;
|
|
6
8
|
const h = request.headers.get('authorization') ?? '';
|
|
7
9
|
return h === `Bearer ${key}`;
|
|
8
10
|
}
|
|
11
|
+
/** Every publish-key route's check: the tenant key, OR a valid publisher pass for this host
|
|
12
|
+
* when the host opted in. A host that configures neither pass field behaves exactly as before. */
|
|
13
|
+
export function isPublisherAuthed(request, auth, now = Date.now()) {
|
|
14
|
+
if (isPublishAuthed(request, auth.publishKey()))
|
|
15
|
+
return true;
|
|
16
|
+
if (!auth.publisherHost || !auth.publisherSecret)
|
|
17
|
+
return false;
|
|
18
|
+
const h = request.headers.get('authorization') ?? '';
|
|
19
|
+
if (!h.startsWith('Bearer '))
|
|
20
|
+
return false;
|
|
21
|
+
return verifyPublisherPass(auth.publisherSecret(), h.slice(7).trim(), auth.publisherHost, now) !== null;
|
|
22
|
+
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -2,7 +2,8 @@ export { parseArtifactSource, type ArtifactMeta } from './front-matter.js';
|
|
|
2
2
|
export { ArtifactMarkdown } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
|
|
5
|
-
export { isPublishAuthed } from './auth.js';
|
|
5
|
+
export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
|
|
6
|
+
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, type Publisher } from './publisher.js';
|
|
6
7
|
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
|
|
7
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -2,7 +2,8 @@ export { parseArtifactSource } from './front-matter.js';
|
|
|
2
2
|
export { ArtifactMarkdown } from './render.js';
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
|
|
5
|
-
export { isPublishAuthed } from './auth.js';
|
|
5
|
+
export { isPublishAuthed, isPublisherAuthed } from './auth.js';
|
|
6
|
+
export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS } from './publisher.js';
|
|
6
7
|
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
|
|
7
8
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
9
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare const PUBLISHER_DOMAIN = "artifact-publisher";
|
|
2
|
+
export declare const PUBLISHER_VERSION = "p1";
|
|
3
|
+
export declare const PUBLISHER_PASS_TTL_SECONDS = 3600;
|
|
4
|
+
/** Who a publisher pass names, and the one host it is good for. */
|
|
5
|
+
export type Publisher = {
|
|
6
|
+
uid: string;
|
|
7
|
+
email: string;
|
|
8
|
+
name: string | null;
|
|
9
|
+
host: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function mintPublisherPass(secret: string, p: Publisher, exp: number): string;
|
|
12
|
+
/** The publisher a pass names, if it is correctly signed, unexpired, and for `host` (a hostname
|
|
13
|
+
* like `artifacts.example.com`, no scheme). Anything else is null: a pass for another host is
|
|
14
|
+
* refused even under the same secret, because two hosts can share one. */
|
|
15
|
+
export declare function verifyPublisherPass(secret: string | undefined, pass: string | null | undefined, host: string | undefined, now?: number): Publisher | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// The publisher pass: a short-lived, signed statement that a named person may do what the
|
|
2
|
+
// publish key does, on ONE host.
|
|
3
|
+
//
|
|
4
|
+
// A shared publish key cannot be self-served and cannot be revoked for one person without
|
|
5
|
+
// rotating it for everyone. A host that opts in (routes config `publisherSecret` +
|
|
6
|
+
// `publisherHost`) also accepts this pass as the Bearer: a sign-in authority that knows who the
|
|
7
|
+
// host's admins are mints it for one of them, and it expires within an hour, so revoking the
|
|
8
|
+
// admin stops new passes and an outstanding one dies on its own.
|
|
9
|
+
//
|
|
10
|
+
// THE SHAPE IS A CONTRACT WITH THE AUTHORITY THAT MINTS IT, byte for byte: `p1.<payload>.<sig>`,
|
|
11
|
+
// payload the base64url of `{ u, e, n, h, x }`, sig the first 32 hex of
|
|
12
|
+
// HMAC-SHA256(secret, `artifact-publisher:p1.<payload>`). Same scheme as the reader pass, with its
|
|
13
|
+
// own domain and version, so a reader pass (a1) or grant (g1) never verifies here and a
|
|
14
|
+
// publisher pass never verifies as either (publisher.test.ts pins both directions and the vector).
|
|
15
|
+
import { openToken, signToken } from './reader.js';
|
|
16
|
+
export const PUBLISHER_DOMAIN = 'artifact-publisher';
|
|
17
|
+
export const PUBLISHER_VERSION = 'p1';
|
|
18
|
+
export const PUBLISHER_PASS_TTL_SECONDS = 3600;
|
|
19
|
+
const normHost = (h) => h.trim().toLowerCase();
|
|
20
|
+
export function mintPublisherPass(secret, p, exp) {
|
|
21
|
+
return signToken(secret, PUBLISHER_DOMAIN, PUBLISHER_VERSION, {
|
|
22
|
+
u: p.uid,
|
|
23
|
+
e: p.email.trim().toLowerCase(),
|
|
24
|
+
n: p.name,
|
|
25
|
+
h: normHost(p.host),
|
|
26
|
+
x: exp,
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
/** The publisher a pass names, if it is correctly signed, unexpired, and for `host` (a hostname
|
|
30
|
+
* like `artifacts.example.com`, no scheme). Anything else is null: a pass for another host is
|
|
31
|
+
* refused even under the same secret, because two hosts can share one. */
|
|
32
|
+
export function verifyPublisherPass(secret, pass, host, now = Date.now()) {
|
|
33
|
+
if (!host)
|
|
34
|
+
return null;
|
|
35
|
+
const d = openToken(secret, PUBLISHER_DOMAIN, PUBLISHER_VERSION, pass, now);
|
|
36
|
+
if (!d)
|
|
37
|
+
return null;
|
|
38
|
+
if (typeof d.u !== 'string' || !d.u || typeof d.e !== 'string' || !d.e.includes('@') || typeof d.h !== 'string')
|
|
39
|
+
return null;
|
|
40
|
+
if (normHost(d.h) !== normHost(host))
|
|
41
|
+
return null;
|
|
42
|
+
return {
|
|
43
|
+
uid: d.u,
|
|
44
|
+
email: d.e.trim().toLowerCase(),
|
|
45
|
+
name: typeof d.n === 'string' && d.n.trim() ? d.n.trim() : null,
|
|
46
|
+
host: normHost(d.h),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -12,6 +12,13 @@ export declare const PASS_TTL_SECONDS = 300;
|
|
|
12
12
|
* changes need no wait: the list is read on every request. */
|
|
13
13
|
export declare const GRANT_TTL_SECONDS: number;
|
|
14
14
|
export declare const GRANT_COOKIE = "artifact_reader";
|
|
15
|
+
/** Sign a small JSON payload as `<version>.<b64url(json)>.<sig>` under one domain. The domain is
|
|
16
|
+
* in the MAC and the version is the token's first segment, so a token minted for one purpose
|
|
17
|
+
* (reader pass, grant, publisher pass) never verifies as another. */
|
|
18
|
+
export declare function signToken(secret: string, domain: string, version: string, data: Record<string, unknown>): string;
|
|
19
|
+
/** The payload of a correctly signed, unexpired token of this domain and version, else null.
|
|
20
|
+
* `x` (unix seconds) is required and checked; every other field is the caller's to validate. */
|
|
21
|
+
export declare function openToken(secret: string | undefined, domain: string, version: string, token: string | null | undefined, now: number): Record<string, unknown> | null;
|
|
15
22
|
export declare function mintPass(secret: string, reader: Reader, exp: number): string;
|
|
16
23
|
export declare function verifyPass(secret: string | undefined, pass: string | null | undefined, now?: number): Reader | null;
|
|
17
24
|
export declare function mintGrant(secret: string, reader: Reader, now?: number): string;
|
package/lib/artifacts/reader.js
CHANGED
|
@@ -29,13 +29,16 @@ function safeEqual(a, b) {
|
|
|
29
29
|
return false;
|
|
30
30
|
return timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
|
31
31
|
}
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
32
|
+
/** Sign a small JSON payload as `<version>.<b64url(json)>.<sig>` under one domain. The domain is
|
|
33
|
+
* in the MAC and the version is the token's first segment, so a token minted for one purpose
|
|
34
|
+
* (reader pass, grant, publisher pass) never verifies as another. */
|
|
35
|
+
export function signToken(secret, domain, version, data) {
|
|
36
|
+
const body = `${version}.${b64url(JSON.stringify(data))}`;
|
|
36
37
|
return `${body}.${sig(secret, domain, body)}`;
|
|
37
38
|
}
|
|
38
|
-
|
|
39
|
+
/** The payload of a correctly signed, unexpired token of this domain and version, else null.
|
|
40
|
+
* `x` (unix seconds) is required and checked; every other field is the caller's to validate. */
|
|
41
|
+
export function openToken(secret, domain, version, token, now) {
|
|
39
42
|
if (!secret || typeof token !== 'string')
|
|
40
43
|
return null;
|
|
41
44
|
const parts = token.split('.');
|
|
@@ -51,7 +54,20 @@ function decode(secret, domain, version, token, now) {
|
|
|
51
54
|
catch {
|
|
52
55
|
return null;
|
|
53
56
|
}
|
|
54
|
-
if (typeof d
|
|
57
|
+
if (!d || typeof d !== 'object' || Array.isArray(d))
|
|
58
|
+
return null;
|
|
59
|
+
const o = d;
|
|
60
|
+
if (typeof o.x !== 'number' || o.x * 1000 < now)
|
|
61
|
+
return null;
|
|
62
|
+
return o;
|
|
63
|
+
}
|
|
64
|
+
function encode(secret, domain, version, reader, exp) {
|
|
65
|
+
// Short keys: this rides in a URL and a cookie.
|
|
66
|
+
return signToken(secret, domain, version, { u: reader.uid, e: reader.email, n: reader.name, m: reader.member, x: exp });
|
|
67
|
+
}
|
|
68
|
+
function decode(secret, domain, version, token, now) {
|
|
69
|
+
const d = openToken(secret, domain, version, token, now);
|
|
70
|
+
if (!d)
|
|
55
71
|
return null;
|
|
56
72
|
if (typeof d.u !== 'string' || !d.u || typeof d.e !== 'string' || !d.e.includes('@'))
|
|
57
73
|
return null;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -126,7 +126,7 @@ async function saveArtifact(col, input) {
|
|
|
126
126
|
...(input.meta.to ? { to: input.meta.to } : {}),
|
|
127
127
|
...(input.meta.audience ? { audience: input.meta.audience } : {}),
|
|
128
128
|
...(input.meta.cover ? { cover: input.meta.cover } : {}),
|
|
129
|
-
...(input.meta.voice ? { voice: input.meta.voice } : {}),
|
|
129
|
+
...(input.meta.voice && input.meta.voice !== 'none' ? { voice: input.meta.voice } : {}),
|
|
130
130
|
...(input.meta.narration ? { narration: input.meta.narration } : {}),
|
|
131
131
|
...(input.meta.timings ? { timings: input.meta.timings } : {}),
|
|
132
132
|
...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
|
|
@@ -190,8 +190,13 @@ async function saveArtifact(col, input) {
|
|
|
190
190
|
...(input.meta.comments ? {} : { comments: FieldValue.delete() }),
|
|
191
191
|
...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
|
|
192
192
|
};
|
|
193
|
+
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
|
194
|
+
// had would otherwise survive every republish; clear it on purpose, and the player goes with it.
|
|
195
|
+
const audio = input.meta.voice === 'none'
|
|
196
|
+
? { voice: FieldValue.delete(), narration: FieldValue.delete(), timings: FieldValue.delete(), narrationHash: FieldValue.delete() }
|
|
197
|
+
: {};
|
|
193
198
|
await ref.update({
|
|
194
|
-
...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, markdown: input.markdown, version: next,
|
|
199
|
+
...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, ...audio, markdown: input.markdown, version: next,
|
|
195
200
|
// `updatedAt` is when this version went live; finishing its own publish does not move it.
|
|
196
201
|
...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
|
|
197
202
|
note: nextNote ?? FieldValue.delete(),
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
|
|
2
|
-
export { BrowserReader } from './browser-reader.js';
|
|
3
2
|
export { PlayerBar } from './player-bar.js';
|
|
4
|
-
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS, type Sentence } from './speech-plan.js';
|
|
5
3
|
export { CommentLayer } from './comment-layer.js';
|
|
6
4
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
7
5
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
// The `./reader` entry:
|
|
1
|
+
// The `./reader` entry: the reader and the bar it uses. Each component module carries its
|
|
2
2
|
// own 'use client'; this barrel is plain re-exports so it can be imported from either side.
|
|
3
3
|
export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
|
|
4
|
-
export { BrowserReader } from './browser-reader.js';
|
|
5
4
|
export { PlayerBar } from './player-bar.js';
|
|
6
|
-
export { planSentences, wordAt, sentenceOf, fromWord, MAX_SENTENCE_WORDS } from './speech-plan.js';
|
|
7
5
|
export { CommentLayer } from './comment-layer.js';
|
|
8
6
|
export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
|
|
9
7
|
export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
|
|
@@ -28,6 +28,13 @@ export type ArtifactRoutesConfig = {
|
|
|
28
28
|
siteUrl: string;
|
|
29
29
|
/** Read at request time, so a rotated key needs no rebuild. */
|
|
30
30
|
publishKey: () => string | undefined;
|
|
31
|
+
/** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
|
|
32
|
+
* for the publish key, minted for one person by an authority that knows who may publish here.
|
|
33
|
+
* Read at request time. Without it, or without `publisherHost`, passes are refused. */
|
|
34
|
+
publisherSecret?: () => string | undefined;
|
|
35
|
+
/** The hostname a publisher pass must name, e.g. artifacts.example.com. Set it only on a host
|
|
36
|
+
* that opts in to passes. */
|
|
37
|
+
publisherHost?: string;
|
|
31
38
|
/** Share image when an artifact has no cover. Absolute or site-relative. */
|
|
32
39
|
defaultShareImage?: string;
|
|
33
40
|
/** Where a page lives under the origin. '/' on a dedicated host (artifacts.<name>/<id>);
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
2
2
|
import { revalidatePath } from 'next/cache';
|
|
3
3
|
import { notFound, redirect } from 'next/navigation';
|
|
4
4
|
import { NextResponse } from 'next/server';
|
|
5
|
-
import {
|
|
5
|
+
import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
6
6
|
import { parseArtifactSource } from '../artifacts/front-matter.js';
|
|
7
7
|
import { narrationText } from '../artifacts/narration.js';
|
|
8
8
|
import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
@@ -17,7 +17,6 @@ import { themedPack } from '../brand/theme.js';
|
|
|
17
17
|
import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
18
18
|
import { renderShareCard } from '../brand/share-card.js';
|
|
19
19
|
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
20
|
-
import { BrowserReader } from '../reader/browser-reader.js';
|
|
21
20
|
import { CommentLayer } from '../reader/comment-layer.js';
|
|
22
21
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
23
22
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
@@ -69,8 +68,11 @@ export function createArtifactRoutes(config) {
|
|
|
69
68
|
// Readers' recordings are played through short-lived signed URLs, issued only beside the comment
|
|
70
69
|
// or note they belong to; a host whose assets cannot sign them shows no play control.
|
|
71
70
|
const audioUrl = config.assets?.audioUrl ? (id, path) => config.assets.audioUrl(id, path) : undefined;
|
|
71
|
+
const publishAuth = { publishKey: config.publishKey, publisherSecret: config.publisherSecret, publisherHost: config.publisherHost };
|
|
72
|
+
const authed = (request) => isPublisherAuthed(request, publishAuth);
|
|
72
73
|
const stateRoutes = createStateRoutes({
|
|
73
|
-
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
|
|
74
|
+
store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
|
|
75
|
+
publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
|
|
74
76
|
clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
|
|
75
77
|
});
|
|
76
78
|
const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
|
|
@@ -259,9 +261,9 @@ export function createArtifactRoutes(config) {
|
|
|
259
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] }));
|
|
260
262
|
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] }));
|
|
261
263
|
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 }) }));
|
|
262
|
-
})() }), _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 })) : (
|
|
263
|
-
|
|
264
|
-
|
|
264
|
+
})() }), _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,
|
|
265
|
+
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
266
|
+
recorded at publish, or there is none. */] }));
|
|
265
267
|
}
|
|
266
268
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
267
269
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
@@ -355,7 +357,7 @@ export function createArtifactRoutes(config) {
|
|
|
355
357
|
/** GET|POST /api/artifacts/<id>/access, publish key: the page's level and its list.
|
|
356
358
|
* POST body: { access?: 'freedom'|'invite'|'public', add?: [{ email, name?, reason? }], remove?: [email] }. */
|
|
357
359
|
async function ACCESS(request, { params }) {
|
|
358
|
-
if (!
|
|
360
|
+
if (!authed(request))
|
|
359
361
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
360
362
|
if (!config.readers)
|
|
361
363
|
return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
|
|
@@ -390,7 +392,7 @@ export function createArtifactRoutes(config) {
|
|
|
390
392
|
/** GET /api/artifacts/<id>/reads, publish key: who read it, for how long, how far, and every
|
|
391
393
|
* attempt to take it away or to open it without being let in. */
|
|
392
394
|
async function READS(request, { params }) {
|
|
393
|
-
if (!
|
|
395
|
+
if (!authed(request))
|
|
394
396
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
395
397
|
if (!config.readers)
|
|
396
398
|
return NextResponse.json({ error: 'this host keeps no reader record' }, { status: 501 });
|
|
@@ -402,7 +404,7 @@ export function createArtifactRoutes(config) {
|
|
|
402
404
|
return NextResponse.json({ id, title: a.title, access: a.access ?? null, views: a.views, ...summarize(sessions, flags), acks: acks.map((k) => ({ email: k.email, name: k.name, at: k.at })) }, { headers: { 'cache-control': 'no-store' } });
|
|
403
405
|
}
|
|
404
406
|
async function POST(request) {
|
|
405
|
-
if (!
|
|
407
|
+
if (!authed(request))
|
|
406
408
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
407
409
|
const text = await request.text();
|
|
408
410
|
const parsed = parseArtifactSource(text);
|
|
@@ -447,7 +449,7 @@ export function createArtifactRoutes(config) {
|
|
|
447
449
|
}, { status: result.created ? 201 : 200 });
|
|
448
450
|
}
|
|
449
451
|
async function GET(request, { params }) {
|
|
450
|
-
if (!
|
|
452
|
+
if (!authed(request))
|
|
451
453
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
452
454
|
const { id } = await params;
|
|
453
455
|
const a = await store.get(id);
|
|
@@ -457,7 +459,7 @@ export function createArtifactRoutes(config) {
|
|
|
457
459
|
}
|
|
458
460
|
/** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
|
|
459
461
|
async function PUT_ASSET(request, { params }) {
|
|
460
|
-
if (!
|
|
462
|
+
if (!authed(request))
|
|
461
463
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
462
464
|
if (!config.assets)
|
|
463
465
|
return NextResponse.json({ error: 'this host does not store assets' }, { status: 501 });
|
|
@@ -501,7 +503,7 @@ export function createArtifactRoutes(config) {
|
|
|
501
503
|
const { id, name } = await params;
|
|
502
504
|
return voiceRoutes.readerUpload(request, id, name, body);
|
|
503
505
|
}
|
|
504
|
-
if (!
|
|
506
|
+
if (!authed(request))
|
|
505
507
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
506
508
|
const assets = config.assets;
|
|
507
509
|
if (!assets?.signUpload || !assets.finishUpload)
|
|
@@ -542,7 +544,7 @@ export function createArtifactRoutes(config) {
|
|
|
542
544
|
/** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
|
|
543
545
|
* version after the fact. POST body: { version: number, note: string | null }. */
|
|
544
546
|
async function VERSIONS(request, { params }) {
|
|
545
|
-
if (!
|
|
547
|
+
if (!authed(request))
|
|
546
548
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
547
549
|
const { id } = await params;
|
|
548
550
|
if (!store.history || !store.setNote)
|
|
@@ -561,7 +563,7 @@ export function createArtifactRoutes(config) {
|
|
|
561
563
|
return NextResponse.json({ id, versions: await store.history(id) });
|
|
562
564
|
}
|
|
563
565
|
async function DELETE(request, { params }) {
|
|
564
|
-
if (!
|
|
566
|
+
if (!authed(request))
|
|
565
567
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
566
568
|
const { id } = await params;
|
|
567
569
|
const ok = await store.delete(id);
|
|
@@ -20,6 +20,9 @@ export type StateRoutesContext = {
|
|
|
20
20
|
readers?: ReadersStore;
|
|
21
21
|
readerSecret: () => string | undefined;
|
|
22
22
|
publishKey: () => string | undefined;
|
|
23
|
+
/** Publisher passes, as in ArtifactRoutesConfig. Both absent means passes are refused. */
|
|
24
|
+
publisherSecret?: () => string | undefined;
|
|
25
|
+
publisherHost?: string;
|
|
23
26
|
pageUrl: (id: string) => string;
|
|
24
27
|
signInOrigin?: string;
|
|
25
28
|
siteUrl: string;
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// signed-in reader, once, and the cookie is cleared.
|
|
12
12
|
import { createHmac, randomBytes } from 'node:crypto';
|
|
13
13
|
import { NextResponse } from 'next/server';
|
|
14
|
-
import {
|
|
14
|
+
import { isPublisherAuthed } from '../artifacts/auth.js';
|
|
15
15
|
import { ARTIFACT_ID_RE } from './ids.js';
|
|
16
16
|
import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../artifacts/reader.js';
|
|
17
17
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
@@ -290,7 +290,7 @@ export function createStateRoutes(ctx) {
|
|
|
290
290
|
return res;
|
|
291
291
|
}
|
|
292
292
|
async function RESPONSES(req, { params }) {
|
|
293
|
-
if (!
|
|
293
|
+
if (!isPublisherAuthed(req, ctx))
|
|
294
294
|
return json({ error: 'Unauthorized' }, 401);
|
|
295
295
|
if (!ctx.state)
|
|
296
296
|
return json({ error: 'this host keeps no answers' }, 501);
|
|
@@ -316,7 +316,7 @@ export function createStateRoutes(ctx) {
|
|
|
316
316
|
* page. It reads the `comments` slot of the state core only; personal notes live in a store no
|
|
317
317
|
* publisher route reads, so they cannot appear here. The publisher's own notifier polls it. */
|
|
318
318
|
async function COMMENTS_FEED(req) {
|
|
319
|
-
if (!
|
|
319
|
+
if (!isPublisherAuthed(req, ctx))
|
|
320
320
|
return json({ error: 'Unauthorized' }, 401);
|
|
321
321
|
const since = feedSince(req.nextUrl.searchParams.get('since'));
|
|
322
322
|
if (!since)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.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",
|
|
@@ -1,8 +0,0 @@
|
|
|
1
|
-
export declare const BROWSER_READER_LABEL = "Read aloud by your browser";
|
|
2
|
-
export declare function BrowserReader({ rootId, label, accent, ground, }: {
|
|
3
|
-
rootId: string;
|
|
4
|
-
/** The line under the play button. */
|
|
5
|
-
label?: string;
|
|
6
|
-
accent: string;
|
|
7
|
-
ground: string;
|
|
8
|
-
}): import("react").JSX.Element;
|
|
@@ -1,171 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
-
// Read-along for a page with no recorded narration: the browser's own voice (speechSynthesis)
|
|
4
|
-
// reads the same word spans the recorded reader lights, and its word-boundary events light them.
|
|
5
|
-
// It sounds worse than a recorded narrator and sends nothing anywhere.
|
|
6
|
-
//
|
|
7
|
-
// One utterance per sentence (see speech-plan.ts). Safari's boundary charIndex drifts on long
|
|
8
|
-
// passages; every new utterance restarts it at zero against offsets we know, so a drift cannot
|
|
9
|
-
// outlast the sentence it began in. Every utterance carries the generation it was spoken in, and
|
|
10
|
-
// an event from an older generation (a sentence cancelled by a click, a seek or a speed change,
|
|
11
|
-
// whose end or error the engine still reports) is ignored.
|
|
12
|
-
//
|
|
13
|
-
// The mount is rendered hidden and empty on the server and on the first client render, so the
|
|
14
|
-
// markup always hydrates; it shows only once the browser is known to have speechSynthesis. It
|
|
15
|
-
// never carries data-artifact-player while hidden, because the popovers measure that element to
|
|
16
|
-
// keep clear of the bar, and a hidden element measures as the whole screen.
|
|
17
|
-
import { useEffect, useRef, useState } from 'react';
|
|
18
|
-
import { BLOCK, seekableWord, wrapWords } from './artifact-reader.js';
|
|
19
|
-
import { nextRate, PlayerBar } from './player-bar.js';
|
|
20
|
-
import { fromWord, planSentences, wordAt } from './speech-plan.js';
|
|
21
|
-
export const BROWSER_READER_LABEL = 'Read aloud by your browser';
|
|
22
|
-
function spokenWords(spans) {
|
|
23
|
-
const words = [];
|
|
24
|
-
const starts = new Set();
|
|
25
|
-
let prevBlock = null;
|
|
26
|
-
spans.forEach((span, i) => {
|
|
27
|
-
// Punctuation glued on across an element edge ("**bold**.") is a tail, not a word; it is
|
|
28
|
-
// spoken with the word it follows, which is also what lets it end a sentence.
|
|
29
|
-
const tail = span.nextSibling instanceof HTMLElement && span.nextSibling.classList.contains('artifact-word-tail') ? span.nextSibling.textContent ?? '' : '';
|
|
30
|
-
words.push((span.textContent ?? '') + tail);
|
|
31
|
-
const block = span.closest(BLOCK);
|
|
32
|
-
if (i > 0 && block !== prevBlock)
|
|
33
|
-
starts.add(i);
|
|
34
|
-
prevBlock = block;
|
|
35
|
-
});
|
|
36
|
-
return { words, starts };
|
|
37
|
-
}
|
|
38
|
-
export function BrowserReader({ rootId, label = BROWSER_READER_LABEL, accent, ground, }) {
|
|
39
|
-
const [ready, setReady] = useState(false);
|
|
40
|
-
const [playing, setPlaying] = useState(false);
|
|
41
|
-
const [current, setCurrent] = useState(-1);
|
|
42
|
-
const [count, setCount] = useState(0);
|
|
43
|
-
const [rate, setRate] = useState(1);
|
|
44
|
-
const spansRef = useRef([]);
|
|
45
|
-
const planRef = useRef([]);
|
|
46
|
-
const genRef = useRef(0);
|
|
47
|
-
const rateRef = useRef(1);
|
|
48
|
-
const currentRef = useRef(-1);
|
|
49
|
-
const playingRef = useRef(false);
|
|
50
|
-
// True from the first play until the page is finished: pause and resume act on a live queue.
|
|
51
|
-
const startedRef = useRef(false);
|
|
52
|
-
const litRef = useRef(null);
|
|
53
|
-
const setPlay = (p) => {
|
|
54
|
-
playingRef.current = p;
|
|
55
|
-
setPlaying(p);
|
|
56
|
-
};
|
|
57
|
-
const light = (i) => {
|
|
58
|
-
const el = i >= 0 ? spansRef.current[i] ?? null : null;
|
|
59
|
-
if (el !== litRef.current) {
|
|
60
|
-
litRef.current?.classList.remove('artifact-word-lit');
|
|
61
|
-
el?.classList.add('artifact-word-lit');
|
|
62
|
-
litRef.current = el;
|
|
63
|
-
if (el && playingRef.current) {
|
|
64
|
-
const r = el.getBoundingClientRect();
|
|
65
|
-
if (r.top < 80 || r.bottom > window.innerHeight - 140)
|
|
66
|
-
el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
currentRef.current = i;
|
|
70
|
-
setCurrent(i);
|
|
71
|
-
};
|
|
72
|
-
const finish = () => {
|
|
73
|
-
startedRef.current = false;
|
|
74
|
-
setPlay(false);
|
|
75
|
-
light(-1);
|
|
76
|
-
};
|
|
77
|
-
const speak = (s, gen) => {
|
|
78
|
-
const synth = window.speechSynthesis;
|
|
79
|
-
const u = new SpeechSynthesisUtterance(s.text);
|
|
80
|
-
u.rate = rateRef.current;
|
|
81
|
-
u.onboundary = (e) => {
|
|
82
|
-
if (gen !== genRef.current || e.name !== 'word')
|
|
83
|
-
return;
|
|
84
|
-
light(wordAt(s, e.charIndex));
|
|
85
|
-
};
|
|
86
|
-
u.onend = () => {
|
|
87
|
-
if (gen !== genRef.current)
|
|
88
|
-
return;
|
|
89
|
-
const next = s.first + s.offsets.length;
|
|
90
|
-
if (next < spansRef.current.length)
|
|
91
|
-
speak(fromWord(planRef.current, next), gen);
|
|
92
|
-
else
|
|
93
|
-
finish();
|
|
94
|
-
};
|
|
95
|
-
u.onerror = () => {
|
|
96
|
-
if (gen === genRef.current)
|
|
97
|
-
finish();
|
|
98
|
-
};
|
|
99
|
-
// Light the sentence's first word now: some engines send no boundary events at all, and the
|
|
100
|
-
// page should still show where it is.
|
|
101
|
-
light(s.first);
|
|
102
|
-
synth.speak(u);
|
|
103
|
-
};
|
|
104
|
-
/** Cancel whatever is speaking and read from word `i` on. */
|
|
105
|
-
const speakFrom = (i) => {
|
|
106
|
-
if (!planRef.current.length)
|
|
107
|
-
return;
|
|
108
|
-
const synth = window.speechSynthesis;
|
|
109
|
-
const gen = ++genRef.current;
|
|
110
|
-
synth.cancel();
|
|
111
|
-
// An engine paused before the cancel stays paused for the next utterance in some browsers.
|
|
112
|
-
synth.resume();
|
|
113
|
-
startedRef.current = true;
|
|
114
|
-
setPlay(true);
|
|
115
|
-
speak(fromWord(planRef.current, Math.max(0, Math.min(i, spansRef.current.length - 1))), gen);
|
|
116
|
-
};
|
|
117
|
-
const speakFromRef = useRef(speakFrom);
|
|
118
|
-
speakFromRef.current = speakFrom;
|
|
119
|
-
useEffect(() => {
|
|
120
|
-
if (typeof window === 'undefined' || !('speechSynthesis' in window))
|
|
121
|
-
return;
|
|
122
|
-
const root = document.getElementById(rootId);
|
|
123
|
-
if (!root)
|
|
124
|
-
return;
|
|
125
|
-
const spans = wrapWords(root);
|
|
126
|
-
if (!spans.length)
|
|
127
|
-
return;
|
|
128
|
-
const { words, starts } = spokenWords(spans);
|
|
129
|
-
spansRef.current = spans;
|
|
130
|
-
planRef.current = planSentences(words, starts);
|
|
131
|
-
setCount(spans.length);
|
|
132
|
-
setReady(true);
|
|
133
|
-
const onClick = (e) => {
|
|
134
|
-
const el = seekableWord(e.target);
|
|
135
|
-
if (!el)
|
|
136
|
-
return;
|
|
137
|
-
const idx = spansRef.current.indexOf(el);
|
|
138
|
-
if (idx !== -1)
|
|
139
|
-
speakFromRef.current(idx);
|
|
140
|
-
};
|
|
141
|
-
root.addEventListener('click', onClick);
|
|
142
|
-
return () => {
|
|
143
|
-
root.removeEventListener('click', onClick);
|
|
144
|
-
genRef.current++;
|
|
145
|
-
window.speechSynthesis.cancel();
|
|
146
|
-
};
|
|
147
|
-
}, [rootId]);
|
|
148
|
-
const toggle = () => {
|
|
149
|
-
const synth = window.speechSynthesis;
|
|
150
|
-
if (!startedRef.current)
|
|
151
|
-
speakFrom(0);
|
|
152
|
-
else if (playingRef.current) {
|
|
153
|
-
synth.pause();
|
|
154
|
-
setPlay(false);
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
157
|
-
synth.resume();
|
|
158
|
-
setPlay(true);
|
|
159
|
-
}
|
|
160
|
-
};
|
|
161
|
-
if (!ready)
|
|
162
|
-
return _jsx("div", { "data-artifact-reader": "browser", hidden: true });
|
|
163
|
-
return (_jsx("div", { "data-artifact-reader": "browser", children: _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: label, accent: accent, ground: ground, value: Math.max(current, 0), max: Math.max(count - 1, 0), step: 1, onSeek: (v) => speakFrom(v), progress: `${current + 1} / ${count}`, rate: rate, onRate: () => {
|
|
164
|
-
const r = nextRate(rateRef.current);
|
|
165
|
-
rateRef.current = r;
|
|
166
|
-
setRate(r);
|
|
167
|
-
// The engine fixes the rate when an utterance starts, so restart at the current word.
|
|
168
|
-
if (startedRef.current && playingRef.current)
|
|
169
|
-
speakFrom(Math.max(currentRef.current, 0));
|
|
170
|
-
} }) }));
|
|
171
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
export type Sentence = {
|
|
2
|
-
/** The words joined by single spaces: exactly what is handed to the speech engine. */
|
|
3
|
-
text: string;
|
|
4
|
-
/** Index of the sentence's first word in the page's word list. */
|
|
5
|
-
first: number;
|
|
6
|
-
/** offsets[i] is the char offset, inside `text`, of word `first + i`. */
|
|
7
|
-
offsets: number[];
|
|
8
|
-
};
|
|
9
|
-
export declare const MAX_SENTENCE_WORDS = 60;
|
|
10
|
-
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
11
|
-
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
12
|
-
export declare function planSentences(words: string[], starts?: ReadonlySet<number>): Sentence[];
|
|
13
|
-
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
14
|
-
* is at or before it. */
|
|
15
|
-
export declare function wordAt(s: Sentence, charIndex: number): number;
|
|
16
|
-
/** The index of the sentence holding `wordIndex`. */
|
|
17
|
-
export declare function sentenceOf(plan: Sentence[], wordIndex: number): number;
|
|
18
|
-
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
19
|
-
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
20
|
-
export declare function fromWord(plan: Sentence[], wordIndex: number): Sentence;
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// How the browser read-aloud splits a page into utterances, and maps a speech engine's
|
|
2
|
-
// word-boundary events back to the page's word spans. Pure, so it is tested without a browser.
|
|
3
|
-
//
|
|
4
|
-
// One utterance per sentence is the drift bound: Safari's boundary charIndex wanders on long
|
|
5
|
-
// passages, and every new utterance restarts it at zero against a sentence whose word offsets
|
|
6
|
-
// are known. The cap keeps a page with no punctuation (a list, a table) from becoming one long
|
|
7
|
-
// utterance, which is also where Chrome is known to stop speaking part-way.
|
|
8
|
-
export const MAX_SENTENCE_WORDS = 60;
|
|
9
|
-
// A word ends a sentence when it ends in . ! ? or :, optionally followed by closing quotes or
|
|
10
|
-
// brackets. "10:30" and "example.com" do not, because the punctuation is not at the end.
|
|
11
|
-
const ENDS_SENTENCE = /[.!?:]["'”’)\]]*$/;
|
|
12
|
-
/** Split a page's words into sentences. `starts` holds word indexes that begin a new block
|
|
13
|
-
* (a heading, a paragraph, a list item), which always begin a new sentence. */
|
|
14
|
-
export function planSentences(words, starts) {
|
|
15
|
-
const plan = [];
|
|
16
|
-
let cur = null;
|
|
17
|
-
for (let i = 0; i < words.length; i++) {
|
|
18
|
-
if (cur && (cur.offsets.length >= MAX_SENTENCE_WORDS || starts?.has(i))) {
|
|
19
|
-
plan.push(cur);
|
|
20
|
-
cur = null;
|
|
21
|
-
}
|
|
22
|
-
if (!cur)
|
|
23
|
-
cur = { text: '', first: i, offsets: [] };
|
|
24
|
-
if (cur.offsets.length)
|
|
25
|
-
cur.text += ' ';
|
|
26
|
-
cur.offsets.push(cur.text.length);
|
|
27
|
-
cur.text += words[i];
|
|
28
|
-
if (ENDS_SENTENCE.test(words[i])) {
|
|
29
|
-
plan.push(cur);
|
|
30
|
-
cur = null;
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
if (cur)
|
|
34
|
-
plan.push(cur);
|
|
35
|
-
return plan;
|
|
36
|
-
}
|
|
37
|
-
/** The absolute word index a boundary event's `charIndex` falls in: the last word whose offset
|
|
38
|
-
* is at or before it. */
|
|
39
|
-
export function wordAt(s, charIndex) {
|
|
40
|
-
let i = 0;
|
|
41
|
-
for (let k = 0; k < s.offsets.length; k++) {
|
|
42
|
-
if (s.offsets[k] <= charIndex)
|
|
43
|
-
i = k;
|
|
44
|
-
else
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
return s.first + i;
|
|
48
|
-
}
|
|
49
|
-
/** The index of the sentence holding `wordIndex`. */
|
|
50
|
-
export function sentenceOf(plan, wordIndex) {
|
|
51
|
-
let idx = 0;
|
|
52
|
-
for (let k = 0; k < plan.length; k++) {
|
|
53
|
-
if (plan[k].first <= wordIndex)
|
|
54
|
-
idx = k;
|
|
55
|
-
else
|
|
56
|
-
break;
|
|
57
|
-
}
|
|
58
|
-
return idx;
|
|
59
|
-
}
|
|
60
|
-
/** The sentence holding `wordIndex`, cut so it starts AT that word, with offsets rebased to the
|
|
61
|
-
* cut text. What is spoken when a reader clicks a word in the middle of a sentence. */
|
|
62
|
-
export function fromWord(plan, wordIndex) {
|
|
63
|
-
const s = plan[sentenceOf(plan, wordIndex)];
|
|
64
|
-
const k = Math.max(0, Math.min(wordIndex - s.first, s.offsets.length - 1));
|
|
65
|
-
const base = s.offsets[k];
|
|
66
|
-
return { text: s.text.slice(base), first: s.first + k, offsets: s.offsets.slice(k).map((o) => o - base) };
|
|
67
|
-
}
|