@supersuit/artifacts 0.6.0 → 0.7.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 CHANGED
@@ -3,6 +3,61 @@
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.7.1 (2026-09-27)
7
+
8
+ **The narrator reads the subtitle.** `narrationText` built the spoken text from the title, the
9
+ summary and the body, while the page renders a `subtitle:` between the title and the summary with
10
+ no `data-nospeak`. So the audio skipped the page's first line under the title, and the highlighter
11
+ expected words the narration never said.
12
+
13
+ - `narrationText` takes an optional `subtitle` and reads it second; the narration-text route
14
+ passes it.
15
+ - DETECTOR: a page with a `subtitle:` whose audio goes straight from the title to the summary.
16
+ - REMEDY: bump, then republish the page so its narration is regenerated (the spoken text changed,
17
+ so the narration hash no longer matches and the publisher re-narrates).
18
+ - Tests: `narration.test.ts` pins title, subtitle, summary, body order.
19
+
20
+ ## 0.7.0 (2026-09-27)
21
+
22
+ **A real version history: every page names its version, lists every version with its change note,
23
+ and serves each past version at its own URL.**
24
+
25
+ - The line under the summary reads "Version N · Updated <minute> · History". History opens a
26
+ panel (`VersionHistory`, a client component) drawn through a portal on `document.body` like the
27
+ definition layer, so it never reflows the page: a bottom sheet above the narration bar on a
28
+ phone, a side panel on a desk. Each row is the version number, the exact minute in the reader's
29
+ zone, and the note. Authors were hand-writing "Version history" sections at the end of their
30
+ markdown; the store already kept every version, so the page now shows them.
31
+ - Past versions render read-only at `/<id>/v/<n>` (`VersionPage`, `generateVersionMetadata`) under
32
+ a banner, "You are reading version N of M", linking the current page. Same password and
33
+ confidential gate as the page, looked up only after the gate opens; no narration, no answers,
34
+ no view counted. The current number redirects to the page; anything else missing is a 404.
35
+ - A change note travels with each version: `?note=` on the publish POST or `change:` in the
36
+ front matter. None given, none stored. A note identical to the one it replaces is dropped as a
37
+ stale front matter line.
38
+ - `?amend=1` finishes the same publish, so a publisher's image and narration POSTs no longer mint
39
+ two or three versions per publish; a republish with an unchanged body and no note is not a new
40
+ version either. History docs now keep the title, subtitle and summary they were published with.
41
+ - New store methods `history`, `version`, `setNote` (optional on the interface); new route
42
+ `VERSIONS` (`GET` lists, `POST {version, note}` backfills a note); `safeReturnPath` accepts
43
+ `/<id>/v/<n>` so signing in from a past version returns to it.
44
+ - **DETECTOR:** a page whose line under the summary has no "Version N", or a markdown file ending
45
+ in a hand-written "Version history" section. A host with no `app/[id]/v/[n]/page.tsx` 404s
46
+ every History link.
47
+ - **REMEDY:** take 0.7.0, and add two route files: `app/[id]/v/[n]/page.tsx` (default
48
+ `VersionPage`, `generateMetadata` = `generateVersionMetadata`) and
49
+ `app/api/artifacts/[id]/versions/route.ts` (`GET`, `POST` = `VERSIONS`). Publishers pass
50
+ `note=` on the first POST and `amend=1` on the ones that follow it.
51
+ - **Tests:** `store.test.ts` (notes ride and move to history, none invented, stale note dropped,
52
+ unchanged body and amend make no version, listing without bodies, one version, legacy array,
53
+ backfill), `versions.test.tsx` (header line, banner, no narration or view, redirect, 404s, gate
54
+ parity for password and confidential pages case by case, no history leaked through a door,
55
+ sign-in returns to the version, the API, note and amend reach the store),
56
+ `version-history.test.tsx` (portal on body, no text change, rows and links, close paths),
57
+ `reader.test.ts` (return paths), `front-matter.test.ts` (`change:`). Mutation-checked: removing
58
+ the gate for versions fails four parity tests; dropping the stale-note rule and the current
59
+ redirect each fail their test.
60
+
6
61
  ## 0.6.0 (2026-09-27)
7
62
 
8
63
  **"Updated" names the exact minute, in the reader's own time zone.**
package/README.md CHANGED
@@ -68,12 +68,14 @@ The rest, each exporting the named handler(s) from `artifacts`:
68
68
 
69
69
  | File | Handlers |
70
70
  |---|---|
71
+ | `app/[id]/v/[n]/page.tsx` | `default` = `VersionPage`, `generateMetadata` = `generateVersionMetadata` |
71
72
  | `app/[id]/share.png/route.tsx` | `GET` = `SHARE_IMAGE` |
72
73
  | `app/api/artifacts/route.ts` | `POST` (publish) |
73
74
  | `app/api/artifacts/[id]/route.ts` | `GET`, `DELETE` |
74
75
  | `app/api/artifacts/[id]/assets/[name]/route.ts` | `PUT` = `PUT_ASSET` |
75
76
  | `app/api/artifacts/[id]/access/route.ts` | `GET`, `POST` = `ACCESS` |
