@supersuit/artifacts 0.10.0 → 0.11.1
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 +62 -0
- package/lib/artifacts/anchor.d.ts +33 -0
- package/lib/artifacts/anchor.js +93 -0
- package/lib/artifacts/comments.d.ts +38 -0
- package/lib/artifacts/comments.js +57 -0
- package/lib/artifacts/front-matter.d.ts +7 -0
- package/lib/artifacts/front-matter.js +19 -3
- package/lib/artifacts/index.d.ts +4 -1
- package/lib/artifacts/index.js +4 -1
- package/lib/artifacts/personal-store.d.ts +29 -0
- package/lib/artifacts/personal-store.js +100 -0
- package/lib/artifacts/render.js +60 -52
- package/lib/artifacts/state-store.d.ts +10 -0
- package/lib/artifacts/state-store.js +19 -0
- package/lib/artifacts/state.d.ts +7 -2
- package/lib/artifacts/state.js +7 -3
- package/lib/artifacts/store.d.ts +4 -0
- package/lib/artifacts/store.js +6 -0
- package/lib/artifacts/widgets.d.ts +4 -0
- package/lib/artifacts/widgets.js +70 -0
- package/lib/reader/comment-card.d.ts +18 -0
- package/lib/reader/comment-card.js +36 -0
- package/lib/reader/comment-layer.d.ts +17 -0
- package/lib/reader/comment-layer.js +394 -0
- package/lib/reader/comment-place.d.ts +45 -0
- package/lib/reader/comment-place.js +117 -0
- package/lib/reader/device-notes.d.ts +6 -0
- package/lib/reader/device-notes.js +35 -0
- package/lib/reader/index.d.ts +4 -0
- package/lib/reader/index.js +4 -0
- package/lib/reader/theme-vars.d.ts +3 -0
- package/lib/reader/theme-vars.js +33 -0
- package/lib/routes/artifacts.d.ts +23 -0
- package/lib/routes/artifacts.js +23 -11
- package/lib/routes/personal-routes.d.ts +24 -0
- package/lib/routes/personal-routes.js +100 -0
- package/lib/routes/state-routes.d.ts +3 -0
- package/lib/routes/state-routes.js +71 -12
- package/package.json +1 -1
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.11.1 (2026-09-28)
|
|
7
|
+
|
|
8
|
+
**The comment controls draw in the page's colours.** The comment overlay is portalled onto
|
|
9
|
+
`document.body`, outside the wrapper that sets the `--a-*` theme variables, so on a dark page the
|
|
10
|
+
Comment chip and the Comment button rendered black text on the dark ground. The overlay now
|
|
11
|
+
carries the variables it finds around the page root (`themeVarsAround`), re-read when a `system`
|
|
12
|
+
page changes scheme.
|
|
13
|
+
|
|
14
|
+
- DETECTOR: on a dark page, the Comment button bottom right is hard to read.
|
|
15
|
+
- REMEDY: bump.
|
|
16
|
+
- Tests: `comment-layer.test.tsx` pins the variables on the portalled overlay (mutation-checked).
|
|
17
|
+
|
|
18
|
+
## 0.11.0 (2026-09-28)
|
|
19
|
+
|
|
20
|
+
**Comment on any part of a page.** Select text, or drag a box over anything that is not text
|
|
21
|
+
(an image, a table, a heading), and a comment card opens pinned to that spot. Pins sit in the
|
|
22
|
+
margin; a toggle hides them. Threads take one level of replies.
|
|
23
|
+
|
|
24
|
+
- **Shared comments** are per page: `comments: anyone | signed-in | off` (default `off`) and
|
|
25
|
+
`comments_visible: owner | readers` (default `owner`). They ride the state core as a `many`
|
|
26
|
+
slot named `comments`, so caps, rate limits, CSRF, sign-in migration and the agreement gate on
|
|
27
|
+
confidential pages apply unchanged. A gated page forces `signed-in`. The owner, signed in with
|
|
28
|
+
`ownerEmail`, sees every comment with names on the page; `/responses` returns them.
|
|
29
|
+
- **Personal notes** work on every page, for every reader. When a reader cannot share, the card
|
|
30
|
+
says before they type: "Only you will see this. <owner> has not opened this page to comments,
|
|
31
|
+
so this is a personal note. They won't see it." Signed-in readers' notes are stored in a new
|
|
32
|
+
store no publisher route reads; anonymous readers' notes never leave the device.
|
|
33
|
+
- **Anchors survive a republish**: text by quote plus 32 characters of context, regions by a
|
|
34
|
+
content-hashed block id (`data-block` on every rendered block) and fractions of that block.
|
|
35
|
+
A comment whose spot is gone is listed under "Comments on earlier versions".
|
|
36
|
+
- State API: `op: replace` edits your own entry (optional `StateStore.replace`; a store without
|
|
37
|
+
it answers 501); deleting someone else's comment answers 404. Slots gained an internal
|
|
38
|
+
per-slot `writers` so `comments: anyone` can sit beside signed-in-only answers.
|
|
39
|
+
- DETECTOR: a host on 0.10.x.
|
|
40
|
+
- REMEDY: bump; pass `personal: createPersonalStore(db, base)`, `ownerEmail`, and add
|
|
41
|
+
`app/api/artifacts/[id]/personal/route.ts` delegating to `PERSONAL_GET`, `PERSONAL_POST`,
|
|
42
|
+
`PERSONAL_DELETE`. Without `personal`, signed-in readers' notes stay on their device.
|
|
43
|
+
- Tests: anchors (re-find after insertions, duplicates and deletions; block ids stable across
|
|
44
|
+
unrelated edits), comment validation, replies, owner view, `comments: off` refusing a write with
|
|
45
|
+
the UI bypassed, the comment layer and card (the warning line, the share switch, device notes,
|
|
46
|
+
pins, the earlier-versions list), and `personal-privacy.test.tsx`, which calls every handler the
|
|
47
|
+
factory returns as publisher, owner and nobody and fails if any of them returns a personal note.
|
|
48
|
+
The packed fixture posts a comment and reads it back through `/responses`.
|
|
49
|
+
|
|
6
50
|
## 0.10.0 (2026-09-28)
|
|
7
51
|
|
|
8
52
|
**Every page can be read aloud.** A page with no recorded narration, or whose timings will not
|
package/README.md
CHANGED
|
@@ -384,6 +384,68 @@ visibility: shared # optional: private | shared
|
|
|
384
384
|
can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
|
|
385
385
|
page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
|
|
386
386
|
|
|
387
|
+
## Comments and personal notes
|
|
388
|
+
|
|
389
|
+
Every page takes comments pinned to any part of it. A reader selects text (a **Comment** chip
|
|
390
|
+
appears) or holds and drags a box over anything that is not text (an image, a table, the gap
|
|
391
|
+
between paragraphs; on a phone, long-press then drag). The card that opens says who will read it
|
|
392
|
+
before anything is typed.
|
|
393
|
+
|
|
394
|
+
```yaml
|
|
395
|
+
comments: anyone # anyone reading can leave SHARED comments
|
|
396
|
+
comments: signed-in # only signed-in readers (forced on a gated page)
|
|
397
|
+
comments: off # the default: personal notes only
|
|
398
|
+
comments_visible: owner # the default: shared comments go to the owner only
|
|
399
|
+
comments_visible: readers # every reader who can comment sees the whole thread
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
- **Shared comments** are the `many` slot `comments` on the state API, declared by the
|
|
403
|
+
`comments:` line (a page needs no `state:` for it). Caps, rate limits, sign-in migration, CSRF
|
|
404
|
+
and the agreement gate all apply. `comments: off` declares no slot, so the API refuses a shared
|
|
405
|
+
comment as it refuses any undeclared slot. A comment is `{ anchor, body, version, parent? }`,
|
|
406
|
+
checked on the server; a reply names a top-level comment its writer can see (one level only). A
|
|
407
|
+
writer edits (`op: replace`) or deletes (`op: remove`) only their own. The publisher reads them
|
|
408
|
+
through `/responses` like every answer.
|
|
409
|
+
- **The owner signed in** (`ownerEmail` in the config) sees every shared comment with its
|
|
410
|
+
writer's full name, whatever `comments_visible:` says.
|
|
411
|
+
- **Personal notes** are for every reader on every page. Without comment access the card opens
|
|
412
|
+
with: *"Only you will see this. <owner> has not opened this page to comments, so this is a
|
|
413
|
+
personal note. They won't see it."* (`<owner>` is the config's `owner`, default the brand name.)
|
|
414
|
+
Signed in, the note is stored under the reader's id through `/personal`; signed out, it stays in
|
|
415
|
+
the browser (`localStorage`) and moves to the account on sign-in. **No publisher route reads
|
|
416
|
+
personal notes**: `src/routes/personal-privacy.test.tsx` calls every handler the factory returns
|
|
417
|
+
with the publish key and fails if one ever does.
|
|
418
|
+
- **Anchors survive a republish.** A text comment stores its quote with 32 characters either side
|
|
419
|
+
and the heading it sat under; a region comment stores the block it was drawn on (every top-level
|
|
420
|
+
block carries a stable `data-block` id from its content) and the box as fractions of it. One
|
|
421
|
+
whose quote or block is gone is listed under "Comments on earlier versions", with its quote.
|
|
422
|
+
|
|
423
|
+
Wiring, beside the state routes:
|
|
424
|
+
|
|
425
|
+
```ts
|
|
426
|
+
// lib/artifacts.ts
|
|
427
|
+
export const artifacts = createArtifactRoutes({
|
|
428
|
+
store,
|
|
429
|
+
state: createStateStore(getFirestore(), 'artifacts'),
|
|
430
|
+
personal: createPersonalStore(getFirestore(), 'artifacts'), // <base>Personal/<page>__<uid>/notes/<id>
|
|
431
|
+
ownerEmail: 'owner@example.com',
|
|
432
|
+
// ...the rest of your config
|
|
433
|
+
})
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
// app/api/artifacts/[id]/personal/route.ts
|
|
438
|
+
import type { NextRequest } from 'next/server'
|
|
439
|
+
import { artifacts } from '@/lib/artifacts'
|
|
440
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
441
|
+
export function GET(request: NextRequest, ctx: Ctx) { return artifacts.PERSONAL_GET(request, ctx) }
|
|
442
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.PERSONAL_POST(request, ctx) }
|
|
443
|
+
export function DELETE(request: NextRequest, ctx: Ctx) { return artifacts.PERSONAL_DELETE(request, ctx) }
|
|
444
|
+
```
|
|
445
|
+
|
|
446
|
+
Without `personal`, every reader's notes stay on their device. Without `state`, no page takes
|
|
447
|
+
shared comments.
|
|
448
|
+
|
|
387
449
|
## Brand packs
|
|
388
450
|
|
|
389
451
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type TextAnchor = {
|
|
2
|
+
kind: 'text';
|
|
3
|
+
quote: string;
|
|
4
|
+
prefix: string;
|
|
5
|
+
suffix: string;
|
|
6
|
+
heading?: string;
|
|
7
|
+
};
|
|
8
|
+
export type RegionAnchor = {
|
|
9
|
+
kind: 'region';
|
|
10
|
+
block: string;
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
w: number;
|
|
14
|
+
h: number;
|
|
15
|
+
};
|
|
16
|
+
export type Anchor = TextAnchor | RegionAnchor;
|
|
17
|
+
export declare const CONTEXT_CHARS = 32;
|
|
18
|
+
export declare const MAX_QUOTE_CHARS = 500;
|
|
19
|
+
export declare const BLOCK_ID: RegExp;
|
|
20
|
+
export declare function textAnchorFrom(fullText: string, start: number, end: number, heading?: string): TextAnchor;
|
|
21
|
+
/** Where the quote is now: the exact quote, and when there are several copies, the one whose
|
|
22
|
+
* surrounding text matches the stored context in the most characters (the first on a tie).
|
|
23
|
+
* Null when the quote no longer appears at all. */
|
|
24
|
+
export declare function findText(fullText: string, a: TextAnchor): {
|
|
25
|
+
start: number;
|
|
26
|
+
end: number;
|
|
27
|
+
} | null;
|
|
28
|
+
/** `b-<kind>-<8 hex of its content>`. `index` is how many identical blocks (same kind and
|
|
29
|
+
* content) come before this one on the page, so it only ever breaks a tie. Images are keyed by
|
|
30
|
+
* their asset name, so a re-uploaded image with the same name keeps its comments. */
|
|
31
|
+
export declare function blockId(kind: string, index: number, content: string): string;
|
|
32
|
+
/** The server's check on an anchor posted with a comment. */
|
|
33
|
+
export declare function parseAnchor(raw: unknown): Anchor | null;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
export const CONTEXT_CHARS = 32;
|
|
2
|
+
export const MAX_QUOTE_CHARS = 500;
|
|
3
|
+
const MAX_HEADING_SLUG = 120;
|
|
4
|
+
export const BLOCK_ID = /^b-[a-z]{1,8}-[0-9a-f]{8}(?:-[1-9]\d{0,3})?$/;
|
|
5
|
+
export function textAnchorFrom(fullText, start, end, heading) {
|
|
6
|
+
return {
|
|
7
|
+
kind: 'text',
|
|
8
|
+
quote: fullText.slice(start, end),
|
|
9
|
+
prefix: fullText.slice(Math.max(0, start - CONTEXT_CHARS), start),
|
|
10
|
+
suffix: fullText.slice(end, end + CONTEXT_CHARS),
|
|
11
|
+
...(heading ? { heading } : {}),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** How many characters at the END of `a` match the end of `b`. */
|
|
15
|
+
function sameEnding(a, b) {
|
|
16
|
+
let n = 0;
|
|
17
|
+
while (n < a.length && n < b.length && a[a.length - 1 - n] === b[b.length - 1 - n])
|
|
18
|
+
n++;
|
|
19
|
+
return n;
|
|
20
|
+
}
|
|
21
|
+
/** How many characters at the START of `a` match the start of `b`. */
|
|
22
|
+
function sameStart(a, b) {
|
|
23
|
+
let n = 0;
|
|
24
|
+
while (n < a.length && n < b.length && a[n] === b[n])
|
|
25
|
+
n++;
|
|
26
|
+
return n;
|
|
27
|
+
}
|
|
28
|
+
/** Where the quote is now: the exact quote, and when there are several copies, the one whose
|
|
29
|
+
* surrounding text matches the stored context in the most characters (the first on a tie).
|
|
30
|
+
* Null when the quote no longer appears at all. */
|
|
31
|
+
export function findText(fullText, a) {
|
|
32
|
+
if (!a.quote)
|
|
33
|
+
return null;
|
|
34
|
+
let best = null;
|
|
35
|
+
for (let i = fullText.indexOf(a.quote); i !== -1; i = fullText.indexOf(a.quote, i + 1)) {
|
|
36
|
+
const before = fullText.slice(Math.max(0, i - a.prefix.length), i);
|
|
37
|
+
const after = fullText.slice(i + a.quote.length, i + a.quote.length + a.suffix.length);
|
|
38
|
+
const score = sameEnding(before, a.prefix) + sameStart(after, a.suffix);
|
|
39
|
+
if (!best || score > best.score)
|
|
40
|
+
best = { start: i, score };
|
|
41
|
+
}
|
|
42
|
+
return best ? { start: best.start, end: best.start + a.quote.length } : null;
|
|
43
|
+
}
|
|
44
|
+
function fnv1a(s) {
|
|
45
|
+
let h = 0x811c9dc5;
|
|
46
|
+
for (let i = 0; i < s.length; i++) {
|
|
47
|
+
h ^= s.charCodeAt(i);
|
|
48
|
+
h = Math.imul(h, 0x01000193) >>> 0;
|
|
49
|
+
}
|
|
50
|
+
return h.toString(16).padStart(8, '0');
|
|
51
|
+
}
|
|
52
|
+
/** `b-<kind>-<8 hex of its content>`. `index` is how many identical blocks (same kind and
|
|
53
|
+
* content) come before this one on the page, so it only ever breaks a tie. Images are keyed by
|
|
54
|
+
* their asset name, so a re-uploaded image with the same name keeps its comments. */
|
|
55
|
+
export function blockId(kind, index, content) {
|
|
56
|
+
const id = `b-${kind}-${fnv1a(`${kind}\n${content}`)}`;
|
|
57
|
+
return index > 0 ? `${id}-${index}` : id;
|
|
58
|
+
}
|
|
59
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
60
|
+
const frac = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
61
|
+
/** The server's check on an anchor posted with a comment. */
|
|
62
|
+
export function parseAnchor(raw) {
|
|
63
|
+
if (!isMap(raw))
|
|
64
|
+
return null;
|
|
65
|
+
if (raw.kind === 'text') {
|
|
66
|
+
if (Object.keys(raw).some((k) => !['kind', 'quote', 'prefix', 'suffix', 'heading'].includes(k)))
|
|
67
|
+
return null;
|
|
68
|
+
const { quote, prefix, suffix, heading } = raw;
|
|
69
|
+
if (typeof quote !== 'string' || !quote.trim() || quote.length > MAX_QUOTE_CHARS)
|
|
70
|
+
return null;
|
|
71
|
+
if (typeof prefix !== 'string' || prefix.length > CONTEXT_CHARS)
|
|
72
|
+
return null;
|
|
73
|
+
if (typeof suffix !== 'string' || suffix.length > CONTEXT_CHARS)
|
|
74
|
+
return null;
|
|
75
|
+
if (heading !== undefined && (typeof heading !== 'string' || !heading || heading.length > MAX_HEADING_SLUG))
|
|
76
|
+
return null;
|
|
77
|
+
return { kind: 'text', quote, prefix, suffix, ...(heading ? { heading: heading } : {}) };
|
|
78
|
+
}
|
|
79
|
+
if (raw.kind === 'region') {
|
|
80
|
+
if (Object.keys(raw).some((k) => !['kind', 'block', 'x', 'y', 'w', 'h'].includes(k)))
|
|
81
|
+
return null;
|
|
82
|
+
const { block, x, y, w, h } = raw;
|
|
83
|
+
if (typeof block !== 'string' || !BLOCK_ID.test(block))
|
|
84
|
+
return null;
|
|
85
|
+
if (!frac(x) || !frac(y) || !frac(w) || !frac(h) || w <= 0 || h <= 0)
|
|
86
|
+
return null;
|
|
87
|
+
// A small allowance for float rounding at the far edge; a box that leaves its block is refused.
|
|
88
|
+
if (x + w > 1.000001 || y + h > 1.000001)
|
|
89
|
+
return null;
|
|
90
|
+
return { kind: 'region', block, x, y, w, h };
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateConfig, Writers } from './state.js';
|
|
2
|
+
import { type Anchor } from './anchor.js';
|
|
3
|
+
export declare const COMMENTS_SLOT = "comments";
|
|
4
|
+
export type CommentsMode = 'off' | Writers;
|
|
5
|
+
export type CommentsVisible = 'owner' | 'readers';
|
|
6
|
+
export declare const COMMENTS_MODES: readonly CommentsMode[];
|
|
7
|
+
/** The page's `comments:` line becomes the `comments` slot, beside whatever `state:` declares,
|
|
8
|
+
* the way the notes block adds `notes`. Off (or absent) adds nothing, so the state API refuses a
|
|
9
|
+
* shared-comment write as it refuses any slot the page did not declare. */
|
|
10
|
+
export declare function mergeCommentsState(state: StateConfig | undefined, comments: CommentsMode | undefined, visible: CommentsVisible | undefined): {
|
|
11
|
+
ok: true;
|
|
12
|
+
state: StateConfig | undefined;
|
|
13
|
+
} | {
|
|
14
|
+
ok: false;
|
|
15
|
+
error: string;
|
|
16
|
+
};
|
|
17
|
+
export declare const MAX_COMMENT_CHARS = 4000;
|
|
18
|
+
export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
|
|
19
|
+
/** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
|
|
20
|
+
* level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
|
|
21
|
+
export type CommentValue = {
|
|
22
|
+
anchor: Anchor;
|
|
23
|
+
body: string;
|
|
24
|
+
version: number;
|
|
25
|
+
parent?: string;
|
|
26
|
+
audio?: string;
|
|
27
|
+
transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
|
|
28
|
+
};
|
|
29
|
+
/** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
|
|
30
|
+
* returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
|
|
31
|
+
* writer was shown. The browser checks too; this is the one that counts. */
|
|
32
|
+
export declare function validateComment(v: unknown, existing: (id: string) => CommentValue | null): {
|
|
33
|
+
ok: true;
|
|
34
|
+
value: CommentValue;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
error: string;
|
|
38
|
+
};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { parseAnchor } from './anchor.js';
|
|
2
|
+
export const COMMENTS_SLOT = 'comments';
|
|
3
|
+
export const COMMENTS_MODES = ['off', 'anyone', 'signed-in'];
|
|
4
|
+
/** The page's `comments:` line becomes the `comments` slot, beside whatever `state:` declares,
|
|
5
|
+
* the way the notes block adds `notes`. Off (or absent) adds nothing, so the state API refuses a
|
|
6
|
+
* shared-comment write as it refuses any slot the page did not declare. */
|
|
7
|
+
export function mergeCommentsState(state, comments, visible) {
|
|
8
|
+
if (!comments || comments === 'off')
|
|
9
|
+
return { ok: true, state };
|
|
10
|
+
if (state && Object.hasOwn(state.slots, COMMENTS_SLOT))
|
|
11
|
+
return { ok: false, error: `comments: writes to slot "${COMMENTS_SLOT}", and state: declares a slot by that name; rename that slot` };
|
|
12
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
13
|
+
const slot = { shape: 'many', visibility: visible === 'readers' ? 'shared' : 'private', writers: comments };
|
|
14
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [COMMENTS_SLOT]: slot } } };
|
|
15
|
+
}
|
|
16
|
+
export const MAX_COMMENT_CHARS = 4000;
|
|
17
|
+
export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
|
|
18
|
+
const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
19
|
+
// An uploaded asset path under the page (comments/<id>.webm), never a URL and never `..`.
|
|
20
|
+
const AUDIO_PATH = /^(?!.*\.\.)[A-Za-z0-9_-][A-Za-z0-9._/-]{0,299}$/;
|
|
21
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
22
|
+
/** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
|
|
23
|
+
* returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
|
|
24
|
+
* writer was shown. The browser checks too; this is the one that counts. */
|
|
25
|
+
export function validateComment(v, existing) {
|
|
26
|
+
if (!isMap(v) || Object.keys(v).some((k) => !['anchor', 'body', 'version', 'parent', 'audio', 'transcript'].includes(k)))
|
|
27
|
+
return { ok: false, error: 'a comment is { anchor, body, version, parent? }' };
|
|
28
|
+
const anchor = parseAnchor(v.anchor);
|
|
29
|
+
if (!anchor)
|
|
30
|
+
return { ok: false, error: 'a comment needs the place on the page it is pinned to' };
|
|
31
|
+
if (v.audio !== undefined && (typeof v.audio !== 'string' || !AUDIO_PATH.test(v.audio)))
|
|
32
|
+
return { ok: false, error: 'a comment\'s audio is the path it was uploaded to' };
|
|
33
|
+
if (typeof v.body !== 'string' || (!v.body.trim() && v.audio === undefined))
|
|
34
|
+
return { ok: false, error: 'a comment needs some text' };
|
|
35
|
+
if (v.body.length > MAX_COMMENT_CHARS)
|
|
36
|
+
return { ok: false, error: `a comment is at most ${MAX_COMMENT_CHARS} characters` };
|
|
37
|
+
if (typeof v.version !== 'number' || !Number.isInteger(v.version) || v.version < 1 || v.version > 999999)
|
|
38
|
+
return { ok: false, error: 'a comment needs the version it was left on' };
|
|
39
|
+
if (v.transcript !== undefined && !COMMENT_TRANSCRIPTS.includes(v.transcript))
|
|
40
|
+
return { ok: false, error: `a comment's transcript is one of: ${COMMENT_TRANSCRIPTS.join(', ')}` };
|
|
41
|
+
const value = { anchor, body: v.body, version: v.version };
|
|
42
|
+
if (v.parent !== undefined) {
|
|
43
|
+
if (typeof v.parent !== 'string' || !ENTRY_ID.test(v.parent))
|
|
44
|
+
return { ok: false, error: 'a reply names the comment it answers' };
|
|
45
|
+
const parent = existing(v.parent);
|
|
46
|
+
if (!parent)
|
|
47
|
+
return { ok: false, error: 'there is no comment here to reply to' };
|
|
48
|
+
if (parent.parent)
|
|
49
|
+
return { ok: false, error: 'replies are one level deep: reply to the comment, not to a reply' };
|
|
50
|
+
value.parent = v.parent;
|
|
51
|
+
}
|
|
52
|
+
if (v.audio !== undefined)
|
|
53
|
+
value.audio = v.audio;
|
|
54
|
+
if (v.transcript !== undefined)
|
|
55
|
+
value.transcript = v.transcript;
|
|
56
|
+
return { ok: true, value };
|
|
57
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Access } from './reader.js';
|
|
2
2
|
import { type StateConfig } from './state.js';
|
|
3
|
+
import { type CommentsMode, type CommentsVisible } from './comments.js';
|
|
3
4
|
import { type ThemeMode } from '../brand/theme.js';
|
|
4
5
|
import { type Definition } from './definitions.js';
|
|
5
6
|
export type ArtifactMeta = {
|
|
@@ -39,6 +40,12 @@ export type ArtifactMeta = {
|
|
|
39
40
|
/** One line saying what changed in this version. Stored with the version it arrives on; a line
|
|
40
41
|
* left in the file from the previous publish is recognised and not repeated. */
|
|
41
42
|
change?: string;
|
|
43
|
+
/** Who may leave SHARED comments: `off` (the default; readers still keep personal notes),
|
|
44
|
+
* `anyone`, or `signed-in`. Forced to signed-in on a gated page. Not an access control, so a
|
|
45
|
+
* republish without the line goes back to off. */
|
|
46
|
+
comments?: CommentsMode;
|
|
47
|
+
/** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
|
|
48
|
+
commentsVisible?: CommentsVisible;
|
|
42
49
|
};
|
|
43
50
|
export declare function parseArtifactSource(text: string): {
|
|
44
51
|
ok: true;
|
|
@@ -4,9 +4,10 @@ import matter from 'gray-matter';
|
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
5
|
import { parseStateConfig } from './state.js';
|
|
6
6
|
import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
7
|
+
import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
|
|
7
8
|
import { THEME_MODES } from '../brand/theme.js';
|
|
8
9
|
import { parseDefinitions } from './definitions.js';
|
|
9
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change']);
|
|
10
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
|
|
10
11
|
export function parseArtifactSource(text) {
|
|
11
12
|
const { data, content } = matter(text);
|
|
12
13
|
const d = data;
|
|
@@ -66,6 +67,18 @@ export function parseArtifactSource(text) {
|
|
|
66
67
|
return { ok: false, error: s.error };
|
|
67
68
|
meta.state = s.state;
|
|
68
69
|
}
|
|
70
|
+
if (d.comments !== undefined && d.comments !== null) {
|
|
71
|
+
// A bare `off` is a string in YAML 1.2, but `false` is a boolean and means the same thing.
|
|
72
|
+
const c = d.comments === false ? 'off' : d.comments;
|
|
73
|
+
if (!COMMENTS_MODES.includes(c))
|
|
74
|
+
return { ok: false, error: `comments must be one of: ${COMMENTS_MODES.join(', ')}` };
|
|
75
|
+
meta.comments = c;
|
|
76
|
+
}
|
|
77
|
+
if (d.comments_visible !== undefined && d.comments_visible !== null) {
|
|
78
|
+
if (d.comments_visible !== 'owner' && d.comments_visible !== 'readers')
|
|
79
|
+
return { ok: false, error: 'comments_visible must be owner or readers' };
|
|
80
|
+
meta.commentsVisible = d.comments_visible;
|
|
81
|
+
}
|
|
69
82
|
// Widgets declare their own slots, so a notes block is merged into state: here, before the
|
|
70
83
|
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
71
84
|
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
|
@@ -76,7 +89,10 @@ export function parseArtifactSource(text) {
|
|
|
76
89
|
const merged = mergeWidgetState(meta.state, w.notes);
|
|
77
90
|
if (!merged.ok)
|
|
78
91
|
return { ok: false, error: merged.error };
|
|
79
|
-
|
|
80
|
-
|
|
92
|
+
const withComments = mergeCommentsState(merged.state, meta.comments, meta.commentsVisible);
|
|
93
|
+
if (!withComments.ok)
|
|
94
|
+
return { ok: false, error: withComments.error };
|
|
95
|
+
if (withComments.state)
|
|
96
|
+
meta.state = withComments.state;
|
|
81
97
|
return { ok: true, meta, body: content };
|
|
82
98
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -10,4 +10,7 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
|
|
|
10
10
|
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';
|
|
11
11
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
12
12
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
13
|
-
export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
13
|
+
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
14
|
+
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
|
|
15
|
+
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
|
|
16
|
+
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, MAX_PERSONAL_PER_READER, type PersonalStore, type PersonalNote, type PersonalNoteValue } from './personal-store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -10,4 +10,7 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
|
10
10
|
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';
|
|
11
11
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
12
12
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
13
|
-
export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
13
|
+
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
14
|
+
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
|
|
15
|
+
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
|
|
16
|
+
export { createPersonalStore, createMemoryPersonalStore, validatePersonal, MAX_PERSONAL_PER_READER } from './personal-store.js';
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import { type CommentValue } from './comments.js';
|
|
3
|
+
export type PersonalNoteValue = Omit<CommentValue, 'parent'>;
|
|
4
|
+
export type PersonalNote = {
|
|
5
|
+
id: string;
|
|
6
|
+
value: PersonalNoteValue;
|
|
7
|
+
at: string;
|
|
8
|
+
updatedAt?: string;
|
|
9
|
+
};
|
|
10
|
+
export declare const MAX_PERSONAL_PER_READER = 500;
|
|
11
|
+
export interface PersonalStore {
|
|
12
|
+
list(artifactId: string, readerUid: string): Promise<PersonalNote[]>;
|
|
13
|
+
add(artifactId: string, readerUid: string, value: PersonalNoteValue): Promise<PersonalNote | {
|
|
14
|
+
full: true;
|
|
15
|
+
}>;
|
|
16
|
+
replace(artifactId: string, readerUid: string, noteId: string, value: PersonalNoteValue): Promise<PersonalNote | null>;
|
|
17
|
+
remove(artifactId: string, readerUid: string, noteId: string): Promise<boolean>;
|
|
18
|
+
}
|
|
19
|
+
/** A personal note is a comment with no thread: nobody else can see it to reply. */
|
|
20
|
+
export declare function validatePersonal(v: unknown): {
|
|
21
|
+
ok: true;
|
|
22
|
+
value: PersonalNoteValue;
|
|
23
|
+
} | {
|
|
24
|
+
ok: false;
|
|
25
|
+
error: string;
|
|
26
|
+
};
|
|
27
|
+
export declare function createPersonalStore(db: Firestore, base: string): PersonalStore;
|
|
28
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
29
|
+
export declare function createMemoryPersonalStore(): PersonalStore;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// Personal notes: a comment only its writer will ever read. NOT the state core.
|
|
2
|
+
//
|
|
3
|
+
// The state core's `private` visibility hides an answer from other readers and still hands it to
|
|
4
|
+
// the publisher through /responses. A personal note promises the page's owner never sees it, so
|
|
5
|
+
// it lives in a store of its own that no publisher route reads. The route factory hands this store
|
|
6
|
+
// to exactly one set of handlers, the reader's own (/personal, behind their grant cookie), and a
|
|
7
|
+
// test (routes/personal-privacy.test.ts) calls every handler the factory returns with a publish key
|
|
8
|
+
// and fails if the note appears in any answer. That test is what makes the warning line true.
|
|
9
|
+
//
|
|
10
|
+
// <base>Personal/<page>__<readerUid>/notes/<noteId>
|
|
11
|
+
//
|
|
12
|
+
// `base` is the artifacts collection path (`tenants/<id>/artifacts`), the same base the readers
|
|
13
|
+
// store uses, so each tenant's notes sit beside its own pages. A reader who is not signed in keeps
|
|
14
|
+
// notes on their device instead (reader/device-notes.ts): those never reach any server at all.
|
|
15
|
+
import { randomUUID } from 'node:crypto';
|
|
16
|
+
import { validateComment } from './comments.js';
|
|
17
|
+
export const MAX_PERSONAL_PER_READER = 500;
|
|
18
|
+
/** A personal note is a comment with no thread: nobody else can see it to reply. */
|
|
19
|
+
export function validatePersonal(v) {
|
|
20
|
+
if (v && typeof v === 'object' && 'parent' in v)
|
|
21
|
+
return { ok: false, error: 'a personal note is not a reply' };
|
|
22
|
+
const r = validateComment(v, () => null);
|
|
23
|
+
return r.ok ? { ok: true, value: r.value } : r;
|
|
24
|
+
}
|
|
25
|
+
const NOTE_ID = /^[A-Za-z0-9_-]{1,64}$/;
|
|
26
|
+
// A Firestore document id cannot hold a slash; a signed uid never should, but the path is built from it.
|
|
27
|
+
const readerDoc = (artifactId, readerUid) => `${artifactId}__${readerUid.replace(/\//g, '_')}`;
|
|
28
|
+
const byAt = (a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id);
|
|
29
|
+
export function createPersonalStore(db, base) {
|
|
30
|
+
const notes = (artifactId, readerUid) => db.collection(`${base}Personal`).doc(readerDoc(artifactId, readerUid)).collection('notes');
|
|
31
|
+
const toNote = (id, d) => ({ id, value: JSON.parse(d.json), at: d.at, ...(d.updatedAt ? { updatedAt: d.updatedAt } : {}) });
|
|
32
|
+
return {
|
|
33
|
+
async list(artifactId, readerUid) {
|
|
34
|
+
const snap = await notes(artifactId, readerUid).get();
|
|
35
|
+
return snap.docs.map((d) => toNote(d.id, d.data())).sort(byAt);
|
|
36
|
+
},
|
|
37
|
+
async add(artifactId, readerUid, value) {
|
|
38
|
+
const col = notes(artifactId, readerUid);
|
|
39
|
+
if ((await col.count().get()).data().count >= MAX_PERSONAL_PER_READER)
|
|
40
|
+
return { full: true };
|
|
41
|
+
const ref = col.doc();
|
|
42
|
+
const at = new Date().toISOString();
|
|
43
|
+
// Stored as a JSON string, like the state core, because Firestore refuses nested arrays.
|
|
44
|
+
await ref.set({ json: JSON.stringify(value), at });
|
|
45
|
+
return { id: ref.id, value, at };
|
|
46
|
+
},
|
|
47
|
+
async replace(artifactId, readerUid, noteId, value) {
|
|
48
|
+
if (!NOTE_ID.test(noteId))
|
|
49
|
+
return null;
|
|
50
|
+
const ref = notes(artifactId, readerUid).doc(noteId);
|
|
51
|
+
const snap = await ref.get();
|
|
52
|
+
if (!snap.exists)
|
|
53
|
+
return null;
|
|
54
|
+
const updatedAt = new Date().toISOString();
|
|
55
|
+
await ref.update({ json: JSON.stringify(value), updatedAt });
|
|
56
|
+
return { ...toNote(noteId, snap.data()), value, updatedAt };
|
|
57
|
+
},
|
|
58
|
+
async remove(artifactId, readerUid, noteId) {
|
|
59
|
+
if (!NOTE_ID.test(noteId))
|
|
60
|
+
return false;
|
|
61
|
+
const ref = notes(artifactId, readerUid).doc(noteId);
|
|
62
|
+
if (!(await ref.get()).exists)
|
|
63
|
+
return false;
|
|
64
|
+
await ref.delete();
|
|
65
|
+
return true;
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
70
|
+
export function createMemoryPersonalStore() {
|
|
71
|
+
const docs = new Map();
|
|
72
|
+
const of = (artifactId, readerUid) => {
|
|
73
|
+
const k = readerDoc(artifactId, readerUid);
|
|
74
|
+
let m = docs.get(k);
|
|
75
|
+
if (!m)
|
|
76
|
+
docs.set(k, (m = new Map()));
|
|
77
|
+
return m;
|
|
78
|
+
};
|
|
79
|
+
return {
|
|
80
|
+
list: async (artifactId, readerUid) => [...of(artifactId, readerUid).values()].map((n) => structuredClone(n)).sort(byAt),
|
|
81
|
+
async add(artifactId, readerUid, value) {
|
|
82
|
+
const m = of(artifactId, readerUid);
|
|
83
|
+
if (m.size >= MAX_PERSONAL_PER_READER)
|
|
84
|
+
return { full: true };
|
|
85
|
+
const n = { id: randomUUID().replace(/-/g, ''), value: structuredClone(value), at: new Date().toISOString() };
|
|
86
|
+
m.set(n.id, n);
|
|
87
|
+
return structuredClone(n);
|
|
88
|
+
},
|
|
89
|
+
async replace(artifactId, readerUid, noteId, value) {
|
|
90
|
+
const m = of(artifactId, readerUid);
|
|
91
|
+
const n = m.get(noteId);
|
|
92
|
+
if (!n)
|
|
93
|
+
return null;
|
|
94
|
+
const next = { ...n, value: structuredClone(value), updatedAt: new Date().toISOString() };
|
|
95
|
+
m.set(noteId, next);
|
|
96
|
+
return structuredClone(next);
|
|
97
|
+
},
|
|
98
|
+
remove: async (artifactId, readerUid, noteId) => of(artifactId, readerUid).delete(noteId),
|
|
99
|
+
};
|
|
100
|
+
}
|