@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 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
@@ -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 };
@@ -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;
@@ -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 store.get(id);
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 store.get(id))?.privateMedia)))
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 store.get(id);
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 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 });
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 store.get(id);
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 store.get(id)))
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 store.get(id)))
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 store.get(id)))
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 store.delete(id);
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.47.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",