@supersuit/artifacts 0.46.1 → 0.48.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 CHANGED
@@ -3,6 +3,45 @@
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.48.0
7
+
8
+ **Unpublishing keeps the history.** `POST /api/artifacts?id=<id>&unpublish=1` takes a page off the
9
+ web and keeps everything that happened on it: the record, every version, every comment, every
10
+ file. Every reader-facing path (the page, its versions, its share card, comments, answers, per-reader
11
+ links) reads through a view of the store that does not see an unpublished page, so each answers
12
+ 404; the publisher's own routes (publish, the narration GET, uploads, DELETE) still reach it. A
13
+ republish to the id puts the page back at the same link. Until now the only way to take a page
14
+ down was DELETE, which destroys every version and every comment for good. DELETE is unchanged,
15
+ and is now the explicit "destroy" rather than the only "take down". The new store method is
16
+ `setUnpublished(id, at | null)`; the Firestore store has it, and a store without it answers 501.
17
+ On the publish route rather than a new one, so no host has a route file to add, and a host older
18
+ than this answers the empty body with a 400 and changes nothing.
19
+
20
+ - DETECTOR: a publisher that sends `?unpublish=1` and gets a 400 is talking to a host before 0.48.0.
21
+ - REMEDY: update the dependency. Nothing to configure.
22
+ - Tests: unpublish.test.tsx.
23
+
24
+ ## 0.47.0
25
+
26
+ **Figures and Helm scenes play inline, as two new embed fences.** A ```` ```figure ```` block
27
+ (`figure: <id>`, `caption:`, optional `height:`, default 560px) plays an interactive figure, and a
28
+ ```` ```helm ```` block (`scene: <id>`, `caption:`, optional `height:`, default 760px, optional
29
+ `autoplay: true`) plays a scene on a Helm phone, both in the same fitted, lazy frame and under the
30
+ same caption as `simulator` and `messages`. They are rows in the one table of embed kinds, which
31
+ now also carries per-kind options (helm's `autoplay`, passed to the embed page only when true), so
32
+ a fifth kind is one line. Publish refuses a block with no id or no caption, naming the line. Embed
33
+ blocks, the old two included, no longer take a comment anchor: the player and its caption were
34
+ never selectable text. The host names the pages with two new config fields, `figureEmbed` and
35
+ `helmEmbed`; there is no default. `ArtifactMarkdown` takes every kind's page as `embeds`
36
+ (`simulator` and `messages` still work).
37
+
38
+ - DETECTOR: a page carrying a ```` ```figure ```` or ```` ```helm ```` block. Before, it drew as a code block.
39
+ - REMEDY: update the dependency, and set `figureEmbed` / `helmEmbed` in `createArtifactRoutes` to
40
+ the host's figure and Helm embed pages. Without them the blocks show their caption only.
41
+ - Tests: simulator.test.ts ("the figure block", "the helm block", "every embed kind"),
42
+ render.test.tsx ("the figure and helm blocks", "an embed block is not read aloud"),
43
+ widgets.test.ts ("scanWidgets and the figure and helm blocks", "blocksOf and embed blocks").
44
+
6
45
  ## 0.46.1
7
46
 
8
47
  0.46.0 republished: its tag's publish run refused on two settings-menu tests that still listed the
package/README.md CHANGED
@@ -116,6 +116,13 @@ hostname and pick by the `host` header.
116
116
  body. The response carries the page's id and URL. Posting a file whose front matter carries
117
117
  that `id:` republishes it in place, keeping every earlier version.
118
118
 
119
+ **Taking a page down keeps its history (0.48.0).** `POST /api/artifacts?id=<id>&unpublish=1`
120
+ (publish key, empty body) takes the page off the web: every reader path answers 404, and the
121
+ record, every version, every comment and every file are kept. Republishing to that id puts it back
122
+ at the same link. `DELETE /api/artifacts/<id>` is the separate act that destroys all of it. A host
123
+ older than 0.48.0 answers the unpublish with a 400 and changes nothing, so a publisher can never
124
+ mistake it for a delete; a store without `setUnpublished` answers 501, likewise changing nothing.
125
+
119
126
  ## The page menu
120
127
 
121
128
  Every page, past version and door carries one small round **⋯** button in the top right corner
@@ -676,6 +683,43 @@ Paste it under the audio line, then correct any word (a name, a phrase in anothe
676
683
  and keep its two times. `transcriptFenceFromWords` and `wordsFromWhisperJson` (from
677
684
  `@supersuit/artifacts/artifacts`) do the same from code.
678
685
 
686
+ ## Embedded players (`simulator`, `messages`, `figure`, `helm`)
687
+
688
+ Four fences play a page the HOST serves, inline, in a frame under a caption:
689
+
690
+ | Fence | Names | Default height | Plays |
691
+ | --- | --- | --- | --- |
692
+ | `simulator` | `scenario: <id>` (required) | 720px | a Supersuit Session demo |
693
+ | `messages` | `chat: <id>` (optional) | 760px | a Messages-app phone, open on one thread or the list |
694
+ | `figure` | `figure: <id>` (required) | 560px | an interactive figure |
695
+ | `helm` | `scene: <id>` (required), `autoplay: true` (optional) | 760px | a scene on a Helm phone |
696
+
697
+ ````markdown
698
+ ```figure
699
+ figure: doug-week
700
+ caption: A week of Doug, before and after.
701
+ height: 560px # optional
702
+ ```
703
+
704
+ ```helm
705
+ scene: doug-walk
706
+ caption: A year later, on the reservoir loop.
707
+ autoplay: true # optional, true or false
708
+ ```
709
+ ````
710
+
711
+ - **Every block needs a `caption`**, the line a reader sees under the player. Publish refuses a
712
+ block with no caption, no required id, an id that is not lowercase letters, digits and dashes, a
713
+ height that is not pixels, or an unknown key, naming the line in the file.
714
+ - **The host names each player's embed page** (`simulatorEmbed`, `messagesEmbed`, `figureEmbed`,
715
+ `helmEmbed` in `createArtifactRoutes`, each an https URL; or `embeds` on `ArtifactMarkdown`). The
716
+ frame loads it with the id (`?figure=doug-week`, `?scene=doug-walk`) and `fit=content`. There is
717
+ no default: a host that names no page for a kind shows that block's caption alone, never its
718
+ source as code, which is also what print and a page with the player blocked show.
719
+ - **The frame fits its player.** It opens at `height:` and takes the height the embed page posts
720
+ as `{ type: 'embed:fit', height }` from its own origin.
721
+ - **Narration skips it**, caption included, and it carries no comment anchor.
722
+
679
723
  ## Comments and personal notes
680
724
 
681
725
  Every page takes comments pinned to any part of it. A reader selects text (a **Comment** chip
@@ -1,5 +1,5 @@
1
1
  export { parseArtifactSource, ARTIFACT_KINDS, PAGE_HISTORY, type ArtifactMeta, type ArtifactKind, type PageHistory } from './front-matter.js';
2
- export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
2
+ export { ArtifactMarkdown, artifactUrlTransform, type EmbedCtx } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS, type ArtifactListItem, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
@@ -1,12 +1,15 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js';
3
3
  import { type Heading } from './widgets.js';
4
+ import { type EmbedKind } from './simulator.js';
4
5
  import { type Definition } from './definitions.js';
5
6
  export declare function isReachableHref(href: unknown): href is string;
6
7
  export declare function artifactUrlTransform(url: string): string;
7
8
  type SimulatorCtx = {
8
9
  embed: string;
9
10
  };
11
+ /** The host's embed page for each embed kind it serves (ArtifactMarkdown `embeds`). */
12
+ export type EmbedCtx = Partial<Record<EmbedKind, SimulatorCtx>>;
10
13
  /** A pack's figure resolver (BrandPack.figure). */
11
14
  export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
12
15
  /** The page's top-level sections: every heading among the body's top-level blocks at the
@@ -14,7 +17,7 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
14
17
  export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
15
18
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
16
19
  * page carries a ```notes block. */
17
- export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }: {
20
+ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, embeds: hosted, }: {
18
21
  markdown: string;
19
22
  /** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
20
23
  * (a host that keeps answers, on a page that declares the slot). On a page whose checklists
@@ -40,5 +43,9 @@ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitio
40
43
  simulator?: SimulatorCtx;
41
44
  /** The host's Messages phone, for ```messages blocks. Without it a block shows its caption only. */
42
45
  messages?: SimulatorCtx;
46
+ /** The host's embed page for every embed kind it serves (```simulator, ```messages, ```figure,
47
+ * ```helm). A kind with none shows its caption only. `simulator` and `messages` above still work
48
+ * and win over this. */
49
+ embeds?: EmbedCtx;
43
50
  }): import("react").JSX.Element;
