@tideworthy/tidysites-platform 0.9.1 → 0.10.1

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/README.md CHANGED
@@ -133,6 +133,10 @@ Applies the template seed's structure to a site that is already set up: block ty
133
133
 
134
134
  Runs EmDash's scheduled work now (scheduled publishing, cleanup) and returns what it published. Workers in a dispatch namespace never receive cron triggers, so the platform's cron calls this on every site each minute.
135
135
 
136
+ ### `GET|POST /_tidy/revisions`
137
+
138
+ The site's revision history for the signed-in editor (the Astro session is the gate, no bearer). `GET ?path=/x` lists every publish; `GET ?id=<revision>&path=/x` returns that page as it was in the revision, read from the control plane's stored copy; `POST {action:"restore", id}` puts the live site and the staging content back on it; `POST {action:"publish"}` makes the live site follow staging now. The editor bar's Revisions and Publish live controls call it; `?tidy-revisions=1` on any page opens the list and `?tidy-revision=<id>` opens that revision of the page.
139
+
136
140
  ## Publish hooks
137
141
 
138
142
  The plugin registers `content:afterPublish`, `content:afterUnpublish`, and `content:afterDelete`. Each posts `{ reason, collection, id }` to `POST <TIDY_CONTROL_ORIGIN>/sites/<TIDY_SITE_ID>/publish` over the `PLATFORM` binding with the site secret as bearer. Hooks run with `errorPolicy: "continue"` and an 8 second timeout, so a slow or failing control plane never fails the editor's publish.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -30,7 +30,8 @@
30
30
  "./routes/rotate-token": "./src/routes/rotate-token.ts",
31
31
  "./routes/fill": "./src/routes/fill.ts",
32
32
  "./routes/schema": "./src/routes/schema.ts",
33
- "./routes/maintenance": "./src/routes/maintenance.ts"
33
+ "./routes/maintenance": "./src/routes/maintenance.ts",
34
+ "./routes/revisions": "./src/routes/revisions.ts"
34
35
  },
35
36
  "scripts": {
36
37
  "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.editor.json",
@@ -52,6 +52,7 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : "tidy-ha
52
52
  </div>
53
53
  </div>
54
54
  <script is:inline define:vars={{ htmlClass }}>document.documentElement.className += " " + htmlClass;</script>
55
+ <script src="./revisions.ts"></script>
55
56
  </>
56
57
  )}