76
77
  | `app/api/artifacts/[id]/reads/route.ts` | `GET` = `READS` |
78
+ | `app/api/artifacts/[id]/versions/route.ts` | `GET`, `POST` = `VERSIONS` |
77
79
  | `app/api/reader/enter/route.ts` | `GET` = `ENTER` |
78
80
  | `app/api/reader/leave/route.ts` | `GET` = `LEAVE` |
79
81
  | `app/api/reader/ack/route.ts` | `POST` = `ACK` |
@@ -109,6 +111,28 @@ hostname and pick by the `host` header.
109
111
  body. The response carries the page's id and URL. Posting a file whose front matter carries
110
112
  that `id:` republishes it in place, keeping every earlier version.
111
113
 
114
+ ## Version history
115
+
116
+ Every page names its version under the summary, "Version 4 · Updated <minute> · History", and
117
+ History opens a panel listing every version newest first: its number, the minute it went live in
118
+ the reader's own time zone, and its change note. The panel is drawn over the page (a sheet on a
119
+ phone, a side panel on a desk), so opening it moves no text. Each past version is readable at
120
+ `/<id>/v/<n>`, read-only, under a banner naming it and linking the current page, behind exactly
121
+ the same password or confidential door as the page itself. A version that does not exist is a
122
+ 404; on a shut page the door comes first, so it never says which versions exist.
123
+
124
+ **Do not write a "Version history" section in the markdown.** Say what changed when you publish:
125
+
126
+ - `POST /api/artifacts?id=<id>&note=<one line>`, or a `change:` line in the front matter. The query
127
+ wins. A note is one line, 280 characters at most.
128
+ - No note records the version with none. A `change:` still in the file from the previous publish
129
+ is recognised as stale and not repeated on the next version.
130
+ - `&amend=1` finishes the publish the previous POST started (a publisher's second and third POSTs,
131
+ carrying uploaded image URLs and then narration) instead of starting a new version. A republish
132
+ whose body is unchanged and carries no note is not a new version either.
133
+ - `GET /api/artifacts/<id>/versions` lists the history; `POST` it `{ "version": 2, "note": "..." }`
134
+ (or `"note": null`) to write a note onto a version after the fact.
135
+
112
136
  ## Front matter
113
137
 
114
138
  ```markdown
@@ -133,6 +157,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
133
157
  | `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
134
158
  | `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
135
159
  | `definitions` | Terms defined inline (below). A republish without it removes them |
160
+ | `change` | One line saying what this version changed (see Version history) |
136
161
  | `template` | `document`, the only one so far |
137
162
 
138
163
  An unknown key is refused, so a typo fails loudly instead of being ignored.
@@ -36,6 +36,9 @@ export type ArtifactMeta = {
36
36
  /** Terms this page defines inline: the first occurrence of each is underlined and opens its
37
37
  * definition. Content, like the body: a republish without it removes them. */
38
38
  definitions?: Definition[];
39
+ /** One line saying what changed in this version. Stored with the version it arrives on; a line
40
+ * left in the file from the previous publish is recognised and not repeated. */
41
+ change?: string;
39
42
  };
40
43
  export declare function parseArtifactSource(text: string): {
41
44
  ok: true;
@@ -6,7 +6,7 @@ import { parseStateConfig } from './state.js';
6
6
  import { mergeWidgetState, scanWidgets } from './widgets.js';
7
7
  import { THEME_MODES } from '../brand/theme.js';
8
8
  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']);
9
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change']);
10
10
  export function parseArtifactSource(text) {
11
11
  const { data, content } = matter(text);
12
12
  const d = data;
@@ -32,6 +32,12 @@ export function parseArtifactSource(text) {
32
32
  if (typeof v === 'string' && v)
33
33
  meta[k] = v;
34
34
  }
35
+ if (d.change !== undefined && d.change !== null) {
36
+ if (typeof d.change !== 'string')
37
+ return { ok: false, error: 'change must be one line of text' };
38
+ if (d.change.trim())
39
+ meta.change = d.change.replace(/\s+/g, ' ').trim();
40
+ }
35
41
  if (d.access !== undefined) {
36
42
  if (d.access !== 'public' && !ACCESS_LEVELS.includes(d.access))
37
43
  return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
@@ -1,7 +1,7 @@
1
1
  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
- export { createArtifactStore, newArtifactId, type ArtifactStore, type ArtifactRecord, type SaveResult } from './store.js';
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
5
  export { isPublishAuthed } from './auth.js';
6
6
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
@@ -1,7 +1,7 @@
1
1
  export { parseArtifactSource } from './front-matter.js';
2
2
  export { ArtifactMarkdown } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
- export { createArtifactStore, newArtifactId } from './store.js';
4
+ export { createArtifactStore, newArtifactId, cleanNote, MAX_CHANGE_NOTE_CHARS } from './store.js';
5
5
  export { isPublishAuthed } from './auth.js';
6
6
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
7
7
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
@@ -1,6 +1,7 @@
1
1
  export declare function narrationTextFromMarkdown(markdown: string): string;
2
2
  export declare function narrationText(input: {
3
3
  title: string;
4
+ subtitle?: string;
4
5
  summary: string;
5
6
  markdown: string;
6
7
  }): string;
@@ -76,8 +76,12 @@ export function narrationTextFromMarkdown(markdown) {
76
76
  blocks(tree.children, out);
77
77
  return clean(out);
78
78
  }
79
+ // The subtitle renders between the title and the summary with no data-nospeak, so the
80
+ // highlighter expects it there; leaving it out made the narrator skip the page's first line.
79
81
  export function narrationText(input) {
80
- return [input.title.trim(), input.summary.trim(), narrationTextFromMarkdown(input.markdown)].filter(Boolean).join('\n');
82
+ return [input.title.trim(), (input.subtitle ?? '').trim(), input.summary.trim(), narrationTextFromMarkdown(input.markdown)]
83
+ .filter(Boolean)
84
+ .join('\n');
81
85
  }
82
86
  /** Tokens the highlighter and the timings agree on: whitespace-split, lowercased, letters and digits only. */
83
87
  export function normalizeWord(w) {
@@ -104,6 +104,7 @@ export function signInUrl(signInOrigin, pageUrl) {
104
104
  export function safeReturnPath(to, prefix = '/') {
105
105
  if (typeof to !== 'string')
106
106
  return null;
107
- const m = new RegExp(`^${prefix.replace(/\//g, '\\/')}([abcdefghjkmnpqrstuvwxyz23456789]{8})$`).exec(to);
108
- return m ? `${prefix}${m[1]}` : null;
107
+ // One page, or one past version of it (/<id>/v/<n>): nothing else on this host, nothing off it.
108
+ const m = new RegExp(`^${prefix.replace(/\//g, '\\/')}([abcdefghjkmnpqrstuvwxyz23456789]{8})(\\/v\\/[1-9]\\d{0,5})?$`).exec(to);
109
+ return m ? `${prefix}${m[1]}${m[2] ?? ''}` : null;
109
110
  }
@@ -32,8 +32,29 @@ export type ArtifactRecord = {
32
32
  markdown: string;
33
33
  at: string;
34
34
  }[];
35
+ /** The one-line change note the current version was published with. Absent when none was
36
+ * given: a version is never described by anything its author did not write. */
37
+ note?: string;
35
38
  views: number;
36
39
  };
