@supersuit/artifacts 0.5.0 → 0.7.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 +56 -0
- package/README.md +25 -0
- package/lib/artifacts/front-matter.d.ts +3 -0
- package/lib/artifacts/front-matter.js +7 -1
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/reader.js +3 -2
- package/lib/artifacts/store.d.ts +38 -5
- package/lib/artifacts/store.js +97 -5
- package/lib/artifacts/updated-at.d.ts +1 -0
- package/lib/artifacts/updated-at.js +11 -0
- package/lib/reader/updated-time.d.ts +4 -0
- package/lib/reader/updated-time.js +12 -0
- package/lib/reader/version-history.d.ts +14 -0
- package/lib/reader/version-history.js +97 -0
- package/lib/routes/artifacts.d.ts +20 -1
- package/lib/routes/artifacts.js +92 -12
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,62 @@
|
|
|
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.0 (2026-09-27)
|
|
7
|
+
|
|
8
|
+
**A real version history: every page names its version, lists every version with its change note,
|
|
9
|
+
and serves each past version at its own URL.**
|
|
10
|
+
|
|
11
|
+
- The line under the summary reads "Version N · Updated <minute> · History". History opens a
|
|
12
|
+
panel (`VersionHistory`, a client component) drawn through a portal on `document.body` like the
|
|
13
|
+
definition layer, so it never reflows the page: a bottom sheet above the narration bar on a
|
|
14
|
+
phone, a side panel on a desk. Each row is the version number, the exact minute in the reader's
|
|
15
|
+
zone, and the note. Authors were hand-writing "Version history" sections at the end of their
|
|
16
|
+
markdown; the store already kept every version, so the page now shows them.
|
|
17
|
+
- Past versions render read-only at `/<id>/v/<n>` (`VersionPage`, `generateVersionMetadata`) under
|
|
18
|
+
a banner, "You are reading version N of M", linking the current page. Same password and
|
|
19
|
+
confidential gate as the page, looked up only after the gate opens; no narration, no answers,
|
|
20
|
+
no view counted. The current number redirects to the page; anything else missing is a 404.
|
|
21
|
+
- A change note travels with each version: `?note=` on the publish POST or `change:` in the
|
|
22
|
+
front matter. None given, none stored. A note identical to the one it replaces is dropped as a
|
|
23
|
+
stale front matter line.
|
|
24
|
+
- `?amend=1` finishes the same publish, so a publisher's image and narration POSTs no longer mint
|
|
25
|
+
two or three versions per publish; a republish with an unchanged body and no note is not a new
|
|
26
|
+
version either. History docs now keep the title, subtitle and summary they were published with.
|
|
27
|
+
- New store methods `history`, `version`, `setNote` (optional on the interface); new route
|
|
28
|
+
`VERSIONS` (`GET` lists, `POST {version, note}` backfills a note); `safeReturnPath` accepts
|
|
29
|
+
`/<id>/v/<n>` so signing in from a past version returns to it.
|
|
30
|
+
- **DETECTOR:** a page whose line under the summary has no "Version N", or a markdown file ending
|
|
31
|
+
in a hand-written "Version history" section. A host with no `app/[id]/v/[n]/page.tsx` 404s
|
|
32
|
+
every History link.
|
|
33
|
+
- **REMEDY:** take 0.7.0, and add two route files: `app/[id]/v/[n]/page.tsx` (default
|
|
34
|
+
`VersionPage`, `generateMetadata` = `generateVersionMetadata`) and
|
|
35
|
+
`app/api/artifacts/[id]/versions/route.ts` (`GET`, `POST` = `VERSIONS`). Publishers pass
|
|
36
|
+
`note=` on the first POST and `amend=1` on the ones that follow it.
|
|
37
|
+
- **Tests:** `store.test.ts` (notes ride and move to history, none invented, stale note dropped,
|
|
38
|
+
unchanged body and amend make no version, listing without bodies, one version, legacy array,
|
|
39
|
+
backfill), `versions.test.tsx` (header line, banner, no narration or view, redirect, 404s, gate
|
|
40
|
+
parity for password and confidential pages case by case, no history leaked through a door,
|
|
41
|
+
sign-in returns to the version, the API, note and amend reach the store),
|
|
42
|
+
`version-history.test.tsx` (portal on body, no text change, rows and links, close paths),
|
|
43
|
+
`reader.test.ts` (return paths), `front-matter.test.ts` (`change:`). Mutation-checked: removing
|
|
44
|
+
the gate for versions fails four parity tests; dropping the stale-note rule and the current
|
|
45
|
+
redirect each fail their test.
|
|
46
|
+
|
|
47
|
+
## 0.6.0 (2026-09-27)
|
|
48
|
+
|
|
49
|
+
**"Updated" names the exact minute, in the reader's own time zone.**
|
|
50
|
+
|
|
51
|
+
- The line under an artifact's summary read "Updated September 27, 2026", so a page republished
|
|
52
|
+
three times in one evening looked the same each time. It now reads "Updated September 27, 2026
|
|
53
|
+
at 8:07 AM CDT": the server renders UTC, and a small client component, `UpdatedTime`, swaps in
|
|
54
|
+
the viewer's zone once the page runs. The value is a `<time dateTime>` carrying the ISO stamp.
|
|
55
|
+
- New `formatUpdated(iso, timeZone?)` in `src/artifacts/updated-at.ts`; a bad value renders
|
|
56
|
+
nothing rather than an invented time.
|
|
57
|
+
- DETECTOR: an artifact page whose "Updated" line has no clock time.
|
|
58
|
+
- REMEDY: take 0.6.0; nothing to configure.
|
|
59
|
+
- Tests: `updated-at.test.ts` (exact minute and zone, UTC default, bad input), verified red with
|
|
60
|
+
the minute removed.
|
|
61
|
+
|
|
6
62
|
## 0.5.0 (2026-09-27)
|
|
7
63
|
|
|
8
64
|
**Inline definitions: a page can define its jargon, and a reader sees it in line.**
|
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>¬e=<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(', ')}` };
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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';
|
package/lib/artifacts/reader.js
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
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
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -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. */
|
package/lib/artifacts/store.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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,
|
|
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 @@
|
|
|
1
|
+
export declare function formatUpdated(iso: string, timeZone?: string): string;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// The "Updated" line under an artifact's summary, to the exact minute with its zone named.
|
|
2
|
+
// The server renders UTC; the reader swaps in the viewer's own zone once it runs, so the minute
|
|
3
|
+
// is always one a person can place (Gary, 2026-09-27).
|
|
4
|
+
export function formatUpdated(iso, timeZone = 'UTC') {
|
|
5
|
+
const d = new Date(iso);
|
|
6
|
+
if (Number.isNaN(d.getTime()))
|
|
7
|
+
return '';
|
|
8
|
+
const date = d.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric', timeZone });
|
|
9
|
+
const time = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', timeZone, timeZoneName: 'short' });
|
|
10
|
+
return `${date} at ${time}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from 'react';
|
|
4
|
+
import { formatUpdated } from '../artifacts/updated-at.js';
|
|
5
|
+
/** The exact minute a page was last updated, in the reader's own time zone once the page runs. */
|
|
6
|
+
export function UpdatedTime({ iso }) {
|
|
7
|
+
const [text, setText] = useState(() => formatUpdated(iso));
|
|
8
|
+
useEffect(() => {
|
|
9
|
+
setText(formatUpdated(iso, Intl.DateTimeFormat().resolvedOptions().timeZone));
|
|
10
|
+
}, [iso]);
|
|
11
|
+
return (_jsx("time", { dateTime: iso, suppressHydrationWarning: true, children: text }));
|
|
12
|
+
}
|
|
@@ -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
|
|
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<{
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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';
|
|
@@ -16,6 +17,8 @@ import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
|
16
17
|
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';
|
|
20
|
+
import { UpdatedTime } from '../reader/updated-time.js';
|
|
21
|
+
import { VersionHistory } from '../reader/version-history.js';
|
|
19
22
|
import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
|
|
20
23
|
import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
|
|
21
24
|
import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
|
|
@@ -23,6 +26,8 @@ import { ARTIFACT_ID_RE } from './ids.js';
|
|
|
23
26
|
import { createStateRoutes } from './state-routes.js';
|
|
24
27
|
/** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
|
|
25
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}$/;
|
|
26
31
|
async function defaultReadCookie(name) {
|
|
27
32
|
const { cookies } = await import('next/headers');
|
|
28
33
|
return (await cookies()).get(name)?.value;
|
|
@@ -75,13 +80,34 @@ export function createArtifactRoutes(config) {
|
|
|
75
80
|
},
|
|
76
81
|
};
|
|
77
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
|
+
}
|
|
78
91
|
async function Page({ params, searchParams }) {
|
|
79
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) {
|
|
80
105
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
81
106
|
if (!a)
|
|
82
107
|
notFound();
|
|
108
|
+
const render = (top) => (n === null ? Body(a, { top }) : VersionBody(a, n, { top }));
|
|
83
109
|
if (a.access)
|
|
84
|
-
return GatedPage(a, id);
|
|
110
|
+
return GatedPage(a, id, render, n);
|
|
85
111
|
// A password shuts the body, never the title: the header stays so the reader knows which
|
|
86
112
|
// page they were sent, and the unfurl (generateMetadata) keeps reading as the page.
|
|
87
113
|
const sp = (await searchParams) ?? {};
|
|
@@ -106,12 +132,15 @@ export function createArtifactRoutes(config) {
|
|
|
106
132
|
if (!open) {
|
|
107
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 })] }));
|
|
108
134
|
}
|
|
109
|
-
|
|
110
|
-
|
|
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] }));
|
|
111
139
|
}
|
|
112
140
|
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
113
141
|
* everyone else gets the title, the summary, and a door. */
|
|
114
|
-
async function GatedPage(a, id) {
|
|
142
|
+
async function GatedPage(a, id, render, n) {
|
|
143
|
+
const here = n === null ? pageUrl(id) : `${pageUrl(id)}/v/${n}`;
|
|
115
144
|
const readCookie = config.readCookie ?? defaultReadCookie;
|
|
116
145
|
const reader = verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE));
|
|
117
146
|
const allow = config.readers && reader ? await config.readers.allowList(id) : [];
|
|
@@ -122,15 +151,42 @@ export function createArtifactRoutes(config) {
|
|
|
122
151
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
123
152
|
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
124
153
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
|
|
125
|
-
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin,
|
|
154
|
+
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined })] }));
|
|
126
155
|
}
|
|
127
156
|
const r = reader;
|
|
128
157
|
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
129
158
|
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
130
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) })] }));
|
|
131
160
|
}
|
|
132
|
-
|
|
133
|
-
|
|
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 })] })] }));
|
|
134
190
|
}
|
|
135
191
|
/** The page itself, shared by open and gated pages. */
|
|
136
192
|
async function Body(a, { top }) {
|
|
@@ -145,9 +201,8 @@ export function createArtifactRoutes(config) {
|
|
|
145
201
|
words = [];
|
|
146
202
|
}
|
|
147
203
|
}
|
|
148
|
-
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
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 }),
|
|
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
|
-
|
|
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));
|
|
@@ -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.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|