@supersuit/artifacts 0.47.0 → 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 +18 -0
- package/README.md +7 -0
- package/lib/artifacts/store.d.ts +7 -0
- package/lib/artifacts/store.js +9 -0
- package/lib/routes/artifacts.d.ts +4 -0
- package/lib/routes/artifacts.js +29 -10
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,24 @@
|
|
|
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
|
+
|
|
6
24
|
## 0.47.0
|
|
7
25
|
|
|
8
26
|
**Figures and Helm scenes play inline, as two new embed fences.** A ```` ```figure ```` block
|
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
|
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 };
|
|
@@ -229,6 +229,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
229
229
|
}>>;
|
|
230
230
|
POST: (request: NextRequest) => Promise<NextResponse<{
|
|
231
231
|
error: string;
|
|
232
|
+
}> | NextResponse<{
|
|
233
|
+
id: string;
|
|
234
|
+
unpublished: boolean;
|
|
235
|
+
at: string;
|
|
232
236
|
}> | NextResponse<{
|
|
233
237
|
warning?: string | undefined;
|
|
234
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;
|
|
@@ -771,6 +775,21 @@ export function createArtifactRoutes(config) {
|
|
|
771
775
|
async function POST(request) {
|
|
772
776
|
if (!authed(request))
|
|
773
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
|
+
}
|
|
774
793
|
const text = await request.text();
|
|
775
794
|
let parsed = parseArtifactSource(text);
|
|
776
795
|
if (!parsed.ok)
|
|
@@ -779,7 +798,7 @@ export function createArtifactRoutes(config) {
|
|
|
779
798
|
// The kind is sticky, like `links:`: a republish of a broadcast that leaves `template:` out is
|
|
780
799
|
// still a broadcast, and one that says `document` is refused rather than quietly changed.
|
|
781
800
|
if (id && parsed.meta.template === 'document') {
|
|
782
|
-
const stored = await
|
|
801
|
+
const stored = await kept.get(id);
|
|
783
802
|
if (stored && kindOf(stored) === 'broadcast') {
|
|
784
803
|
const again = parseArtifactSource(text, { defaultKind: 'broadcast' });
|
|
785
804
|
if (!again.ok)
|
|
@@ -792,10 +811,10 @@ export function createArtifactRoutes(config) {
|
|
|
792
811
|
// Fail closed: private media needs a private store. Without one the media would have to sit in
|
|
793
812
|
// the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
|
|
794
813
|
// `media:` is sticky, so a stored page that already keeps private media counts too.
|
|
795
|
-
if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await
|
|
814
|
+
if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await kept.get(id))?.privateMedia)))
|
|
796
815
|
return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
|
|
797
816
|
if (id && parsed.meta.state) {
|
|
798
|
-
const existing = await
|
|
817
|
+
const existing = await kept.get(id);
|
|
799
818
|
const changed = shapeChanges(existing?.state, parsed.meta.state);
|
|
800
819
|
if (changed.length)
|
|
801
820
|
return NextResponse.json({ error: changed.join('; ') }, { status: 400 });
|
|
@@ -822,7 +841,7 @@ export function createArtifactRoutes(config) {
|
|
|
822
841
|
// `?amend=1` is the publisher finishing the publish it started (image URLs, then narration).
|
|
823
842
|
const note = cleanNote(request.nextUrl.searchParams.get('note')) ?? parsed.meta.change;
|
|
824
843
|
const amend = request.nextUrl.searchParams.get('amend') === '1';
|
|
825
|
-
const result = await
|
|
844
|
+
const result = await kept.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
|
|
826
845
|
if ('notFound' in result)
|
|
827
846
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
828
847
|
// A broadcast with no date gets one: 90 days from now. A date already set (by the sender, or
|
|
@@ -850,7 +869,7 @@ export function createArtifactRoutes(config) {
|
|
|
850
869
|
if (!authed(request))
|
|
851
870
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
852
871
|
const { id } = await params;
|
|
853
|
-
const a = await
|
|
872
|
+
const a = await kept.get(id);
|
|
854
873
|
if (!a)
|
|
855
874
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
856
875
|
return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
|
|
@@ -867,7 +886,7 @@ export function createArtifactRoutes(config) {
|
|
|
867
886
|
const { id, name } = await params;
|
|
868
887
|
if (!ARTIFACT_ID.test(id))
|
|
869
888
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
870
|
-
if (!(await
|
|
889
|
+
if (!(await kept.get(id)))
|
|
871
890
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
872
891
|
if (!ASSET_NAME.test(name))
|
|
873
892
|
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
@@ -917,7 +936,7 @@ export function createArtifactRoutes(config) {
|
|
|
917
936
|
const { id, name } = await params;
|
|
918
937
|
if (!ARTIFACT_ID.test(id))
|
|
919
938
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
920
|
-
if (!(await
|
|
939
|
+
if (!(await kept.get(id)))
|
|
921
940
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
922
941
|
if (!ASSET_NAME.test(name))
|
|
923
942
|
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
@@ -977,7 +996,7 @@ export function createArtifactRoutes(config) {
|
|
|
977
996
|
if (!authed(request))
|
|
978
997
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
979
998
|
const { id } = await params;
|
|
980
|
-
if (!ARTIFACT_ID.test(id) || !(await
|
|
999
|
+
if (!ARTIFACT_ID.test(id) || !(await kept.get(id)))
|
|
981
1000
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
982
1001
|
let files = 0;
|
|
983
1002
|
if (config.assets?.deletePage) {
|
|
@@ -995,7 +1014,7 @@ export function createArtifactRoutes(config) {
|
|
|
995
1014
|
catch (e) {
|
|
996
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 });
|
|
997
1016
|
}
|
|
998
|
-
const ok = await
|
|
1017
|
+
const ok = await kept.delete(id);
|
|
999
1018
|
if (!ok)
|
|
1000
1019
|
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
1001
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",
|