40
+ /** One line of a page's history. `at` is when that version went live. */
41
+ export type VersionEntry = {
42
+ version: number;
43
+ at: string;
44
+ note?: string;
45
+ current?: true;
46
+ };
47
+ /** One version, readable: its body and the title it carried (absent on versions filed before
48
+ * 0.7.0, which kept only the body; the page's current title stands in). */
49
+ export type VersionRecord = VersionEntry & {
50
+ markdown: string;
51
+ title?: string;
52
+ subtitle?: string;
53
+ summary?: string;
54
+ };
55
+ /** A change note is one line: whitespace collapsed, capped, empty means none. */
56
+ export declare const MAX_CHANGE_NOTE_CHARS = 280;
57
+ export declare function cleanNote(note: unknown): string | undefined;
37
58
  export declare function newArtifactId(): string;
38
59
  export type SaveResult = {
39
60
  id: string;
@@ -42,17 +63,29 @@ export type SaveResult = {
42
63
  } | {
43
64
  notFound: true;
44
65
  };
66
+ export type SaveInput = {
67
+ id?: string;
68
+ meta: ArtifactMeta;
69
+ markdown: string;
70
+ /** What changed in this version, in the author's words. Falls back to the front matter's `change:`. */
71
+ note?: string;
72
+ /** Finish the SAME publish rather than start a new version: the publisher's second and third
73
+ * POSTs (uploaded image URLs, then narration) amend the version its first POST created. */
74
+ amend?: boolean;
75
+ };
45
76
  export interface ArtifactStore {
46
77
  get(id: string): Promise<ArtifactRecord | null>;
47
- save(input: {
48
- id?: string;
49
- meta: ArtifactMeta;
50
- markdown: string;
51
- }): Promise<SaveResult>;
78
+ save(input: SaveInput): Promise<SaveResult>;
52
79
  delete(id: string): Promise<boolean>;
53
80
  bumpViews(id: string): Promise<void>;
54
81
  /** Set who may read a page without republishing it; 'public' opens it. False if no such page. */
55
82
  setAccess?(id: string, access: Access | 'public'): Promise<boolean>;
83
+ /** Every version, newest first, current included, without bodies. Null when no such page. */
84
+ history?(id: string): Promise<VersionEntry[] | null>;
85
+ /** One version by number, current included. Null when the page or the version does not exist. */
86
+ version?(id: string, n: number): Promise<VersionRecord | null>;
87
+ /** Write (or, with null, clear) the change note on one version after the fact. */
88
+ setNote?(id: string, n: number, note: string | null): Promise<boolean>;
56
89
  }
57
90
  /** The Firestore-backed store. The instance hands in its own Firestore, so the shell
58
91
  * never knows which project it is writing to; a hosted tenant passes a namespaced one. */
@@ -9,6 +9,14 @@
9
9
  // first save of a page still carrying the array moves it into the subcollection.
10
10
  import { randomInt } from 'node:crypto';
11
11
  import { FieldValue } from 'firebase-admin/firestore';
12
+ /** A change note is one line: whitespace collapsed, capped, empty means none. */
13
+ export const MAX_CHANGE_NOTE_CHARS = 280;
14
+ export function cleanNote(note) {
15
+ if (typeof note !== 'string')
16
+ return undefined;
17
+ const one = note.replace(/\s+/g, ' ').trim();
18
+ return one ? one.slice(0, MAX_CHANGE_NOTE_CHARS) : undefined;
19
+ }
12
20
  // No 0/o, 1/l/i: an id read aloud or retyped from a screenshot must survive it.
13
21
  const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
14
22
  export function newArtifactId() {
@@ -33,8 +41,74 @@ export function createArtifactStore(db, collection = 'artifacts') {
33
41
  await ref.update({ access: access === 'public' ? FieldValue.delete() : access });
34
42
  return true;
35
43
  },
44
+ history: (id) => historyOf(col, id),
45
+ version: (id, n) => versionOf(col, id, n),
46
+ setNote: (id, n, note) => setNote(col, id, n, note),
36
47
  };
37
48
  }