44
51
  export {};
@@ -17,7 +17,7 @@ import { unified } from 'unified';
17
17
  import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
18
18
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
19
19
  import { Checklist } from '../widgets/checklist.js';
20
- import { embedSrc, parseEmbedFence } from './simulator.js';
20
+ import { embedSrc, isEmbedKind, parseEmbedFence, EMBED_KINDS } from './simulator.js';
21
21
  import { EmbedFrame } from './embed-frame.js';
22
22
  import { VideoAutoplay } from '../reader/video-autoplay.js';
23
23
  import { CodeCopy } from '../reader/code-copy.js';
@@ -114,6 +114,8 @@ function InlineAudio({ src, label }) {
114
114
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio data-artifact-media="${escapeAttr(mediaName(src))}" controls preload="none" class="block w-full"></audio>`;
115
115
  return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
116
116
  }
117
+ /** Fences that draw something other than code, so `pre` must not wrap them. */
118
+ const OWN_BLOCK = new RegExp(`language-(links|notes|checklist|transcript|${EMBED_KINDS.join('|')})\\b`);
117
119
  // A session demo on a wide screen is wider than the reading column, so the device it draws (a
118
120
  // laptop, or a phone beside what was made) has room: up to 64rem, centred on the column, and clear
119
121
  // of the contents rail fixed in the left margin from 1280px (w-56 at left-6, w-64 at left-12).
@@ -125,14 +127,14 @@ const WIDE_EMBED_CSS = `@media (min-width:1024px){${WIDE}{width:max(100%,min(64r
125
127
  `[data-broadcast] ${WIDE}{width:auto;margin-left:0;transform:none}[data-broadcast] ${WIDE} figcaption{text-align:inherit}}` +
126
128
  `@media (min-width:1280px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 36rem)))}}` +
127
129
  `@media (min-width:1536px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 40rem)))}}`;
128
- /** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
130
+ /** An embed block (```simulator, ```messages, ```figure, ```helm): the player in a frame when the host names one, its
129
131
  * caption alone when it does not, and nothing when the block does not parse (a page published
130
132
  * before validation existed). */
131
133
  function EmbedFence({ kind, source, ctx }) {
132
134
  const r = parseEmbedFence(kind, source.replace(/\n$/, ''), 0);
133
135
  if (!r.ok)
134
136
  return null;
135
- const src = ctx ? embedSrc(kind, ctx.embed, r.block.value) : null;
137
+ const src = ctx ? embedSrc(kind, ctx.embed, r.block.value, r.block.options) : null;
136
138
  const wide = kind === 'simulator' && src != null;
137
139
  return (
138
140
  // data-nospeak: the narration skips every fenced block (narration.ts), so the caption must not
@@ -219,7 +221,7 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
219
221
  // A transcript not under an audio line draws nothing (publish refuses one; an old page may hold it).
220
222
  if (lang === TRANSCRIPT_LANG)
221
223
  return null;
222
- if (lang === 'simulator' || lang === 'messages')
224
+ if (isEmbedKind(lang))
223
225
  return _jsx(EmbedFence, { kind: lang, source: textOf(children), ctx: embeds?.[lang] });
224
226
  if (!className) {
225
227
  return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
@@ -230,7 +232,7 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
230
232
  // A links fence renders its own block; do not wrap it in <pre>.
231
233
  const inner = Array.isArray(children) ? children[0] : children;
232
234
  const cls = inner?.props?.className ?? '';
233
- if (/language-(links|notes|checklist|simulator|messages|transcript)/.test(cls))
235
+ if (OWN_BLOCK.test(cls))
234
236
  return _jsx(_Fragment, { children: children });
235
237
  return (_jsx("pre", { ...block(node), "data-artifact-code": true,
236
238
  // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
@@ -355,9 +357,9 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
355
357
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
356
358
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
357
359
  * page carries a ```notes block. */
358
- export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }) {
360
+ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, embeds: hosted, }) {
359
361
  const on = !!notes && hasNotesWidget(markdown);
360
- const embeds = { simulator, messages };
362
+ const embeds = { ...hosted, ...(simulator ? { simulator } : {}), ...(messages ? { messages } : {}) };
361
363
  const headings = headingsOf(markdown);
362
364
  const blocks = blocksOf(markdown);
363
365
  const block = (node) => {
@@ -1,10 +1,12 @@
1
- export type EmbedKind = 'simulator' | 'messages';
1
+ export type EmbedKind = 'simulator' | 'messages' | 'figure' | 'helm';
2
2
  export declare const EMBED_KINDS: EmbedKind[];
3
3
  export declare const isEmbedKind: (k: unknown) => k is EmbedKind;
4
+ /** `options` holds a kind's further keys (helm's `autoplay`) as written, and is absent when none were. */
4
5
  export type EmbedBlock = {
5
6
  value: string | null;
6
7
  caption: string;
7
8
  height: string;
9
+ options?: Record<string, string>;
8
10
  };
9
11
  export type SimulatorBlock = {
10
12
  scenario: string;
@@ -22,7 +24,7 @@ export declare function parseEmbedFence(kind: EmbedKind, value: string, line: nu
22
24
  };
23
25
  /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
24
26
  * embed address that is not https, so a misconfigured host frames nothing. */
25
- export declare function embedSrc(kind: EmbedKind, embed: string, value: string | null): string | null;
27
+ export declare function embedSrc(kind: EmbedKind, embed: string, value: string | null, options?: Record<string, string>): string | null;
26
28
  /** The ```simulator block (kept for callers of 0.25.0). */
27
29
  export declare function parseSimulatorFence(value: string, line: number): {
28
30
  ok: true;
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // `simulator` is a Supersuit Session demo; `messages` is a Messages-app phone of real threads. The
10
10
  // block names what to open and a caption; the HOST names each player's embed page
11
- // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed), so this public package points at nobody's
11
+ // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed / figureEmbed / helmEmbed), so this public package points at nobody's
12
12
  // service by default, and a host without the player draws the caption alone rather than the block's
13
13
  // source as code. `simulator` keeps the syntax the Superskills book's reader takes.
14
14
  //
@@ -19,9 +19,12 @@
19
19
  // frame then takes (EmbedFrame). A page that posts nothing keeps the block's height. The embed
20
20
  // page must hold its player's size once asked, never size it from its own viewport, or the two
21
21
  // chase each other; that is why the frame listens only for this message and for no player's own.
22
+ const BOOL = { values: ['true', 'false'], off: 'false' };
22
23
  const KINDS = {
23
24
  simulator: { key: 'scenario', required: true, fixed: { timeline: 'off', fit: 'content' }, height: '720px' },
24
25
  messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
26
+ figure: { key: 'figure', required: true, fixed: { fit: 'content' }, height: '560px' },
27
+ helm: { key: 'scene', required: true, fixed: { fit: 'content' }, height: '760px', options: { autoplay: BOOL } },
25
28
  };
26
29
  export const EMBED_KINDS = Object.keys(KINDS);
27
30
  export const isEmbedKind = (k) => typeof k === 'string' && Object.hasOwn(KINDS, k);
@@ -31,7 +34,8 @@ const HEIGHT = /^\d{3,4}px$/;
31
34
  * line the author sees. */
32
35
  export function parseEmbedFence(kind, value, line) {
33
36
  const spec = KINDS[kind];
34
- const keys = [spec.key, 'caption', 'height'];
37
+ const opts = spec.options ?? {};
38
+ const keys = [spec.key, 'caption', 'height', ...Object.keys(opts)];
35
39
  const got = {};
36
40
  const rows = value ? value.split('\n') : [];
37
41
  for (let i = 0; i < rows.length; i++) {
@@ -55,11 +59,23 @@ export function parseEmbedFence(kind, value, line) {
55
59
  return { ok: false, error: `line ${line}: the ${kind} block needs a caption, the line a reader sees under it` };
56
60
  if (got.height && !HEIGHT.test(got.height.v))
57
61
  return { ok: false, error: `line ${got.height.at}: a ${kind} height is a number of pixels, like 760px` };
58
- return { ok: true, block: { value: id?.v ?? null, caption: got.caption.v, height: got.height?.v ?? spec.height } };
62
+ const options = {};
63
+ for (const [name, o] of Object.entries(opts)) {
64
+ const g = got[name];
65
+ if (!g)
66
+ continue;
67
+ if (!o.values.includes(g.v))
68
+ return { ok: false, error: `line ${g.at}: a ${kind} ${name} is ${o.values.join(' or ')}` };
69
+ options[name] = g.v;
70
+ }
71
+ const block = { value: id?.v ?? null, caption: got.caption.v, height: got.height?.v ?? spec.height };
72
+ if (Object.keys(options).length)
73
+ block.options = options;
74
+ return { ok: true, block };
59
75
  }
60
76
  /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
61
77
  * embed address that is not https, so a misconfigured host frames nothing. */
62
- export function embedSrc(kind, embed, value) {
78
+ export function embedSrc(kind, embed, value, options) {
63
79
  let u;
64
80
  try {
65
81
  u = new URL(embed);
@@ -72,6 +88,11 @@ export function embedSrc(kind, embed, value) {
72
88
  const spec = KINDS[kind];
73
89
  if (value)
74
90
  u.searchParams.set(spec.key, value);
91
+ for (const [name, o] of Object.entries(spec.options ?? {})) {
92
+ const v = options?.[name];
93
+ if (v && v !== o.off && o.values.includes(v))
94
+ u.searchParams.set(name, v);
95
+ }
75
96
  for (const [k, v] of Object.entries(spec.fixed))
76
97
  u.searchParams.set(k, v);
77
98
  return u.toString();
@@ -57,6 +57,10 @@ export type ArtifactRecord = {
57
57
  * given: a version is never described by anything its author did not write. */
58
58
  note?: string;
59
59
  views: number;
60
+ /** When the page was taken off the web (0.48.0). The record, every version, every comment and
61
+ * every file are KEPT: an unpublished page is served to no reader, and a republish to its id
62
+ * puts it back. DELETE is the separate act that destroys. */
63
+ unpublishedAt?: string;
60
64
  };
61
65
  /** One line of a page's history. `at` is when that version went live. */
62
66
  export type VersionEntry = {
@@ -130,6 +134,9 @@ export interface ArtifactStore {
130
134
  version?(id: string, n: number): Promise<VersionRecord | null>;
131
135
  /** Write (or, with null, clear) the change note on one version after the fact. */
132
136
  setNote?(id: string, n: number, note: string | null): Promise<boolean>;
137
+ /** Take a page off the web keeping everything (an ISO time), or put it back (null). False when
138
+ * no such page. A store without it can only destroy, and the host refuses to unpublish. */
139
+ setUnpublished?(id: string, at: string | null): Promise<boolean>;
133
140
  /** Every page of one kind, newest first, without bodies (0.37.0): the owner's documents and the
134
141
  * owner's broadcasts are two lists. */
135
142
  list?(kind: ArtifactKind): Promise<ArtifactListItem[]>;
@@ -69,6 +69,13 @@ export function createArtifactStore(db, collection = 'artifacts') {
69
69
  history: (id) => historyOf(col, id),
70
70
  version: (id, n) => versionOf(col, id, n),
71
71
  setNote: (id, n, note) => setNote(col, id, n, note),
72
+ setUnpublished: async (id, at) => {
73
+ const ref = col().doc(id);
74
+ if (!(await ref.get()).exists)
75
+ return false;
76
+ await ref.update({ unpublishedAt: at ?? FieldValue.delete() });
77
+ return true;
78
+ },
72
79
  list: async (kind) => {
73
80
  // `select` keeps every body out of the listing; a tenant's pages number in the hundreds.
74
81
  const snap = await col().select('title', 'summary', 'template', 'createdAt', 'updatedAt', 'views').get();
@@ -231,6 +238,8 @@ async function saveArtifact(col, input) {
231
238
  // `updatedAt` is when this version went live; finishing its own publish does not move it.
232
239
  ...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
233
240
  note: nextNote ?? FieldValue.delete(),
241
+ // A republish is the page going back up: an unpublished page is live again at the same link.
242
+ unpublishedAt: FieldValue.delete(),
234
243
  ...(existing.versions ? { versions: FieldValue.delete() } : {}),
235
244
  });
236
245
  return { id: input.id, version: next, created: false };
@@ -292,7 +292,9 @@ export function blocksOf(markdown) {
292
292
  content = plain(n);
293
293
  }
294
294
  else if (n.type === 'code') {
295
- if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist' || n.lang === TRANSCRIPT_LANG)
295
+ // An embed block draws a player and a caption the reader cannot comment on (data-nospeak, no
296
+ // data-block), so it takes no anchor either.
297
+ if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist' || n.lang === TRANSCRIPT_LANG || isEmbedKind(n.lang))
296
298
  continue;
297
299
  kind = 'code';
298
300
  content = n.value;
@@ -37,6 +37,13 @@ export type ArtifactRoutesConfig = {
37
37
  /** The embed page of this host's Messages phone (an https URL), for ```messages blocks: a
38
38
  * `chat=<id>` opens one thread. Without it a block shows its caption only. No default. */
39
39
  messagesEmbed?: string;
40
+ /** The embed page of this host's interactive figures (an https URL, e.g.
41
+ * https://example.com/figures/embed), for ```figure blocks: `figure=<id>` opens one. Without it
42
+ * a block shows its caption only. No default. */
43
+ figureEmbed?: string;
44
+ /** The embed page of this host's Helm player (an https URL), for ```helm blocks: `scene=<id>`
45
+ * plays one, `autoplay=true` when the block asks. Without it a block shows its caption only. No default. */
46
+ helmEmbed?: string;
40
47
  /** Read at request time, so a rotated key needs no rebuild. */
41
48
  publishKey: () => string | undefined;
42
49
  /** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
@@ -222,6 +229,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
222
229
  }>>;
223
230
  POST: (request: NextRequest) => Promise<NextResponse<{
224
231
  error: string;
232
+ }> | NextResponse<{
233
+ id: string;
234
+ unpublished: boolean;
235
+ at: string;
225
236
  }> | NextResponse<{
226
237
  warning?: string | undefined;
227
238
  expireAt?: string | null | undefined;
@@ -71,7 +71,11 @@ function ViewingAsReader({ back }) {
71
71
  return (_jsx("div", { "data-viewing-as-reader": true, "data-nospeak": true, className: "fixed inset-x-0 top-3 z-50 flex justify-center px-3", children: _jsxs("p", { className: "m-0 rounded-full border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] px-4 py-1.5 font-sans text-xs text-[color:var(--a-strong)] shadow-lg", children: ["Viewing as a reader \u00B7 ", _jsx("a", { href: back, className: "underline", children: "Back to your view" })] }) }));
72
72
  }
73
73
  export function createArtifactRoutes(config) {
74
- const { store, siteUrl } = config;
74
+ const { store: kept, siteUrl } = config;
75
+ // An unpublished page (0.48.0) is KEPT, versions, comments and files all, and served to nobody:
76
+ // every reader-facing path reads through this view, which does not see it. The publisher's own
77
+ // routes (POST, GET, DELETE, the uploads) read `kept`, so the page can be put back or destroyed.
78
+ const store = { ...kept, get: async (id) => { const a = await kept.get(id); return a?.unpublishedAt ? null : a; } };
75
79
  // The pack as configured draws the share card; the page paints with its theme variables, so a
76
80
  // page follows its light, dark or system mode (brand/theme.ts).
77
81
  const pack = config.brand;
@@ -92,6 +96,11 @@ export function createArtifactRoutes(config) {
92
96
  // The version in the URL is what makes a re-publish show up: every unfurler caches by URL.
93
97
  const signInOrigin = config.signInOrigin;
94
98
  const owner = config.owner ?? brand.name;
99
+ // The embed page of each player this host serves, for every ArtifactMarkdown below.
100
+ const embeds = {};
101
+ for (const [kind, url] of [['simulator', config.simulatorEmbed], ['messages', config.messagesEmbed], ['figure', config.figureEmbed], ['helm', config.helmEmbed]])
102
+ if (url)
103
+ embeds[kind] = { embed: url };
95
104
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
96
105
  const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
97
106
  /** Whether this reader may see this page's version number and past versions: the page's own
@@ -465,7 +474,7 @@ export function createArtifactRoutes(config) {
465
474
  const title = v.title ?? a.title;
466
475
  const summary = v.summary ?? a.summary;
467
476
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
468
- return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), 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" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at, numbered: true }), version: n, showVersion: true, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _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, { n: n, at: v.at, numbered: true })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
477
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), 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" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at, numbered: true }), version: n, showVersion: true, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _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, { n: n, at: v.at, numbered: true })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, embeds: embeds, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
469
478
  }
470
479
  /** A broadcast: one message card. The sender's words at the top, then the media (a video with
471
480
  * sound and controls, photos, a voice note's player, link cards) in the order the words put
@@ -480,7 +489,7 @@ export function createArtifactRoutes(config) {
480
489
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
481
490
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
482
491
  // eslint-disable-next-line @next/next/no-img-element
483
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
492
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
484
493
  }
485
494
  /** The page itself, shared by open and gated pages. */
486
495
  async function Body(a, { top, reader, perReader }) {
@@ -507,7 +516,7 @@ export function createArtifactRoutes(config) {
507
516
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, showVersion: seesHistory(a, reader), updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
508
517
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
509
518
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
510
- checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
519
+ checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, embeds: embeds }) }));
511
520
  })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
512
521
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
513
522
  recorded at publish, or there is none. */] }));
@@ -766,6 +775,21 @@ export function createArtifactRoutes(config) {
766
775
  async function POST(request) {
767
776
  if (!authed(request))
768
777
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
778
+ // `?unpublish=1`: take the page off the web and keep everything. On the publish route rather
779
+ // than a route of its own so no host has a file to add, and so a host older than 0.48.0 refuses
780
+ // the empty body with a 400 instead of doing anything at all: it can never be mistaken for a delete.
781
+ if (request.nextUrl.searchParams.get('unpublish') === '1') {
782
+ const id = request.nextUrl.searchParams.get('id');
783
+ if (!id || !ARTIFACT_ID.test(id))
784
+ return NextResponse.json({ error: 'unpublish needs ?id=<page id>' }, { status: 400 });
785
+ if (!kept.setUnpublished)
786
+ return NextResponse.json({ error: 'this store cannot keep a page unpublished; nothing was changed' }, { status: 501 });
787
+ const at = new Date().toISOString();
788
+ if (!(await kept.get(id)) || !(await kept.setUnpublished(id, at)))
789
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
790
+ revalidatePath(pagePath(id));
791
+ return NextResponse.json({ id, unpublished: true, at });
792
+ }
769
793
  const text = await request.text();
770
794
  let parsed = parseArtifactSource(text);
771
795
  if (!parsed.ok)
@@ -774,7 +798,7 @@ export function createArtifactRoutes(config) {
774
798
  // The kind is sticky, like `links:`: a republish of a broadcast that leaves `template:` out is
775
799
  // still a broadcast, and one that says `document` is refused rather than quietly changed.
776
800
  if (id && parsed.meta.template === 'document') {
777
- const stored = await store.get(id);
801
+ const stored = await kept.get(id);
778
802
  if (stored && kindOf(stored) === 'broadcast') {
779
803
  const again = parseArtifactSource(text, { defaultKind: 'broadcast' });
780
804
  if (!again.ok)
@@ -787,10 +811,10 @@ export function createArtifactRoutes(config) {
787
811
  // Fail closed: private media needs a private store. Without one the media would have to sit in
788
812
  // the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
789
813
  // `media:` is sticky, so a stored page that already keeps private media counts too.
790
- if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
814
+ if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await kept.get(id))?.privateMedia)))
791
815
  return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
792
816
  if (id && parsed.meta.state) {
793
- const existing = await store.get(id);
817
+ const existing = await kept.get(id);
794
818
  const changed = shapeChanges(existing?.state, parsed.meta.state);
795
819
  if (changed.length)
796
820
  return NextResponse.json({ error: changed.join('; ') }, { status: 400 });
@@ -817,7 +841,7 @@ export function createArtifactRoutes(config) {
817
841
  // `?amend=1` is the publisher finishing the publish it started (image URLs, then narration).
818
842
  const note = cleanNote(request.nextUrl.searchParams.get('note')) ?? parsed.meta.change;
819
843
  const amend = request.nextUrl.searchParams.get('amend') === '1';
820
- const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
844
+ const result = await kept.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
821
845
  if ('notFound' in result)
822
846
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
823
847
  // A broadcast with no date gets one: 90 days from now. A date already set (by the sender, or
@@ -845,7 +869,7 @@ export function createArtifactRoutes(config) {
845
869
  if (!authed(request))
846
870
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
847
871
  const { id } = await params;
848
- const a = await store.get(id);
872
+ const a = await kept.get(id);
849
873
  if (!a)
850
874
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
851
875
  return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
@@ -862,7 +886,7 @@ export function createArtifactRoutes(config) {
862
886
  const { id, name } = await params;
863
887
  if (!ARTIFACT_ID.test(id))
864
888
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
865
- if (!(await store.get(id)))
889
+ if (!(await kept.get(id)))
866
890
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
867
891
  if (!ASSET_NAME.test(name))
868
892
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
@@ -912,7 +936,7 @@ export function createArtifactRoutes(config) {
912
936
  const { id, name } = await params;
913
937
  if (!ARTIFACT_ID.test(id))
914
938
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
915
- if (!(await store.get(id)))
939
+ if (!(await kept.get(id)))
916
940
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
917
941
  if (!ASSET_NAME.test(name))
918
942
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
@@ -972,7 +996,7 @@ export function createArtifactRoutes(config) {
972
996
  if (!authed(request))
973
997
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
974
998
  const { id } = await params;
975
- if (!ARTIFACT_ID.test(id) || !(await store.get(id)))
999
+ if (!ARTIFACT_ID.test(id) || !(await kept.get(id)))
976
1000
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
977
1001
  let files = 0;
978
1002
  if (config.assets?.deletePage) {
@@ -990,7 +1014,7 @@ export function createArtifactRoutes(config) {
990
1014
  catch (e) {
991
1015
  return NextResponse.json({ error: `the page's readers could not be forgotten, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
992
1016
  }
993
- const ok = await store.delete(id);
1017
+ const ok = await kept.delete(id);
994
1018
  if (!ok)
995
1019
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
996
1020
  revalidatePath(pagePath(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.46.1",
3
+ "version": "0.48.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",