@tideworthy/tidysites-platform 0.9.0 → 0.10.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 +4 -0
- package/README.md +4 -0
- package/package.json +3 -2
- package/src/editor/TidyEditor.astro +18 -8
- package/src/editor/revisions.ts +154 -0
- package/src/editor/runtime.ts +4 -2
- package/src/index.ts +2 -1
- package/src/routes/revisions.ts +74 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.1
|
|
4
|
+
|
|
5
|
+
- Editor: one editor on the site. An article page (a post, a service area, a page with the article layout) gets the bar too, with Publish from the entry's draft state, Business details and Done, over EmDash's inline prose editing; EmDash's own pill is hidden under the bar everywhere. Inside sections and record regions this editor owns the fields and EmDash's inline editor does not mount; outside them (article prose) it keeps working.
|
|
6
|
+
|
|
3
7
|
## 0.9.0
|
|
4
8
|
|
|
5
9
|
- Editor: sections are an EmDash `blocks` field (`sections` by default), and the forms come from the seed's block type definitions (`slug`, `type`, `validation.options`, `subFields`) instead of Block Kit shapes. Fields render by EmDash type: string, text, url, number, integer, boolean, datetime, select, multiSelect, image (the media picker, storing EmDash's media value) and repeater; Portable Text and file fields say "edit in the admin". New blocks carry no `_version` so EmDash stamps the active one; a saved block keeps its `_type`, `_key` and `_version`, and fields the form read as empty are left out rather than written as null.
|
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.
|
|
3
|
+
"version": "0.10.0",
|
|
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",
|
|
@@ -9,10 +9,10 @@
|
|
|
9
9
|
* block with `data-tidy-block` (its key) and `data-tidy-type`, and wraps the
|
|
10
10
|
* list in `data-tidy-blocks`. A record region (`data-tidy-record`,
|
|
11
11
|
* `data-tidy-record-id`, `data-tidy-record-label`) gets an "Edit details"
|
|
12
|
-
* chip whose form comes from EmDash's manifest. A page without
|
|
13
|
-
* article) gets the bar
|
|
14
|
-
*
|
|
15
|
-
*
|
|
12
|
+
* chip whose form comes from EmDash's manifest. A page without sections (an
|
|
13
|
+
* article) gets the bar too: Publish, Business details and Done over EmDash's
|
|
14
|
+
* inline prose editing, whose own pill is always hidden under the bar, so the
|
|
15
|
+
* site has one editor. The bar sets `--tidy-bar` on <html> (its
|
|
16
16
|
* height) so a template's sticky header can sit below it.
|
|
17
17
|
*/
|
|
18
18
|
import type { Block, BlockTypeSchema } from "./ops";
|
|
@@ -35,9 +35,12 @@ const signedIn = Boolean(Astro.session ? await Astro.session.get("user") : null)
|
|
|
35
35
|
const active = Astro.cookies.get("emdash-edit-mode")?.value === "true";
|
|
36
36
|
const here = Astro.url.pathname + Astro.url.search;
|
|
37
37
|
const toggle = (on: boolean) => `/_tidy/edit?on=${on ? 1 : 0}&to=${encodeURIComponent(here)}`;
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
// Any entry page is editable: a sections page gets the frames and the picker,
|
|
39
|
+
// an article page gets the bar (Publish, Business details, Done) over EmDash's
|
|
40
|
+
// inline prose editing. One editor on the site, never two.
|
|
41
|
+
const editable = active && Boolean(collection && id);
|
|
42
|
+
const config = editable ? JSON.stringify({ collection, id, field, blocks: blocks ?? [], value: value ?? [], records, done: toggle(false) }) : null;
|
|
43
|
+
const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : "tidy-has-bar";
|
|
41
44
|
---
|
|
42
45
|
{signedIn && (
|
|
43
46
|
<>
|
|
@@ -49,6 +52,7 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
49
52
|
</div>
|
|
50
53
|
</div>
|
|
51
54
|
<script is:inline define:vars={{ htmlClass }}>document.documentElement.className += " " + htmlClass;</script>
|
|
55
|
+
<script src="./revisions.ts"></script>
|
|
52
56
|
</>
|
|
53
57
|
)}
|
|
54
58
|
{config && (
|
|
@@ -58,7 +62,8 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
58
62
|
</>
|
|
59
63
|
)}
|
|
60
64
|
<style is:global>
|
|
61
|
-
|
|
65
|
+
/* EmDash's own pill never shows under the bar; its inline prose editing keeps working underneath. */
|
|
66
|
+
html.tidy-has-bar #emdash-toolbar { display: none !important; }
|
|
62
67
|
html.tidy-has-bar { --tidy-bar: 44px; }
|
|
63
68
|
html.tidy-has-bar body { padding-top: var(--tidy-bar); }
|
|
64
69
|
.tidy-bar { position: fixed; top: 0; left: 0; right: 0; height: var(--tidy-bar, 44px); z-index: 2147483000; display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 0 16px; box-sizing: border-box; background: #17211c; color: #fff; font: 500 13px/1 system-ui, sans-serif; box-shadow: 0 1px 0 rgba(255,255,255,.08), 0 6px 20px rgba(0,0,0,.18); }
|
|
@@ -115,4 +120,9 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
115
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; }
|
|
116
121
|
.tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
|
|
117
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; }
|
|
118
128
|
</style>
|
|
@@ -0,0 +1,154 @@
|
|
|
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 assets by absolute path; a base tag resolves them against the live site, where they still are.
|
|
104
|
+
const html = await r.text();
|
|
105
|
+
frame.srcdoc = html.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>`);
|
|
106
|
+
}
|
|
107
|
+
viewer.hidden = false;
|
|
108
|
+
restoreBtn.onclick = async () => {
|
|
109
|
+
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;
|
|
110
|
+
restoreBtn.disabled = true;
|
|
111
|
+
restoreBtn.textContent = "Restoring";
|
|
112
|
+
const p = await fetch(ROUTE, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "restore", id: rev.id }) });
|
|
113
|
+
if (p.ok) location.href = here;
|
|
114
|
+
else {
|
|
115
|
+
restoreBtn.disabled = false;
|
|
116
|
+
restoreBtn.textContent = "Restore failed. Try again";
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
revBtn.addEventListener("click", async () => {
|
|
122
|
+
if (!panel.hidden) return close();
|
|
123
|
+
try {
|
|
124
|
+
await load();
|
|
125
|
+
renderList();
|
|
126
|
+
} catch {
|
|
127
|
+
panel.replaceChildren(el("p", { class: "tidy-field-note", text: "The revisions could not be read right now." }));
|
|
128
|
+
panel.hidden = false;
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
liveBtn.addEventListener("click", async () => {
|
|
133
|
+
if (!confirm("Publish the website?\n\nEverything on staging goes live, every change made since the last publish.")) return;
|
|
134
|
+
liveBtn.disabled = true;
|
|
135
|
+
liveBtn.textContent = "Publishing";
|
|
136
|
+
const p = await fetch(ROUTE, { method: "POST", credentials: "same-origin", headers: { "content-type": "application/json" }, body: JSON.stringify({ action: "publish" }) });
|
|
137
|
+
liveBtn.disabled = false;
|
|
138
|
+
liveBtn.textContent = p.ok ? "Published" : "Publish failed";
|
|
139
|
+
setTimeout(() => (liveBtn.textContent = "Publish live"), 4000);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// Deep links from the platform.
|
|
143
|
+
const params = new URLSearchParams(location.search);
|
|
144
|
+
const want = params.get("tidy-revision");
|
|
145
|
+
if (want || params.get("tidy-revisions")) {
|
|
146
|
+
void load().then((list) => {
|
|
147
|
+
const rev = want ? list.find((x) => x.id === want) : null;
|
|
148
|
+
if (rev) void view(rev, list[0]?.id === rev.id);
|
|
149
|
+
else renderList();
|
|
150
|
+
}).catch(() => undefined);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
startRevisions();
|
package/src/editor/runtime.ts
CHANGED
|
@@ -99,9 +99,11 @@ export function start(config: Config): void {
|
|
|
99
99
|
const schemaOf = (type: string) => config.blocks.find((b) => b.slug === type);
|
|
100
100
|
const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
|
|
101
101
|
|
|
102
|
-
//
|
|
102
|
+
// Inside the sections and the record regions this editor owns the fields, so
|
|
103
|
+
// EmDash's inline editor must not mount there; article prose outside them
|
|
104
|
+
// keeps EmDash's in-place editing under this bar.
|
|
103
105
|
document.documentElement.classList.add("tidy-editing");
|
|
104
|
-
document.querySelectorAll("[data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
|
|
106
|
+
document.querySelectorAll("[data-tidy-blocks] [data-emdash-ref], [data-tidy-record] [data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
|
|
105
107
|
|
|
106
108
|
// --- pending publishes ---------------------------------------------------
|
|
107
109
|
// Records whose publish failed after a save; the page itself is read from EmDash.
|
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,74 @@
|
|
|
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
|
+
return new Response(await r.text(), { headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
|
|
51
|
+
}
|
|
52
|
+
const r = await c.call("/releases");
|
|
53
|
+
if (!r.ok) return json({ error: "The revisions could not be read." }, 502);
|
|
54
|
+
const body = (await r.json().catch(() => ({}))) as { releases?: Array<{ deployId: string; at: string; pages?: number; restoredFrom?: string }> };
|
|
55
|
+
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" });
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const POST: APIRoute = async (context) => {
|
|
59
|
+
const c = await control(context);
|
|
60
|
+
if (c instanceof Response) return c;
|
|
61
|
+
const body = await readJson<{ action?: unknown; id?: unknown; keepContent?: unknown }>(context.request);
|
|
62
|
+
if (body.action === "publish") {
|
|
63
|
+
const r = await c.call("/publish-live", { method: "POST" });
|
|
64
|
+
return json(await r.json().catch(() => ({})), r.ok ? 200 : 502);
|
|
65
|
+
}
|
|
66
|
+
if (body.action === "restore") {
|
|
67
|
+
if (typeof body.id !== "string" || !/^[0-9a-f-]{36}$/i.test(body.id)) return json({ error: "bad revision id" }, 400);
|
|
68
|
+
const r = await c.call(`/releases/${body.id}/restore`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ keepContent: body.keepContent === true }) });
|
|
69
|
+
return json(await r.json().catch(() => ({})), r.ok ? 200 : 502);
|
|
70
|
+
}
|
|
71
|
+
return json({ error: "action must be publish or restore" }, 400);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export const ALL = methodNotAllowed("GET, POST");
|
package/src/version.ts
CHANGED