49
+ const vid = (n) => String(n).padStart(6, '0');
50
+ const currentOf = (a) => a.version ?? (a.versions?.length ?? 0) + 1;
51
+ async function historyOf(col, id) {
52
+ const a = await getArtifact(col, id);
53
+ if (!a)
54
+ return null;
55
+ const current = { version: currentOf(a), at: a.updatedAt, ...(a.note ? { note: a.note } : {}), current: true };
56
+ let past;
57
+ if (a.versions) {
58
+ past = a.versions.map((v, i) => ({ version: i + 1, at: v.at }));
59
+ }
60
+ else {
61
+ // `select` keeps the bodies out: a 55 KB page republished 40 times is 2 MB of history.
62
+ const snap = await col().doc(id).collection('versions').select('version', 'at', 'note').get();
63
+ past = snap.docs.map((d) => {
64
+ const v = d.data();
65
+ return { version: v.version, at: v.at, ...(v.note ? { note: v.note } : {}) };
66
+ });
67
+ }
68
+ return [current, ...past.filter((v) => v.version < current.version).sort((x, y) => y.version - x.version)];
69
+ }
70
+ async function versionOf(col, id, n) {
71
+ if (!Number.isInteger(n) || n < 1)
72
+ return null;
73
+ const a = await getArtifact(col, id);
74
+ if (!a)
75
+ return null;
76
+ const cur = currentOf(a);
77
+ if (n === cur) {
78
+ return { version: n, at: a.updatedAt, markdown: a.markdown, title: a.title, summary: a.summary, current: true,
79
+ ...(a.subtitle ? { subtitle: a.subtitle } : {}), ...(a.note ? { note: a.note } : {}) };
80
+ }
81
+ if (n > cur)
82
+ return null;
83
+ if (a.versions) {
84
+ const v = a.versions[n - 1];
85
+ return v ? { version: n, at: v.at, markdown: v.markdown } : null;
86
+ }
87
+ const snap = await col().doc(id).collection('versions').doc(vid(n)).get();
88
+ if (!snap.exists)
89
+ return null;
90
+ const v = snap.data();
91
+ return { ...v, version: n };
92
+ }
93
+ async function setNote(col, id, n, note) {
94
+ const a = await getArtifact(col, id);
95
+ if (!a || !Number.isInteger(n) || n < 1)
96
+ return false;
97
+ const clean = note === null ? undefined : cleanNote(note);
98
+ const value = clean ?? FieldValue.delete();
99
+ if (n === currentOf(a)) {
100
+ await col().doc(id).update({ note: value });
101
+ return true;
102
+ }
103
+ // A legacy array is moved out on the next save; notes go on the moved docs, so move it first.
104
+ if (a.versions)
105
+ return false;
106
+ const ref = col().doc(id).collection('versions').doc(vid(n));
107
+ if (!(await ref.get()).exists)
108
+ return false;
109
+ await ref.update({ note: value });
110
+ return true;
111
+ }
38
112
  async function getArtifact(col, id) {
39
113
  const snap = await col().doc(id).get();
40
114
  if (!snap.exists)
@@ -43,6 +117,7 @@ async function getArtifact(col, id) {
43
117
  }
44
118
  async function saveArtifact(col, input) {
45
119
  const now = new Date().toISOString();
120
+ const note = cleanNote(input.note) ?? cleanNote(input.meta.change);
46
121
  const fields = {
47
122
  title: input.meta.title,
48
123
  summary: input.meta.summary,
@@ -69,12 +144,25 @@ async function saveArtifact(col, input) {
69
144
  const history = ref.collection('versions');
70
145
  const legacy = existing.versions ?? [];
71
146
  const current = existing.version ?? legacy.length + 1;
72
- const vid = (n) => String(n).padStart(6, '0');
73
- // Move any legacy array out first, then file the body being replaced under its own number.
147
+ // Move any legacy array out first, whatever else this save does.
74
148
  for (let i = 0; i < legacy.length; i++)
75
149
  await history.doc(vid(i + 1)).set({ version: i + 1, ...legacy[i] });
76
- await history.doc(vid(current)).set({ version: current, markdown: existing.markdown, at: existing.updatedAt });
77
- const next = current + 1;
150
+ // Three ways a save is NOT a new version: the publisher finishing the publish it started
151
+ // (amend), and a republish whose body is unchanged with nothing to say about it (narration
152
+ // attached by an older publisher, a metadata-only fix). Either way the version and its note
153
+ // stand, and only the fields move.
154
+ const same = input.markdown === existing.markdown && !note;
155
+ const fresh = !input.amend && !same;
156
+ // A note the file still carries from the version it replaces is a stale line, not news.
157
+ const nextNote = fresh ? (note && note !== existing.note ? note : undefined) : (note ?? existing.note);
158
+ if (fresh) {
159
+ await history.doc(vid(current)).set({
160
+ version: current, markdown: existing.markdown, at: existing.updatedAt,
161
+ title: existing.title, summary: existing.summary,
162
+ ...(existing.subtitle ? { subtitle: existing.subtitle } : {}), ...(existing.note ? { note: existing.note } : {}),
163
+ });
164
+ }
165
+ const next = fresh ? current + 1 : current;
78
166
  // An update merges, so a password the file no longer carries has to be cleared on purpose:
79
167
  // otherwise removing the line from the file would leave the door up on the live page.
80
168
  const password = input.meta.password ? {} : { password: FieldValue.delete() };
@@ -92,7 +180,10 @@ async function saveArtifact(col, input) {
92
180
  ...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
93
181
  };
94
182
  await ref.update({
95
- ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, updatedAt: now, version: next,
183
+ ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, version: next,
184
+ // `updatedAt` is when this version went live; finishing its own publish does not move it.
185
+ ...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
186
+ note: nextNote ?? FieldValue.delete(),
96
187
  ...(existing.versions ? { versions: FieldValue.delete() } : {}),
97
188
  });
98
189
  return { id: input.id, version: next, created: false };
@@ -105,6 +196,7 @@ async function saveArtifact(col, input) {
105
196
  createdAt: now,
106
197
  updatedAt: now,
107
198
  version: 1,
199
+ ...(note ? { note } : {}),
108
200
  views: 0,
109
201
  };
110
202
  await col().doc(id).set(rec);
@@ -0,0 +1,14 @@
1
+ export type HistoryItem = {
2
+ version: number;
3
+ at: string;
4
+ note?: string;
5
+ current?: boolean;
6
+ };
7
+ export declare function VersionHistory({ items, base, viewing }: {
8
+ /** Newest first, current included. */
9
+ items: HistoryItem[];
10
+ /** The current page's path, e.g. "/abc23456"; a past version lives at `${base}/v/<n>`. */
11
+ base: string;
12
+ /** The version this page shows. */
13
+ viewing: number;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,97 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The page's history, opened from the "Version N" line under the summary. The list renders
4
+ // through a portal on document.body, OUTSIDE the text flow, like the definition layer, so opening
5
+ // it cannot reflow a word of the page: on a phone it is a sheet pinned to the bottom, above the
6
+ // narration bar; on a desk it is a panel pinned to the right of the window. Escape, the close
7
+ // button, or a tap outside closes it.
8
+ //
9
+ // It exists so an author never hand-writes a "Version history" section at the end of the
10
+ // markdown again (2026-09-27): the store already keeps every version, and each one carries
11
+ // the one-line note it was published with.
12
+ import { useCallback, useEffect, useRef, useState } from 'react';
13
+ import { createPortal } from 'react-dom';
14
+ import { formatUpdated } from '../artifacts/updated-at.js';
15
+ const PHONE = '(max-width: 639px)';
16
+ const MARGIN = 16;
17
+ const VARS = ['--a-ground', '--a-ink', '--a-accent', '--a-line'];
18
+ export function VersionHistory({ items, base, viewing }) {
19
+ const [open, setOpen] = useState(false);
20
+ const [phone, setPhone] = useState(false);
21
+ const [bottom, setBottom] = useState(MARGIN);
22
+ const [vars, setVars] = useState({});
23
+ const [zone, setZone] = useState('UTC');
24
+ const button = useRef(null);
25
+ const panel = useRef(null);
26
+ const measure = useCallback(() => {
27
+ const el = button.current;
28
+ if (!el)
29
+ return;
30
+ const cs = getComputedStyle(el);
31
+ setVars({ ...Object.fromEntries(VARS.map((v) => [v, cs.getPropertyValue(v).trim()]).filter(([, x]) => x)), fontFamily: cs.fontFamily });
32
+ const isPhone = window.matchMedia ? window.matchMedia(PHONE).matches : window.innerWidth < 640;
33
+ setPhone(isPhone);
34
+ const bar = document.querySelector('[data-artifact-player]')?.getBoundingClientRect();
35
+ setBottom((bar && bar.top < window.innerHeight ? window.innerHeight - bar.top : 0) + MARGIN);
36
+ }, []);
37
+ const close = useCallback(() => {
38
+ setOpen(false);
39
+ button.current?.focus();
40
+ }, []);
41
+ useEffect(() => {
42
+ setZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
43
+ }, []);
44
+ useEffect(() => {
45
+ if (!open)
46
+ return;
47
+ measure();
48
+ panel.current?.focus();
49
+ const onKey = (e) => {
50
+ if (e.key === 'Escape')
51
+ close();
52
+ };
53
+ const onDown = (e) => {
54
+ const t = e.target;
55
+ if (panel.current?.contains(t) || button.current?.contains(t))
56
+ return;
57
+ setOpen(false);
58
+ };
59
+ document.addEventListener('keydown', onKey);
60
+ document.addEventListener('pointerdown', onDown);
61
+ window.addEventListener('resize', measure);
62
+ return () => {
63
+ document.removeEventListener('keydown', onKey);
64
+ document.removeEventListener('pointerdown', onDown);
65
+ window.removeEventListener('resize', measure);
66
+ };
67
+ }, [open, measure, close]);
68
+ const style = {
69
+ ...vars,
70
+ zIndex: 60,
71
+ boxSizing: 'border-box',
72
+ borderRadius: 12,
73
+ background: 'var(--a-ground, #fff)',
74
+ color: 'var(--a-ink, #111)',
75
+ border: '1px solid var(--a-line, rgba(127,127,127,0.3))',
76
+ boxShadow: '0 16px 40px rgba(0,0,0,0.3)',
77
+ fontSize: 15,
78
+ lineHeight: 1.45,
79
+ fontStyle: 'normal',
80
+ textAlign: 'left',
81
+ overflowY: 'auto',
82
+ overscrollBehavior: 'contain',
83
+ outline: 'none',
84
+ ...(phone
85
+ ? { position: 'fixed', left: MARGIN, right: MARGIN, bottom: `calc(${bottom}px + env(safe-area-inset-bottom, 0px))`, maxHeight: '65vh' }
86
+ : { position: 'fixed', top: 80, right: 24, width: 380, maxHeight: 'calc(100vh - 120px)' }),
87
+ };
88
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: button, type: "button", "data-version-history": true, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpen((o) => !o), style: { font: 'inherit', color: 'inherit', background: 'none', border: 0, padding: 0, cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: '0.2em' }, children: "History" }), _jsx("span", { hidden: true, "data-version-list": true, children: items.map((v) => (_jsx("a", { href: v.current ? base : `${base}/v/${v.version}`, children: `Version ${v.version}${v.note ? `: ${v.note}` : ''}` }, v.version))) }), open && typeof document !== 'undefined'
89
+ ? createPortal(_jsxs("div", { ref: panel, "data-version-panel": true, role: "dialog", "aria-label": "Version history", tabIndex: -1, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px 8px' }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 500, letterSpacing: '0.2em', textTransform: 'uppercase', opacity: 0.6 }, children: "Version history" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: close, style: { font: 'inherit', fontSize: 20, lineHeight: 1, color: 'inherit', background: 'none', border: 0, padding: 4, cursor: 'pointer', opacity: 0.6 }, children: "\u00D7" })] }), _jsx("ol", { style: { listStyle: 'none', margin: 0, padding: '0 8px 10px' }, children: items.map((v) => {
90
+ const here = v.version === viewing;
91
+ return (_jsx("li", { children: _jsxs("a", { href: v.current ? base : `${base}/v/${v.version}`, "aria-current": here ? 'page' : undefined, style: {
92
+ display: 'block', padding: '10px 10px', borderRadius: 8, color: 'inherit', textDecoration: 'none',
93
+ background: here ? 'color-mix(in srgb, var(--a-accent, #888) 14%, transparent)' : undefined,
94
+ }, children: [_jsxs("span", { style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }, children: [_jsx("strong", { style: { fontWeight: 600 }, children: `Version ${v.version}` }), v.current ? _jsx("span", { style: { fontSize: 12, color: 'var(--a-accent)' }, children: "current" }) : null, _jsx("time", { dateTime: v.at, style: { fontSize: 12, opacity: 0.6 }, children: formatUpdated(v.at, zone) })] }), v.note ? _jsx("span", { style: { display: 'block', marginTop: 2, fontSize: 14, opacity: 0.85 }, children: v.note }) : null] }) }, v.version));
95
+ }) })] }), document.body)
96
+ : null] }));
97
+ }
@@ -1,6 +1,6 @@
1
1
  import type { Metadata } from 'next';
