@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 +39 -0
- package/README.md +44 -0
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/render.d.ts +8 -1
- package/lib/artifacts/render.js +9 -7
- package/lib/artifacts/simulator.d.ts +4 -2
- package/lib/artifacts/simulator.js +25 -4
- package/lib/artifacts/store.d.ts +7 -0
- package/lib/artifacts/store.js +9 -0
- package/lib/artifacts/widgets.js +3 -1
- package/lib/routes/artifacts.d.ts +11 -0
- package/lib/routes/artifacts.js +37 -13
- package/package.json +1 -1
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
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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 {};
|
package/lib/artifacts/render.js
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
|
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
|
-
|
|
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();
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -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[]>;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -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 };
|
package/lib/artifacts/widgets.js
CHANGED
|
@@ -292,7 +292,9 @@ export function blocksOf(markdown) {
|
|
|
292
292
|
content = plain(n);
|
|
293
293
|
}
|
|
294
294
|
else if (n.type === 'code') {
|
|
295
|
-
|
|
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;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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,
|
|
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 },
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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",
|