@supersuit/artifacts 0.1.0 → 0.3.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 +76 -0
- package/README.md +157 -1
- package/lib/artifacts/front-matter.d.ts +4 -0
- package/lib/artifacts/front-matter.js +21 -1
- package/lib/artifacts/index.d.ts +4 -0
- package/lib/artifacts/index.js +4 -0
- package/lib/artifacts/notes-place.d.ts +27 -0
- package/lib/artifacts/notes-place.js +25 -0
- package/lib/artifacts/render.d.ts +7 -1
- package/lib/artifacts/render.js +41 -3
- package/lib/artifacts/state-store.d.ts +62 -0
- package/lib/artifacts/state-store.js +153 -0
- package/lib/artifacts/state-view.d.ts +38 -0
- package/lib/artifacts/state-view.js +66 -0
- package/lib/artifacts/state.d.ts +35 -0
- package/lib/artifacts/state.js +83 -0
- package/lib/artifacts/store.d.ts +2 -0
- package/lib/artifacts/store.js +4 -1
- package/lib/artifacts/widgets.d.ts +38 -0
- package/lib/artifacts/widgets.js +141 -0
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/routes/artifacts.d.ts +25 -2
- package/lib/routes/artifacts.js +51 -8
- package/lib/routes/ids.d.ts +1 -0
- package/lib/routes/ids.js +3 -0
- package/lib/routes/state-routes.d.ts +35 -0
- package/lib/routes/state-routes.js +216 -0
- package/lib/widgets/notes.d.ts +21 -0
- package/lib/widgets/notes.js +134 -0
- package/package.json +39 -10
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,82 @@
|
|
|
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.3.0 (2026-09-25)
|
|
7
|
+
|
|
8
|
+
**Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
|
|
9
|
+
beside every heading, so readers can leave notes on a section and, on a `shared` page, read each
|
|
10
|
+
other's. Poll, form and checklist follow in a later release.
|
|
11
|
+
|
|
12
|
+
- **Fence:** ```` ```notes ```` with an optional `visibility: private | shared` line. At most one
|
|
13
|
+
per page. It declares slot `notes` of shape `many` itself, merged into `state:` at parse time
|
|
14
|
+
(a page with no `state:` gets `writers: signed-in`, `visibility: private`), so the 0.2.0 state
|
|
15
|
+
API takes notes unchanged. Refused at publish with the file's line number: a second block, a
|
|
16
|
+
name, an unknown key, `tally`, a `notes` slot declared `one`, a visibility that disagrees with
|
|
17
|
+
`state:`. `poll`, `form` and `checklist` fences are refused until they ship.
|
|
18
|
+
- **A note is `{ slug, heading, note }`**, checked by the server on a page with a notes block.
|
|
19
|
+
Headings carry their GitHub-style slug as an `id`; the renderer finds each heading by its source
|
|
20
|
+
line, so the slug on the page and the slug a note stores come from one derivation.
|
|
21
|
+
- **A renamed or removed heading keeps its notes**, shown under "Notes on earlier versions" with
|
|
22
|
+
the heading they were left under, never attached to another section.
|
|
23
|
+
- **Narration skips the widget**: the fence is not spoken and everything drawn is `data-nospeak`.
|
|
24
|
+
- **Password pages with `state:`** now set the answer API's unlock cookie on every open, not only
|
|
25
|
+
on `?key=`. Readers who unlocked such a page before 0.2.0 are no longer refused (the item parked
|
|
26
|
+
in 0.2.0).
|
|
27
|
+
- **The replace-at-cap check** reads one document (`StateStore.hasOne`, optional, implemented by
|
|
28
|
+
both shipped stores) instead of every answer on the page.
|
|
29
|
+
- **DETECTOR:** a page with a notes block published to a host with no state store gets `warning:
|
|
30
|
+
this host keeps no answers` and draws no controls. A notes page whose readers see no "note"
|
|
31
|
+
button beside its headings is on 0.2.0 or earlier.
|
|
32
|
+
- **REMEDY (hosts):** take 0.3.0. No new routes: the notes widget uses the 0.2.0 state routes
|
|
33
|
+
(`app/api/artifacts/[id]/state/route.ts`), which a host must already mount. A host with its own
|
|
34
|
+
`StateStore` may add `hasOne`; without it the old full read is used.
|
|
35
|
+
- **Tests:** `widgets.test.ts` (slugs, fence parsing, one per page, reserved widgets, merge,
|
|
36
|
+
note shape, placement), `front-matter.test.ts` (line numbers in the file, the first real page's
|
|
37
|
+
front matter), `render.test.tsx` (controls beside headings, fence never drawn as code),
|
|
38
|
+
`widgets/notes.test.tsx` (jsdom: shared notes under their heading, earlier versions, posting
|
|
39
|
+
appends `{ slug, heading, note }`, sign-in offer, removal, no-store host), `narration.test.ts`,
|
|
40
|
+
`state.test.tsx` (note shape refused, shared read, `hasOne` path), `artifacts.test.tsx` (API
|
|
41
|
+
cookie on every open, page wiring, publish refusal), `state-store.test.ts` (`hasOne`), and the
|
|
42
|
+
packed fixture (a notes page draws its controls, a note posts and reads back, a malformed one is
|
|
43
|
+
refused, the compiled widget keeps `'use client'`).
|
|
44
|
+
|
|
45
|
+
## 0.2.0 (2026-09-24)
|
|
46
|
+
|
|
47
|
+
**Reader answers.** A page can now take input from the people reading it. It declares named
|
|
48
|
+
slots under `state:` in its front matter, and readers' answers are kept per reader, beside the
|
|
49
|
+
pages, and handed back to the publisher. Nothing renders an answer UI yet: widgets (poll, form,
|
|
50
|
+
checklist, notes) and HTML pages are the next releases, and they are the first callers.
|
|
51
|
+
|
|
52
|
+
- `state:` in front matter: `writers` (`signed-in`, default, or `anyone`), `visibility`
|
|
53
|
+
(`private`, default, `tally` or `shared`), and `slots` of shape `one` (one value per reader,
|
|
54
|
+
replaced) or `many` (an append-only list per reader). A write to an undeclared slot is refused.
|
|
55
|
+
- Routes: `GET`/`POST /api/artifacts/<id>/state` for the reader, `GET`/`DELETE
|
|
56
|
+
/api/artifacts/<id>/responses` for the publisher (publish key; JSON or `?format=csv`).
|
|
57
|
+
- A reader never receives an email or another reader's key. Shared `one` entries carry an
|
|
58
|
+
opaque id. Tallies count signed-in and anonymous answers apart, because anyone can answer
|
|
59
|
+
again by clearing a cookie. The publisher's CSV escapes cells a spreadsheet would run.
|
|
60
|
+
- Anonymous writers (on `writers: anyone` pages) get an HttpOnly `artifact_anon` cookie, limited
|
|
61
|
+
to 30 writes a minute per page per client; their answers move to them when they sign in, on
|
|
62
|
+
every page. A gated page (`access:`) always requires a signed-in reader the page is open to
|
|
63
|
+
who has accepted the agreement. A password page needs its unlock cookie, which is now also
|
|
64
|
+
set for the page's answer API.
|
|
65
|
+
- Limits: 8 KB per value, 16 KB per request, 200 `many` entries per reader per slot, 2000
|
|
66
|
+
answers per slot per page, the newest 100 shared entries returned. Cross-origin posts refused.
|
|
67
|
+
- Republishing keeps answers; changing a slot's shape is refused (against the previous file and
|
|
68
|
+
against stored answers). Adding and removing slots is allowed.
|
|
69
|
+
- **DETECTOR:** a page with `state:` published to a host without a state store gets
|
|
70
|
+
`warning: this host keeps no answers` in the publish response, and its readers get 501.
|
|
71
|
+
- **REMEDY (hosts):** pass `state: createStateStore(db, '<base>')` to `createArtifactRoutes`,
|
|
72
|
+
add `app/api/artifacts/[id]/state/route.ts` (GET, POST to `STATE_GET`/`STATE_POST`) and
|
|
73
|
+
`app/api/artifacts/[id]/responses/route.ts` (GET, DELETE to `RESPONSES`), set a Firestore TTL
|
|
74
|
+
policy on `expireAt` in `<base>StateRate`, and pass `clientIp` when not on Vercel. README,
|
|
75
|
+
"Reader answers".
|
|
76
|
+
- **Known, fixed next release:** a reader who unlocked a password page before this version holds
|
|
77
|
+
only the page's cookie and must reopen the `?key=` link before answering there.
|
|
78
|
+
- **Tests:** `state.test.ts`, `state-store.test.ts` (memory contract), `state-view.test.ts`,
|
|
79
|
+
`routes/state.test.tsx`, and the packed fixture's anonymous answer round trip (`npm run
|
|
80
|
+
test:packed`). The Firestore store has no emulator test; it is proven on a live host.
|
|
81
|
+
|
|
6
82
|
## 0.1.0 (2026-09-24)
|
|
7
83
|
|
|
8
84
|
**The site shell behind Freedom's `artifacts.<name>` pages, published as a package.** It was a
|
package/README.md
CHANGED
|
@@ -25,7 +25,8 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
25
25
|
| `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
|
|
26
26
|
| `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
|
-
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing |
|
|
28
|
+
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
29
|
+
| `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
|
|
29
30
|
| `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
|
|
30
31
|
| `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
|
|
31
32
|
|
|
@@ -179,6 +180,161 @@ reimplement it. Keep passes short-lived (five minutes is what the tests assume).
|
|
|
179
180
|
Without `signInOrigin`, a confidential page shows its door with no way through. It fails
|
|
180
181
|
closed, never open.
|
|
181
182
|
|
|
183
|
+
## Reader answers (`state:`)
|
|
184
|
+
|
|
185
|
+
A page can take answers from the people reading it: a vote, a reaction, a short response.
|
|
186
|
+
Declare it in front matter:
|
|
187
|
+
|
|
188
|
+
```yaml
|
|
189
|
+
state:
|
|
190
|
+
writers: anyone # or signed-in
|
|
191
|
+
visibility: tally # private | tally | shared
|
|
192
|
+
slots:
|
|
193
|
+
vote:
|
|
194
|
+
shape: one # one value per reader, overwritten by a later `set`
|
|
195
|
+
reactions:
|
|
196
|
+
shape: many # a growing list per reader, built by `append`
|
|
197
|
+
visibility: shared # per-slot override of the page default
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Four routes, each requiring `state: createStateStore(db, '<base>')` in the config or they answer
|
|
201
|
+
501:
|
|
202
|
+
|
|
203
|
+
- `GET /api/artifacts/<id>/state`: the reader's own answers plus what the page's visibility lets
|
|
204
|
+
them see (a tally, or every shared answer).
|
|
205
|
+
- `POST /api/artifacts/<id>/state`: `{ "slot": "vote", "op": "set" | "append" | "remove", "value": ..., "entry": "<id for remove>" }`.
|
|
206
|
+
`set` is for a `one` slot, `append` for a `many` slot; `remove` takes either.
|
|
207
|
+
- `GET /api/artifacts/<id>/responses` (publish key): every answer with who wrote it, whatever the
|
|
208
|
+
page's `visibility` says, including each row's `reader` key. `?format=csv` returns the same rows
|
|
209
|
+
as CSV (a `reader` column after `anonymous`), with formula-injection escaping on any value
|
|
210
|
+
starting `=`, `+`, `-` or `@`.
|
|
211
|
+
- `DELETE /api/artifacts/<id>/responses?reader=<key>` (publish key): erase one reader's answers,
|
|
212
|
+
by the `reader` key from the read above.
|
|
213
|
+
|
|
214
|
+
**Limits**: a value is capped at 8 KB and a request body at 16 KB (Vercel caps bodies at 4.5 MB
|
|
215
|
+
before this runs; on any other host, cap the body size at your proxy too). A `many` slot holds at
|
|
216
|
+
most 200 entries per reader, and a slot holds at most 2,000 entries per page across every reader:
|
|
217
|
+
past that a new answer is refused with 409, though a reader can still replace their own `one`
|
|
218
|
+
answer. A `shared` slot shows readers only its newest 100 entries; tallies count everything. A
|
|
219
|
+
`POST` whose `Origin` header names another site is refused with 403.
|
|
220
|
+
|
|
221
|
+
**The anonymous rate limit**: 30 writes per minute per client address, counted in the
|
|
222
|
+
`<base>StateRate` collection under an HMAC of the address (keyed by your reader secret when set).
|
|
223
|
+
Each counter carries an `expireAt` a day ahead: set a Firestore TTL policy on `expireAt` for that
|
|
224
|
+
collection, or the counters are kept forever. The address is the first hop of `x-forwarded-for`,
|
|
225
|
+
so without one (plain `next start` with no proxy in front) every client shares one bucket: pass
|
|
226
|
+
`clientIp` there. An IPv6 client can rotate addresses within its /64, so treat the limit as a
|
|
227
|
+
brake, not a wall. A `remove` is never counted.
|
|
228
|
+
|
|
229
|
+
**Who can write**: a page with `access:` only ever accepts its signed-in readers, whatever
|
|
230
|
+
`writers:` says, and only after they have accepted the page's agreement (403 until then). A page
|
|
231
|
+
with a password takes answers only from a browser that has unlocked it; opening the page with
|
|
232
|
+
`?key=` sets the unlock cookie for the page and for its state API. `writers: anyone` only takes effect on a page with no `access:`: an anonymous
|
|
233
|
+
writer gets an opaque id in an HttpOnly `artifact_anon` cookie, good for a year. A `shared` slot
|
|
234
|
+
shows a reader everyone else's answer, but an anonymous one only ever by that same opaque id,
|
|
235
|
+
never a name or email; a publisher's `/responses` read and the CSV always show everything.
|
|
236
|
+
|
|
237
|
+
**Signing in after writing anonymously**: the next `GET /api/artifacts/<id>/state` from a reader
|
|
238
|
+
who is now signed in moves that cookie's answers onto their account, once, and clears the cookie.
|
|
239
|
+
|
|
240
|
+
**Republishing refuses only a shape change**: a slot going from `one` to `many` or back is
|
|
241
|
+
refused, checked both against the previous `state:` in front matter and against the shapes already
|
|
242
|
+
sitting in stored answers, so a republish can never misread history. Adding and removing slots is
|
|
243
|
+
allowed and the answers are kept: a removed slot's answers stop showing to readers and still come
|
|
244
|
+
back in `/responses`, and they reappear if the slot does. Drop `state:` entirely to close the page
|
|
245
|
+
to new answers; existing ones stay. Publishing `state:` to a host with no state store succeeds,
|
|
246
|
+
with a `warning` in the response saying the answers have nowhere to go.
|
|
247
|
+
|
|
248
|
+
**Host wiring**:
|
|
249
|
+
|
|
250
|
+
```ts
|
|
251
|
+
export const artifacts = createArtifactRoutes({
|
|
252
|
+
store,
|
|
253
|
+
state: createStateStore(getFirestore(), 'artifacts'),
|
|
254
|
+
// ...the rest of your config
|
|
255
|
+
})
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
```ts
|
|
259
|
+
// app/api/artifacts/[id]/state/route.ts
|
|
260
|
+
import type { NextRequest } from 'next/server'
|
|
261
|
+
import { artifacts } from '@/lib/artifacts'
|
|
262
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
263
|
+
export function GET(request: NextRequest, ctx: Ctx) { return artifacts.STATE_GET(request, ctx) }
|
|
264
|
+
export function POST(request: NextRequest, ctx: Ctx) { return artifacts.STATE_POST(request, ctx) }
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// app/api/artifacts/[id]/responses/route.ts
|
|
269
|
+
import type { NextRequest } from 'next/server'
|
|
270
|
+
import { artifacts } from '@/lib/artifacts'
|
|
271
|
+
type Ctx = { params: Promise<{ id: string }> }
|
|
272
|
+
export function GET(request: NextRequest, ctx: Ctx) { return artifacts.RESPONSES(request, ctx) }
|
|
273
|
+
export function DELETE(request: NextRequest, ctx: Ctx) { return artifacts.RESPONSES(request, ctx) }
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
**Firestore indexes**: every `append` runs a count over `(artifactId, slot, readerKey)` on
|
|
277
|
+
`<base>State` for `MAX_MANY_PER_READER`, and every new answer runs a count over
|
|
278
|
+
`(artifactId, slot)` for the per-slot total. Firestore may serve both from its single-field
|
|
279
|
+
indexes. If it asks for a composite index instead, the first failure surfaces as a 500 on a
|
|
280
|
+
reader's write, with a create-index link in your host's logs; following it once is the whole step.
|
|
281
|
+
The cross-page move that runs on sign-in queries by `readerKey` alone. Consider a single-field
|
|
282
|
+
index exemption for the `json` field of `<base>State`: it holds whole answers as strings and is
|
|
283
|
+
never queried, so indexing it only costs writes and storage.
|
|
284
|
+
|
|
285
|
+
On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used for the anonymous
|
|
286
|
+
rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
|
|
287
|
+
the real client IP but another proxy may only append to.
|
|
288
|
+
|
|
289
|
+
## Widgets in a page
|
|
290
|
+
|
|
291
|
+
A widget is a fenced block in a page's markdown that gives readers a place to answer, drawn by
|
|
292
|
+
the shell in the page's brand and kept through the state API above. Widgets need a host with a
|
|
293
|
+
state store (`state:` in the config); on a host without one they draw nothing, and the publish
|
|
294
|
+
response carries the no-answers warning.
|
|
295
|
+
|
|
296
|
+
This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
|
|
297
|
+
`poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
|
|
298
|
+
block that turns into a live widget on a later update.
|
|
299
|
+
|
|
300
|
+
### Notes
|
|
301
|
+
|
|
302
|
+
````markdown
|
|
303
|
+
## Sales
|
|
304
|
+
|
|
305
|
+
The weekly pipeline review.
|
|
306
|
+
|
|
307
|
+
```notes
|
|
308
|
+
visibility: shared # optional: private | shared
|
|
309
|
+
```
|
|
310
|
+
````
|
|
311
|
+
|
|
312
|
+
- **A small "note" control sits beside every heading.** A reader opens it, writes, and saves. The
|
|
313
|
+
note appears under that heading; with `visibility: shared` every reader sees every note with
|
|
314
|
+
the writer's first name ("a reader" for a signed-out one), with `private` each reader sees only
|
|
315
|
+
their own and the publisher sees all through `/responses`.
|
|
316
|
+
- **At most one notes block per page.** Where it sits is where the page shows "Notes on earlier
|
|
317
|
+
versions" (below).
|
|
318
|
+
- **The block declares its own slot**: `notes`, shape `many`. A page needs no `state:` for it;
|
|
319
|
+
without one the page takes `writers: signed-in` and `visibility: private`. A page may declare
|
|
320
|
+
the slot itself (`slots: { notes: { shape: many } }`) and set `writers:` and `visibility:` as
|
|
321
|
+
usual. Refused at publish, naming the line in the file: a second notes block, a name after
|
|
322
|
+
`notes`, any key but `visibility`, `visibility: tally`, a `notes` slot declared `shape: one`,
|
|
323
|
+
and a block visibility that disagrees with one `state:` sets on the `notes` slot.
|
|
324
|
+
- **A note is `{ slug, heading, note }`**: the slug and the text of the heading it was left under,
|
|
325
|
+
and up to 4,000 characters of text. On a page with a notes block the server refuses any other
|
|
326
|
+
shape in the `notes` slot. Slugs follow GitHub's rule (lowercase, punctuation dropped, spaces
|
|
327
|
+
to dashes, a repeated heading `-1`, `-2`), and each heading carries its slug as its `id`, so
|
|
328
|
+
`#sales` links to it.
|
|
329
|
+
- **Notes survive a changed heading.** A note shows under the heading with its slug; failing
|
|
330
|
+
that, under a heading with exactly its text; otherwise under "Notes on earlier versions", with
|
|
331
|
+
the heading it was left under. It never attaches to a different section.
|
|
332
|
+
- **Narration skips it.** The fence is not prose, and everything the widget draws is marked
|
|
333
|
+
`data-nospeak`, so the narrator and the read-along highlighter read the page as before.
|
|
334
|
+
- On a gated page (`access:`) only signed-in readers the page is open to, after the agreement,
|
|
335
|
+
can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
|
|
336
|
+
page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
|
|
337
|
+
|
|
182
338
|
## Brand packs
|
|
183
339
|
|
|
184
340
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Access } from './reader.js';
|
|
2
|
+
import { type StateConfig } from './state.js';
|
|
2
3
|
export type ArtifactMeta = {
|
|
3
4
|
title: string;
|
|
4
5
|
summary: string;
|
|
@@ -23,6 +24,9 @@ export type ArtifactMeta = {
|
|
|
23
24
|
* ABSENT CHANGES NOTHING: the level is host-side state, so a republish that omits the line
|
|
24
25
|
* (or a publisher that strips unknown keys) can never reopen a confidential page. */
|
|
25
26
|
access?: Access | 'public';
|
|
27
|
+
/** What readers may put into the page. Content, like the body: a republish without it removes
|
|
28
|
+
* the slots from the page and KEEPS the answers already given. */
|
|
29
|
+
state?: StateConfig;
|
|
26
30
|
};
|
|
27
31
|
export declare function parseArtifactSource(text: string): {
|
|
28
32
|
ok: true;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
// Documented in README, "Front matter".
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
|
-
|
|
5
|
+
import { parseStateConfig } from './state.js';
|
|
6
|
+
import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
7
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
|
|
6
8
|
export function parseArtifactSource(text) {
|
|
7
9
|
const { data, content } = matter(text);
|
|
8
10
|
const d = data;
|
|
@@ -33,5 +35,23 @@ export function parseArtifactSource(text) {
|
|
|
33
35
|
return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
|
|
34
36
|
meta.access = d.access;
|
|
35
37
|
}
|
|
38
|
+
if (d.state !== undefined) {
|
|
39
|
+
const s = parseStateConfig(d.state);
|
|
40
|
+
if (!s.ok)
|
|
41
|
+
return { ok: false, error: s.error };
|
|
42
|
+
meta.state = s.state;
|
|
43
|
+
}
|
|
44
|
+
// Widgets declare their own slots, so a notes block is merged into state: here, before the
|
|
45
|
+
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
46
|
+
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
|
47
|
+
const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
|
|
48
|
+
const w = scanWidgets(content, offset);
|
|
49
|
+
if (!w.ok)
|
|
50
|
+
return { ok: false, error: w.error };
|
|
51
|
+
const merged = mergeWidgetState(meta.state, w.notes);
|
|
52
|
+
if (!merged.ok)
|
|
53
|
+
return { ok: false, error: merged.error };
|
|
54
|
+
if (merged.state)
|
|
55
|
+
meta.state = merged.state;
|
|
36
56
|
return { ok: true, meta, body: content };
|
|
37
57
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -7,3 +7,7 @@ export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets }
|
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
|
|
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
|
+
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -7,3 +7,7 @@ export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
|
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
|
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
|
+
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
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';
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export declare const NOTES_SLOT = "notes";
|
|
2
|
+
export declare const MAX_NOTE_CHARS = 4000;
|
|
3
|
+
export type Heading = {
|
|
4
|
+
depth: number;
|
|
5
|
+
line: number;
|
|
6
|
+
text: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
};
|
|
9
|
+
export type NoteValue = {
|
|
10
|
+
slug: string;
|
|
11
|
+
heading: string;
|
|
12
|
+
note: string;
|
|
13
|
+
};
|
|
14
|
+
/** A note as a reader's page holds it: the stored value plus who and when. */
|
|
15
|
+
export type PlacedNote = NoteValue & {
|
|
16
|
+
id: string;
|
|
17
|
+
name: string;
|
|
18
|
+
at: string;
|
|
19
|
+
mine: boolean;
|
|
20
|
+
};
|
|
21
|
+
/** Where each note shows: under the heading with its slug, else under a heading with its exact
|
|
22
|
+
* text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
|
|
23
|
+
* heading that merely looks close: a note on the wrong section is worse than one set aside. */
|
|
24
|
+
export declare function placeNotes(headings: Pick<Heading, 'slug' | 'text'>[], notes: PlacedNote[]): {
|
|
25
|
+
bySlug: Record<string, PlacedNote[]>;
|
|
26
|
+
earlier: PlacedNote[];
|
|
27
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Where each note on a page shows, and the few names both sides share. Kept free of the
|
|
2
|
+
// markdown parser on purpose: the notes widget runs in the reader's browser and imports this,
|
|
3
|
+
// and the parser has no business in that bundle.
|
|
4
|
+
export const NOTES_SLOT = 'notes';
|
|
5
|
+
export const MAX_NOTE_CHARS = 4000;
|
|
6
|
+
/** Where each note shows: under the heading with its slug, else under a heading with its exact
|
|
7
|
+
* text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
|
|
8
|
+
* heading that merely looks close: a note on the wrong section is worse than one set aside. */
|
|
9
|
+
export function placeNotes(headings, notes) {
|
|
10
|
+
const bySlug = {};
|
|
11
|
+
const earlier = [];
|
|
12
|
+
const slugs = new Set(headings.map((h) => h.slug));
|
|
13
|
+
const byText = new Map();
|
|
14
|
+
for (const h of headings)
|
|
15
|
+
if (!byText.has(h.text))
|
|
16
|
+
byText.set(h.text, h.slug);
|
|
17
|
+
for (const n of notes) {
|
|
18
|
+
const at = slugs.has(n.slug) ? n.slug : byText.get(n.heading);
|
|
19
|
+
if (at)
|
|
20
|
+
(bySlug[at] ??= []).push(n);
|
|
21
|
+
else
|
|
22
|
+
earlier.push(n);
|
|
23
|
+
}
|
|
24
|
+
return { bySlug, earlier };
|
|
25
|
+
}
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export declare function isReachableHref(href: unknown): href is string;
|
|
2
|
-
|
|
2
|
+
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
3
|
+
* page carries a ```notes block. */
|
|
4
|
+
export declare function ArtifactMarkdown({ markdown, notes }: {
|
|
3
5
|
markdown: string;
|
|
6
|
+
notes?: {
|
|
7
|
+
artifactId: string;
|
|
8
|
+
accent?: string;
|
|
9
|
+
};
|
|
4
10
|
}): import("react").JSX.Element;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import ReactMarkdown from 'react-markdown';
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
|
+
import { hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
|
+
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
4
6
|
const GOLD = '#C2A15C';
|
|
5
7
|
function hostOf(url) {
|
|
6
8
|
try {
|
|
@@ -61,6 +63,10 @@ const components = {
|
|
|
61
63
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
62
64
|
if (lang === 'links')
|
|
63
65
|
return _jsx(LinkCards, { source: textOf(children) });
|
|
66
|
+
// A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
|
|
67
|
+
// answers) it draws nothing: its settings are not prose and never shown as code.
|
|
68
|
+
if (lang === 'notes')
|
|
69
|
+
return null;
|
|
64
70
|
if (!className) {
|
|
65
71
|
return _jsx("code", { className: "rounded bg-white/10 px-1.5 py-0.5 text-[0.9em] text-zinc-100", children: children });
|
|
66
72
|
}
|
|
@@ -70,7 +76,7 @@ const components = {
|
|
|
70
76
|
// A links fence renders its own block; do not wrap it in <pre>.
|
|
71
77
|
const inner = Array.isArray(children) ? children[0] : children;
|
|
72
78
|
const cls = inner?.props?.className ?? '';
|
|
73
|
-
if (/language-links/.test(cls))
|
|
79
|
+
if (/language-(links|notes)/.test(cls))
|
|
74
80
|
return _jsx(_Fragment, { children: children });
|
|
75
81
|
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-4 text-sm text-zinc-100", children: children }));
|
|
76
82
|
},
|
|
@@ -85,6 +91,38 @@ const components = {
|
|
|
85
91
|
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'rgba(255,255,255,0.03)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-zinc-100", children: body })] }));
|
|
86
92
|
},
|
|
87
93
|
};
|
|
88
|
-
|
|
89
|
-
|
|
94
|
+
const HEADING_CLASS = {
|
|
95
|
+
1: 'mt-10 mb-4 font-serif text-3xl text-zinc-50',
|
|
96
|
+
2: 'mt-10 mb-3 font-serif text-2xl text-zinc-50',
|
|
97
|
+
3: 'mt-8 mb-2 text-lg font-semibold text-zinc-100',
|
|
98
|
+
4: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
99
|
+
5: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
100
|
+
6: 'mt-6 mb-2 font-semibold text-zinc-100',
|
|
101
|
+
};
|
|
102
|
+
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
103
|
+
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
104
|
+
* one headingsOf derived, the same one a note stores. */
|
|
105
|
+
function notesComponents(headings) {
|
|
106
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
107
|
+
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
108
|
+
const Tag = `h${depth}`;
|
|
109
|
+
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
110
|
+
if (!h)
|
|
111
|
+
return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
|
|
112
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
|
|
113
|
+
};
|
|
114
|
+
const Code = components.code;
|
|
115
|
+
return {
|
|
116
|
+
...components,
|
|
117
|
+
h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6),
|
|
118
|
+
code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
122
|
+
* page carries a ```notes block. */
|
|
123
|
+
export function ArtifactMarkdown({ markdown, notes }) {
|
|
124
|
+
const on = !!notes && hasNotesWidget(markdown);
|
|
125
|
+
const headings = on ? headingsOf(markdown) : [];
|
|
126
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : components, children: markdown }));
|
|
127
|
+
return (_jsx("div", { className: "text-[17px] text-zinc-200", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
|
|
90
128
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import { type Shape } from './state.js';
|
|
3
|
+
export type Writer = {
|
|
4
|
+
key: string;
|
|
5
|
+
uid?: string;
|
|
6
|
+
email?: string;
|
|
7
|
+
name: string | null;
|
|
8
|
+
anonymous: boolean;
|
|
9
|
+
};
|
|
10
|
+
export type StateEntry = {
|
|
11
|
+
id: string;
|
|
12
|
+
artifactId: string;
|
|
13
|
+
slot: string;
|
|
14
|
+
shape: Shape;
|
|
15
|
+
readerKey: string;
|
|
16
|
+
writer: Omit<Writer, 'key'>;
|
|
17
|
+
value: unknown;
|
|
18
|
+
at: string;
|
|
19
|
+
};
|
|
20
|
+
export interface StateStore {
|
|
21
|
+
entries(artifactId: string): Promise<StateEntry[]>;
|
|
22
|
+
set(input: {
|
|
23
|
+
artifactId: string;
|
|
24
|
+
slot: string;
|
|
25
|
+
writer: Writer;
|
|
26
|
+
value: unknown;
|
|
27
|
+
}): Promise<StateEntry>;
|
|
28
|
+
append(input: {
|
|
29
|
+
artifactId: string;
|
|
30
|
+
slot: string;
|
|
31
|
+
writer: Writer;
|
|
32
|
+
value: unknown;
|
|
33
|
+
}): Promise<StateEntry | {
|
|
34
|
+
full: true;
|
|
35
|
+
}>;
|
|
36
|
+
remove(input: {
|
|
37
|
+
artifactId: string;
|
|
38
|
+
slot: string;
|
|
39
|
+
readerKey: string;
|
|
40
|
+
entryId?: string;
|
|
41
|
+
}): Promise<number>;
|
|
42
|
+
removeReader(artifactId: string, readerKey: string): Promise<number>;
|
|
43
|
+
/** Moves every answer on every page from one reader key to another, not just the page the
|
|
44
|
+
* reader signed in from: an anonymous reader who answered several pages before signing in
|
|
45
|
+
* must not have the rest stranded under the cookie that is about to be cleared. */
|
|
46
|
+
moveReader(fromKey: string, to: Writer): Promise<number>;
|
|
47
|
+
/** Records one anonymous write and returns the count for that minute. The Firestore store
|
|
48
|
+
* stamps each counter with `expireAt` a day ahead, for a TTL policy to sweep. */
|
|
49
|
+
countAnonWrite(artifactId: string, ipHash: string, minute: number): Promise<number>;
|
|
50
|
+
/** Every reader's entries in one slot on one page, for MAX_ENTRIES_PER_SLOT. */
|
|
51
|
+
countSlot(artifactId: string, slot: string): Promise<number>;
|
|
52
|
+
/** Does this reader already hold a `one` answer in this slot? One document read. Optional so a
|
|
53
|
+
* host's own store keeps working; without it the route falls back to loading the page. */
|
|
54
|
+
hasOne?(artifactId: string, slot: string, readerKey: string): Promise<boolean>;
|
|
55
|
+
}
|
|
56
|
+
export declare const readerKeyFor: {
|
|
57
|
+
signedIn: (uid: string) => string;
|
|
58
|
+
anonymous: (id: string) => string;
|
|
59
|
+
};
|
|
60
|
+
export declare function createStateStore(db: Firestore, base: string): StateStore;
|
|
61
|
+
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
62
|
+
export declare function createMemoryStateStore(): StateStore;
|