2
2
  import { NextRequest, NextResponse } from 'next/server';
3
- import type { ArtifactStore } from '../artifacts/store.js';
3
+ import { type ArtifactStore, type VersionEntry } from '../artifacts/store.js';
4
4
  import type { StateStore } from '../artifacts/state-store.js';
5
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
@@ -51,6 +51,17 @@ type PageProps = Params & {
51
51
  key?: string | string[];
52
52
  }>;
53
53
  };
54
+ type VersionParams = {
55
+ params: Promise<{
56
+ id: string;
57
+ n: string;
58
+ }>;
59
+ };
60
+ type VersionProps = VersionParams & {
61
+ searchParams?: Promise<{
62
+ key?: string | string[];
63
+ }>;
64
+ };
54
65
  export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
55
66
  dynamic: "force-dynamic";
56
67
  maxDuration: number;
@@ -71,6 +82,14 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
71
82
  }) => Promise<NextResponse<unknown>>;
72
83
  Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
73
84
  generateMetadata: ({ params }: Params) => Promise<Metadata>;
85
+ VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
86
+ generateVersionMetadata: ({ params }: VersionParams) => Promise<Metadata>;
87
+ VERSIONS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
88
+ error: string;
89
+ }> | NextResponse<{
90
+ id: string;
91
+ versions: VersionEntry[] | null;
92
+ }>>;
74
93
  POST: (request: NextRequest) => Promise<NextResponse<{
75
94
  error: string;
76
95
  }> | NextResponse<{
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { revalidatePath } from 'next/cache';
3
- import { notFound } from 'next/navigation';
3
+ import { notFound, redirect } from 'next/navigation';
4
4
  import { NextResponse } from 'next/server';
5
5
  import { isPublishAuthed } from '../artifacts/auth.js';
6
6
  import { parseArtifactSource } from '../artifacts/front-matter.js';
@@ -8,6 +8,7 @@ import { narrationText } from '../artifacts/narration.js';
8
8
  import { ArtifactMarkdown } from '../artifacts/render.js';
9
9
  import { ArtifactDoor } from '../artifacts/door.js';
10
10
  import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
11
+ import { cleanNote } from '../artifacts/store.js';
11
12
  import { shapeChanges } from '../artifacts/state.js';
12
13
  import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
13
14
  import { BrandGround } from '../brand/wrapper.js';
@@ -17,6 +18,7 @@ import { renderShareCard } from '../brand/share-card.js';
17
18
  import { ArtifactReader } from '../reader/artifact-reader.js';
18
19
  import { ReaderWatch } from '../reader/reader-watch.js';
19
20
  import { UpdatedTime } from '../reader/updated-time.js';
21
+ import { VersionHistory } from '../reader/version-history.js';
20
22
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
21
23
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
22
24
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
@@ -24,6 +26,8 @@ import { ARTIFACT_ID_RE } from './ids.js';
24
26
  import { createStateRoutes } from './state-routes.js';
25
27
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
26
28
  export const ARTIFACT_ID = ARTIFACT_ID_RE;
29
+ /** A version number as it appears in a URL: no zero, no leading zero, so each version has one address. */
30
+ const VERSION_N = /^[1-9]\d{0,5}$/;
27
31
  async function defaultReadCookie(name) {
28
32
  const { cookies } = await import('next/headers');
29
33
  return (await cookies()).get(name)?.value;
@@ -76,13 +80,34 @@ export function createArtifactRoutes(config) {
76
80
  },
77
81
  };
78
82
  }