57
58
  {config && (
@@ -119,4 +120,9 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : "tidy-ha
119
120
  .tidy-add-item { display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid #d3dcd6; border-radius: 8px; background: #fff; cursor: pointer; font: inherit; }
120
121
  .tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
121
122
  .tidy-add-item span { color: #4a5851; font-size: 13px; }
123
+ .tidy-viewer { position: fixed; inset: 0; z-index: 2147483002; display: flex; flex-direction: column; background: #fff; }
124
+ .tidy-viewer[hidden] { display: none; }
125
+ .tidy-viewer-bar { display: flex; align-items: center; gap: 8px; padding: 0 16px; height: 44px; background: #17211c; color: #fff; font: 500 13px/1 system-ui, sans-serif; }
126
+ .tidy-viewer-label { flex: 1; opacity: .85; }
127
+ .tidy-viewer-frame { flex: 1; width: 100%; border: 0; }
122
128
  </style>
@@ -0,0 +1,159 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+ /**
4
+ * Revision history and Publish live, on the editor bar for anyone signed in,
5
+ * editing or not. Revisions are the site's publishes (the control plane keeps
6
+ * a full copy of each). Pick one and the page you are on shows as it was
7
+ * then; Restore puts the live site, and the staging content, back on it.
8
+ * Publish live makes the live site follow staging now.
9
+ *
10
+ * Deep links: ?tidy-revisions=1 opens the list; ?tidy-revision=<id> opens
11
+ * that revision of this page.
12
+ */
13
+
14
+ interface Revision {
15
+ id: string;
16
+ at: string;
17
+ pages: number | null;
18
+ restoredFrom: string | null;
19
+ }
20
+
21
+ const ROUTE = "/_tidy/revisions";
22
+
23
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
24
+ const node = document.createElement(tag);
25
+ for (const [k, v] of Object.entries(attrs)) {
26
+ if (k === "class") node.className = v;
27
+ else if (k === "text") node.textContent = v;
28
+ else node.setAttribute(k, v);
29
+ }
30
+ for (const c of children) node.append(c);
31
+ return node;
32
+ }
33
+
34
+ function when(iso: string): string {
35
+ return new Date(iso).toLocaleString(undefined, { month: "short", day: "numeric", year: "numeric", hour: "numeric", minute: "2-digit" });
36
+ }
37
+
38
+ export function startRevisions(): void {
39
+ const actions = document.getElementById("tidy-bar-actions");
40
+ if (!actions) return;
41
+ const here = location.pathname;
42
+ const revBtn = el("button", { type: "button", class: "tidy-btn", text: "Revisions" });
43
+ const liveBtn = el("button", { type: "button", class: "tidy-btn", text: "Publish live" });
44
+ actions.prepend(revBtn, liveBtn);
45
+
46
+ // --- the panel --------------------------------------------------------
47
+ const panel = el("div", { class: "tidy-panel", hidden: "" });
48
+ document.body.append(panel);
49
+ let revisions: Revision[] = [];
50
+
51
+ async function load(): Promise<Revision[]> {
52
+ const r = await fetch(`${ROUTE}?path=${encodeURIComponent(here)}`, { credentials: "same-origin" });
53
+ if (!r.ok) throw new Error(`${r.status}`);
54
+ const j = (await r.json()) as { revisions: Revision[] };
55
+ revisions = j.revisions;
56
+ return revisions;
57
+ }
58
+
59
+ function close() {
60
+ panel.hidden = true;
61
+ panel.replaceChildren();
62
+ viewer.hidden = true;
63
+ frame.removeAttribute("srcdoc");
64
+ }
65
+
66
+ function renderList() {
67
+ const head = el("div", { class: "tidy-panel-head" }, [el("h2", { text: "Revisions" }), el("p", { text: "Every time the website was published. Open one to see this page as it was then." })]);
68
+ const list = el("div", { class: "tidy-add-list" });
69
+ revisions.forEach((rev, i) => {
70
+ const note = i === 0 ? "Live now" : rev.restoredFrom ? "Restored" : "";
71
+ const item = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: when(rev.at) }), el("span", { text: [note, rev.pages ? `${rev.pages} pages` : ""].filter(Boolean).join(" · ") })]);
72
+ item.addEventListener("click", () => void view(rev, i === 0));
73
+ list.append(item);
74
+ });
75
+ if (revisions.length === 0) list.append(el("p", { class: "tidy-field-note", text: "Nothing published yet." }));
76
+ const closeBtn = el("button", { type: "button", class: "tidy-btn", text: "Close" });
77
+ closeBtn.addEventListener("click", close);
78
+ panel.replaceChildren(head, list, el("div", { class: "tidy-panel-actions" }, [closeBtn]));
79
+ panel.hidden = false;
80
+ }
81
+
82
+ // --- the viewer: this page as it was in a revision --------------------
83
+ const viewer = el("div", { class: "tidy-viewer", hidden: "" });
84
+ const viewerBar = el("div", { class: "tidy-viewer-bar" });
85
+ const viewerLabel = el("span", { class: "tidy-viewer-label" });
86
+ const backBtn = el("button", { type: "button", class: "tidy-btn", text: "Back to revisions" });
87
+ const restoreBtn = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Restore this version" });
88
+ const frame = el("iframe", { class: "tidy-viewer-frame", title: "This page in an earlier revision", sandbox: "allow-same-origin" });
89
+ viewerBar.append(viewerLabel, backBtn, restoreBtn);
90
+ viewer.append(viewerBar, frame);
91
+ document.body.append(viewer);
92
+ backBtn.addEventListener("click", () => { viewer.hidden = true; frame.removeAttribute("srcdoc"); renderList(); });
93
+
94
+ async function view(rev: Revision, isLive: boolean) {
95
+ panel.hidden = true;
96
+ viewerLabel.textContent = `${when(rev.at)}${isLive ? " · live now" : ""}`;
97
+ restoreBtn.hidden = isLive;
98
+ const r = await fetch(`${ROUTE}?id=${encodeURIComponent(rev.id)}&path=${encodeURIComponent(here)}`, { credentials: "same-origin" });
99
+ if (!r.ok) {
100
+ const j = (await r.json().catch(() => ({}))) as { error?: string };
101
+ frame.srcdoc = `<p style="font:15px system-ui;margin:2rem">${j.error ?? "This revision could not be read."}</p>`;
102
+ } else {
103
+ // The stored copy links its built assets by absolute path (/_astro/...);
104
+ // those are read from the revision itself, so the page shows the way it
105
+ // was built. Everything else (uploads, links) resolves against this site.
106
+ const html = await r.text();
107
+ const own = (p: string) => `${ROUTE}?id=${encodeURIComponent(rev.id)}&path=${encodeURIComponent(p)}`;
108
+ frame.srcdoc = html
109
+ .replace(/(href|src)="(\/_astro\/[^"]+)"/g, (_m, attr: string, p: string) => `${attr}="${own(p)}"`)
110
+ .replace(/<head([^>]*)>/i, `<head$1><base href="${location.origin}/"><style>html.tidy-has-bar body{padding-top:0!important}#tidy-bar,.tidy-bar{display:none!important}</style>`);
111
+ }
112
+ viewer.hidden = false;
113
+ restoreBtn.onclick = async () => {
114
+ if (!confirm(`Restore the website to ${when(rev.at)}?\n\nThe live site goes back to how it was then, and so does staging: every change made since is lost.`)) return;
115
+ restoreBtn.disabled = true;
116
+ restoreBtn.textContent = "Restoring";
117
+ const p = await fetch(ROUTE, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "restore", id: rev.id }) });
118
+ if (p.ok) location.href = here;
119
+ else {
120
+ restoreBtn.disabled = false;
121
+ restoreBtn.textContent = "Restore failed. Try again";
122
+ }
123
+ };
124
+ }
125
+
126
+ revBtn.addEventListener("click", async () => {
127
+ if (!panel.hidden) return close();
128
+ try {
129
+ await load();
130
+ renderList();
131
+ } catch {
132
+ panel.replaceChildren(el("p", { class: "tidy-field-note", text: "The revisions could not be read right now." }));
133
+ panel.hidden = false;
134
+ }
135
+ });
136
+
137
+ liveBtn.addEventListener("click", async () => {
138
+ if (!confirm("Publish the website?\n\nEverything on staging goes live, every change made since the last publish.")) return;
139
+ liveBtn.disabled = true;
140
+ liveBtn.textContent = "Publishing";
141
+ const p = await fetch(ROUTE, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "publish" }) });
142
+ liveBtn.disabled = false;
143
+ liveBtn.textContent = p.ok ? "Published" : "Publish failed";
144
+ setTimeout(() => (liveBtn.textContent = "Publish live"), 4000);
145
+ });
146
+
147
+ // Deep links from the platform.
148
+ const params = new URLSearchParams(location.search);
149
+ const want = params.get("tidy-revision");
150
+ if (want || params.get("tidy-revisions")) {
151
+ void load().then((list) => {
152
+ const rev = want ? list.find((x) => x.id === want) : null;
153
+ if (rev) void view(rev, list[0]?.id === rev.id);
154
+ else renderList();
155
+ }).catch(() => undefined);
156
+ }
157
+ }
158
+
159
+ startRevisions();
package/src/index.ts CHANGED
@@ -30,7 +30,8 @@ export const PLATFORM_ROUTES = [
30
30
  { pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
31
31
  { pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
32
32
  { pattern: "/_tidy/schema", entrypoint: `${PACKAGE}/routes/schema` },
33
- { pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` }
33
+ { pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` },
34
+ { pattern: "/_tidy/revisions", entrypoint: `${PACKAGE}/routes/revisions` }
34
35
  ] as const;
35
36
 
36
37
  /**
@@ -0,0 +1,78 @@
1
+ /**
2
+ * /_tidy/revisions: the site's revision history for the signed-in editor,
3
+ * read from the control plane with the site's own secret. No bearer: the
4
+ * browser calls it from the editor bar, and the Astro session is the gate.
5
+ *
6
+ * GET ?path=/reviews the revisions (every publish, newest first)
7
+ * GET ?id=<revision>&path=/x the page as it was in that revision (HTML)
8
+ * POST { action: "restore", id } put the live site (and staging) back on it
9
+ * POST { action: "publish" } make the live site follow staging now
10
+ */
11
+ import type { APIContext, APIRoute } from "astro";
12
+ import { workerEnv } from "../lib/env";
13
+ import { isSafeLocalPath, json, readJson } from "../lib/http";
14
+ import { methodNotAllowed } from "./_shared";
15
+
16
+ export const prerender = false;
17
+
18
+ interface ControlEnv {
19
+ TIDY_CONTROL_ORIGIN?: unknown;
20
+ TIDY_SITE_ID?: unknown;
21
+ TIDY_PLATFORM_SECRET?: unknown;
22
+ PLATFORM?: { fetch(input: string, init?: RequestInit): Promise<Response> };
23
+ }
24
+
25
+ async function control(context: APIContext): Promise<{ call(path: string, init?: RequestInit): Promise<Response> } | Response> {
26
+ const user = context.session ? await context.session.get("user") : null;
27
+ if (!user) return new Response("Unauthorized", { status: 401 });
28
+ const env = (await workerEnv()) as ControlEnv | undefined;
29
+ const origin = env?.TIDY_CONTROL_ORIGIN;
30
+ const siteId = env?.TIDY_SITE_ID;
31
+ const secret = env?.TIDY_PLATFORM_SECRET;
32
+ const platform = env?.PLATFORM;
33
+ if (!platform || typeof origin !== "string" || typeof siteId !== "string" || typeof secret !== "string") return new Response("Not found", { status: 404 });
34
+ const base = `${origin.replace(/\/+$/, "")}/sites/${encodeURIComponent(siteId)}`;
35
+ return {
36
+ call: (path, init = {}) => platform.fetch(`${base}${path}`, { ...init, headers: { authorization: `Bearer ${secret}`, ...(init.headers as Record<string, string> | undefined) } })
37
+ };
38
+ }
39
+
40
+ export const GET: APIRoute = async (context) => {
41
+ const c = await control(context);
42
+ if (c instanceof Response) return c;
43
+ const id = context.url.searchParams.get("id");
44
+ const rawPath = context.url.searchParams.get("path");
45
+ const path = isSafeLocalPath(rawPath) ? rawPath.replace(/[?#].*$/, "") : "/";
46
+ if (id) {
47
+ if (!/^[0-9a-f-]{36}$/i.test(id)) return json({ error: "bad revision id" }, 400);
48
+ const r = await c.call(`/releases/${id}/page?path=${encodeURIComponent(path)}`);
49
+ if (!r.ok) return json({ error: r.status === 404 ? "That page was not in this revision." : "The revision could not be read." }, r.status === 404 ? 404 : 502);
50
+ // A page comes back as HTML; a path with an extension is one of the
51
+ // revision's own files (its stylesheets, scripts, images), passed through
52
+ // with its type so the page shows the way it was built.
53
+ const type = r.headers.get("content-type") ?? "application/octet-stream";
54
+ return new Response(r.body, { headers: { "content-type": type, "cache-control": "no-store" } });
55
+ }
56
+ const r = await c.call("/releases");
57
+ if (!r.ok) return json({ error: "The revisions could not be read." }, 502);
58
+ const body = (await r.json().catch(() => ({}))) as { releases?: Array<{ deployId: string; at: string; pages?: number; restoredFrom?: string }> };
59
+ return json({ revisions: (body.releases ?? []).map((x) => ({ id: x.deployId, at: x.at, pages: x.pages ?? null, restoredFrom: x.restoredFrom ?? null })) }, 200, { "cache-control": "no-store" });
60
+ };
61
+
62
+ export const POST: APIRoute = async (context) => {
63
+ const c = await control(context);
64
+ if (c instanceof Response) return c;
65
+ const body = await readJson<{ action?: unknown; id?: unknown; keepContent?: unknown }>(context.request);
66
+ if (body.action === "publish") {
67
+ const r = await c.call("/publish-live", { method: "POST" });
68
+ return json(await r.json().catch(() => ({})), r.ok ? 200 : 502);
69
+ }
70
+ if (body.action === "restore") {
71
+ if (typeof body.id !== "string" || !/^[0-9a-f-]{36}$/i.test(body.id)) return json({ error: "bad revision id" }, 400);
72
+ const r = await c.call(`/releases/${body.id}/restore`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ keepContent: body.keepContent === true }) });
73
+ return json(await r.json().catch(() => ({})), r.ok ? 200 : 502);
74
+ }
75
+ return json({ error: "action must be publish or restore" }, 400);
76
+ };
77
+
78
+ export const ALL = methodNotAllowed("GET, POST");
package/src/version.ts CHANGED
@@ -1,3 +1,3 @@
1
1
  /** Kept in step with package.json by the release checklist in MAINTAINING.md. */
2
- export const VERSION = "0.9.1";
2
+ export const VERSION = "0.10.1";
3
3
  export const PLUGIN_ID = "tidy-platform";