83
+ /** GET /<id>/v/<n>: the metadata of a past version. Never indexed, like every page. */
84
+ async function generateVersionMetadata({ params }) {
85
+ const { id, n } = await params;
86
+ const m = await generateMetadata({ params: Promise.resolve({ id }) });
87
+ if (!VERSION_N.test(n) || m.title === 'Not found')
88
+ return { title: 'Not found', robots: { index: false, follow: false } };
89
+ return { ...m, title: `${String(m.title)} (version ${n})`, alternates: { canonical: `${pagePath(id)}/v/${n}` } };
90
+ }
79
91
  async function Page({ params, searchParams }) {
80
92
  const { id } = await params;
93
+ return serve(id, searchParams, null);
94
+ }
95
+ /** /<id>/v/<n>: one past version, read-only, behind exactly the door the current page has. */
96
+ async function VersionPage({ params, searchParams }) {
97
+ const { id, n } = await params;
98
+ if (!VERSION_N.test(n))
99
+ notFound();
100
+ return serve(id, searchParams, Number(n));
101
+ }
102
+ /** The current page (n null) or one past version, through the same gate. The version is looked
103
+ * up only AFTER the gate opens, so a shut page never says which versions exist. */
104
+ async function serve(id, searchParams, n) {
81
105
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
82
106
  if (!a)
83
107
  notFound();
108
+ const render = (top) => (n === null ? Body(a, { top }) : VersionBody(a, n, { top }));
84
109
  if (a.access)
85
- return GatedPage(a, id);
110
+ return GatedPage(a, id, render, n);
86
111
  // A password shuts the body, never the title: the header stays so the reader knows which
87
112
  // page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
88
113
  const sp = (await searchParams) ?? {};
@@ -107,12 +132,15 @@ export function createArtifactRoutes(config) {
107
132
  if (!open) {
108
133
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
109
134
  }
110
- void store.bumpViews(id);
111
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, await Body(a, { top: true })] }));
135
+ const page = await render(true);
136
+ if (n === null)
137
+ void store.bumpViews(id);
138
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, page] }));
112
139
  }
113
140
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
114
141
  * everyone else gets the title, the summary, and a door. */
115
- async function GatedPage(a, id) {
142
+ async function GatedPage(a, id, render, n) {
143
+ const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
116
144
  const readCookie = config.readCookie ?? defaultReadCookie;
117
145
  const reader = verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE));
118
146
  const allow = config.readers && reader ? await config.readers.allowList(id) : [];
@@ -123,15 +151,42 @@ export function createArtifactRoutes(config) {
123
151
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
124
152
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
125
153
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
126
- : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
154
+ : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined })] }));
127
155
  }
128
156
  const r = reader;
129
157
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
130
158
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
131
159
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
132
160
  }
133
- void store.bumpViews(id);
134
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), await Body(a, { top: false }), _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
161
+ const page = await render(false);
162
+ if (n === null)
163
+ void store.bumpViews(id);
164
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
165
+ }
166
+ /** Every version of a page, newest first, or null when the store keeps none. A failed read hides
167
+ * the History control rather than failing the page. */
168
+ async function historyOf(id) {
169
+ if (!store.history)
170
+ return null;
171
+ return store.history(id).catch(() => null);
172
+ }
173
+ /** "Version N · Updated <minute> · History": the line under the summary. */
174
+ function VersionLine({ a, n, at, history }) {
175
+ return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
176
+ }
177
+ /** A past version: its own body and title, read-only (no narration, no answers), under a banner. */
178
+ async function VersionBody(a, n, { top }) {
179
+ const v = store.version ? await store.version(a.id, n) : null;
180
+ if (!v)
181
+ notFound();
182
+ if (v.current)
183
+ redirect(pagePath(a.id));
184
+ const history = await historyOf(a.id);
185
+ const of = history?.[0]?.version ?? n;
186
+ const title = v.title ?? a.title;
187
+ const summary = v.summary ?? a.summary;
188
+ const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
189
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
135
190
  }
136
191
  /** The page itself, shared by open and gated pages. */
137
192
  async function Body(a, { top }) {
@@ -147,7 +202,7 @@ export function createArtifactRoutes(config) {
147
202
  }
148
203
  }
149
204
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
150
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", _jsx(UpdatedTime, { iso: a.updatedAt })] })] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), 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] }));
205
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), 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] }));
151
206
  }
152
207
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
153
208
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
@@ -316,7 +371,11 @@ export function createArtifactRoutes(config) {
316
371
  return NextResponse.json({ error: dodged.join('; ') }, { status: 400 });
317
372
  }
318
373
  }
319
- const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body });
374
+ // What changed, in the author's words: `?note=` from the publisher, else the file's `change:`.
375
+ // `?amend=1` is the publisher finishing the publish it started (image URLs, then narration).
376
+ const note = cleanNote(request.nextUrl.searchParams.get('note')) ?? parsed.meta.change;
377
+ const amend = request.nextUrl.searchParams.get('amend') === '1';
378
+ const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
320
379
  if ('notFound' in result)
321
380
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
322
381
  revalidatePath(pagePath(result.id));
@@ -332,7 +391,7 @@ export function createArtifactRoutes(config) {
332
391
  const a = await store.get(id);
333
392
  if (!a)
334
393
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
335
- return NextResponse.json({ id, text: narrationText({ title: a.title, summary: a.summary, markdown: a.markdown }) });
394
+ return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
336
395
  }
337
396
  /** PUT /api/artifacts/<id>/assets/<name>: raw bytes in, public URL out. 16 MiB cap. */
338
397
  async function PUT_ASSET(request, { params }) {
@@ -403,6 +462,27 @@ export function createArtifactRoutes(config) {
403
462
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
404
463
  return renderShareCard(pack, a.title);
405
464
  }
465
+ /** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
466
+ * version after the fact. POST body: { version: number, note: string | null }. */
467
+ async function VERSIONS(request, { params }) {
468
+ if (!isPublishAuthed(request, config.publishKey()))
469
+ return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
470
+ const { id } = await params;
471
+ if (!store.history || !store.setNote)
472
+ return NextResponse.json({ error: 'this store keeps no history' }, { status: 501 });
473
+ const history = ARTIFACT_ID.test(id) ? await store.history(id) : null;
474
+ if (!history)
475
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
476
+ if (request.method === 'GET')
477
+ return NextResponse.json({ id, current: history[0]?.version, versions: history }, { headers: { 'cache-control': 'no-store' } });
478
+ const b = (await request.json().catch(() => null));
479
+ if (!b || !Number.isInteger(b.version) || (b.note !== null && typeof b.note !== 'string'))
480
+ return NextResponse.json({ error: 'body is { version: <number>, note: <one line, or null to clear> }' }, { status: 400 });
481
+ if (!(await store.setNote(id, b.version, b.note)))
482
+ return NextResponse.json({ error: `no version ${String(b.version)} of ${id}` }, { status: 404 });
483
+ revalidatePath(pagePath(id));
484
+ return NextResponse.json({ id, versions: await store.history(id) });
485
+ }
406
486
  async function DELETE(request, { params }) {
407
487
  if (!isPublishAuthed(request, config.publishKey()))
408
488
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -413,5 +493,5 @@ export function createArtifactRoutes(config) {
413
493
  revalidatePath(pagePath(id));
414
494
  return NextResponse.json({ deleted: true });
415
495
  }
416
- return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
496
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
417
497
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.6.0",
3
+ "version": "0.7.1",
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",