@tideworthy/tidysites-platform 0.8.0 → 0.9.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 +6 -0
- package/README.md +9 -5
- package/package.json +2 -1
- package/src/editor/TidyEditor.astro +9 -4
- package/src/editor/ops.ts +86 -32
- package/src/editor/runtime.ts +147 -94
- package/src/index.ts +1 -0
- package/src/lib/handlers.ts +36 -0
- package/src/lib/runtime.ts +9 -0
- package/src/routes/schema.ts +10 -0
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.9.0
|
|
4
|
+
|
|
5
|
+
- 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.
|
|
6
|
+
- Editor: Publish reads the page's draft state from EmDash (`compare`), so a draft an agent staged over the API is publishable from the page; the count no longer depends on this browser's session.
|
|
7
|
+
- `POST /_tidy/schema`: applies the template seed's block types, collections, fields and relations to a bootstrapped site (`onConflict: "update"`, no content, settings or menus). How a template schema change reaches a site provisioned before it. Refuses before bootstrap (409).
|
|
8
|
+
|
|
3
9
|
## 0.8.0
|
|
4
10
|
|
|
5
11
|
- EmDash 1.0: the peer range is `>=1.0.0 <2.0.0` for `emdash` and `@emdash-cms/auth` (was 0.37 only). Nothing in the package changed: every export it depends on (`emdash/runtime` `getDb`, `emdash/seed` `loadSeed`, `emdash/middleware` `runScheduledTasks`, the repositories, `applySeed`, the auth token helpers) is still public in 1.0.1; only the `emdash/middleware/*` and `emdash/db/*-migrations` subpaths moved under `emdash/internal/*`, and this package never used them. Proven on a 0.37 database: the 1.0.1 runtime applied its 15 migrations on the first request (73 to 88), pages, sessions, editing, fill, maintenance and the content API all answered as before.
|
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@ It is the only code in a Tidysites template that knows about the platform, and i
|
|
|
10
10
|
npm install @tideworthy/tidysites-platform
|
|
11
11
|
```
|
|
12
12
|
|
|
13
|
-
Peer dependencies: `astro` 7, `emdash` and `@emdash-cms/auth`
|
|
13
|
+
Peer dependencies: `astro` 7, `emdash` and `@emdash-cms/auth` 1.x, `kysely`. Published on npm as `@tideworthy/tidysites-platform`; GitHub tags `vX.Y.Z` match each version.
|
|
14
14
|
|
|
15
15
|
## Wire it up
|
|
16
16
|
|
|
@@ -45,17 +45,17 @@ EmDash answers every page with `X-Frame-Options: SAMEORIGIN` unless a Content-Se
|
|
|
45
45
|
|
|
46
46
|
## The on-site editor
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
A template built from sections (an EmDash `blocks` field) uses the Tideworthy editor on the site:
|
|
49
49
|
|
|
50
50
|
```astro
|
|
51
51
|
---
|
|
52
52
|
import TidyEditor from "@tideworthy/tidysites-platform/editor";
|
|
53
|
-
|
|
53
|
+
import seed from "../../seed/seed.json";
|
|
54
54
|
---
|
|
55
|
-
|
|
55
|
+
<TidyEditor collection="pages" id={page.data.id} field="sections" blocks={seed.blockTypes} value={page.data.sections} records={[{ collection: "business", id, label: "Business details" }]} />
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
The template renders the
|
|
58
|
+
The template renders the sections inside `<div data-tidy-blocks>` and each one inside `<div data-tidy-block={_key} data-tidy-type={_type}>`. `blocks` is the seed's `blockTypes` (the same definitions EmDash's admin and MCP read); the form for a block comes from its retained version's fields. Hovering a block shows a chip (Edit, move, add below, remove); Edit opens the panel; Save writes the field through EmDash's content API with the signed-in editor's session, as a draft, and reloads. Publish appears when EmDash says the page has a draft (so a draft an agent staged over the API is publishable from the page) and publishes it. Image fields get a picker over the media library with upload and store EmDash's media value. Records: wrap a region in `data-tidy-record="services" data-tidy-record-id="…" data-tidy-record-label="Service details"` and pass `records` to the component; the form comes from EmDash's manifest and saves publish at once. Pass the business record in `records` and it is always one click away in the bar. A section that lists records (a services grid) wraps each card in the same `data-tidy-record` attributes, so the card edits its record and the section's chip edits the section. On these pages EmDash's toolbar is hidden and its inline editing is off; article bodies keep EmDash's inline editor by not passing a sections field.
|
|
59
59
|
|
|
60
60
|
## Bindings the routes read
|
|
61
61
|
|
|
@@ -125,6 +125,10 @@ Writes real content over the template's sample content on a bootstrapped site. S
|
|
|
125
125
|
|
|
126
126
|
Entries are upserted by slug (`slug` defaults to `id`). An existing entry keeps every field the document does not name, so a fill can send only what it knows and the template's images and blocks stay underneath. `remove` deletes the named sample entries; slugs written by the same call are never removed, and unknown slugs are reported back in `missing`. Refuses with 409 before bootstrap. Returns `{ ok, settings, content: { created, updated, media }, removed, missing }`. Re-running the same document is safe.
|
|
127
127
|
|
|
128
|
+
### `POST /_tidy/schema`
|
|
129
|
+
|
|
130
|
+
Applies the template seed's structure to a site that is already set up: block types (new ones created, compatible versions amended), collections and their fields (existing ones updated to the template's definition, missing ones added, nothing deleted) and relations. Entries, settings, menus, taxonomies and sections are untouched. Refuses with 409 before bootstrap. Returns `{ ok, applied }` with EmDash's counts. This is how a template schema change reaches a site provisioned before it; the platform calls it before deploying the new template artifact.
|
|
131
|
+
|
|
128
132
|
### `POST /_tidy/maintenance?cron=…`
|
|
129
133
|
|
|
130
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.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tideworthy/tidysites-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"./routes/health": "./src/routes/health.ts",
|
|
30
30
|
"./routes/rotate-token": "./src/routes/rotate-token.ts",
|
|
31
31
|
"./routes/fill": "./src/routes/fill.ts",
|
|
32
|
+
"./routes/schema": "./src/routes/schema.ts",
|
|
32
33
|
"./routes/maintenance": "./src/routes/maintenance.ts"
|
|
33
34
|
},
|
|
34
35
|
"scripts": {
|
|
@@ -15,20 +15,22 @@
|
|
|
15
15
|
* the bar is the one control. The bar sets `--tidy-bar` on <html> (its
|
|
16
16
|
* height) so a template's sticky header can sit below it.
|
|
17
17
|
*/
|
|
18
|
-
import type { Block,
|
|
18
|
+
import type { Block, BlockTypeSchema } from "./ops";
|
|
19
19
|
|
|
20
20
|
interface Props {
|
|
21
21
|
collection?: string;
|
|
22
22
|
id?: string;
|
|
23
|
+
/** The entry's `blocks` field the sections live in; "sections" by default. */
|
|
23
24
|
field?: string;
|
|
24
|
-
|
|
25
|
+
/** The block types a section may be (the seed's `blockTypes`, filtered to what the field allows). */
|
|
26
|
+
blocks?: BlockTypeSchema[];
|
|
25
27
|
value?: Block[] | null;
|
|
26
28
|
/** Records reachable from the bar (the business) or from `[data-tidy-record]` regions on the page. */
|
|
27
29
|
records?: Array<{ collection: string; id: string; label: string }>;
|
|
28
30
|
/** What the bar calls this site; "Staging" by default. */
|
|
29
31
|
label?: string;
|
|
30
32
|
}
|
|
31
|
-
const { collection, id, field = "
|
|
33
|
+
const { collection, id, field = "sections", blocks, value, records = [], label = "Staging" } = Astro.props;
|
|
32
34
|
const signedIn = Boolean(Astro.session ? await Astro.session.get("user") : null);
|
|
33
35
|
const active = Astro.cookies.get("emdash-edit-mode")?.value === "true";
|
|
34
36
|
const here = Astro.url.pathname + Astro.url.search;
|
|
@@ -96,9 +98,12 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
96
98
|
.tidy-form { display: grid; gap: 14px; }
|
|
97
99
|
.tidy-field { display: grid; gap: 6px; }
|
|
98
100
|
.tidy-field-label { font-weight: 600; font-size: 13px; }
|
|
99
|
-
.tidy-field input[type="text"], .tidy-field input[type="number"], .tidy-field textarea, .tidy-field select { width: 100%; box-sizing: border-box; border: 1px solid #c9d2cc; border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; color: inherit; }
|
|
101
|
+
.tidy-field input[type="text"], .tidy-field input[type="url"], .tidy-field input[type="number"], .tidy-field input[type="datetime-local"], .tidy-field textarea, .tidy-field select { width: 100%; box-sizing: border-box; border: 1px solid #c9d2cc; border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; color: inherit; }
|
|
100
102
|
.tidy-field textarea { resize: vertical; min-height: 88px; }
|
|
101
103
|
.tidy-field-toggle { display: flex; align-items: center; gap: 10px; }
|
|
104
|
+
.tidy-field-note { margin: 0; color: #4a5851; font-size: 13px; }
|
|
105
|
+
.tidy-multi { display: grid; gap: 6px; }
|
|
106
|
+
.tidy-multi-item { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
|
102
107
|
.tidy-repeater { border: 1px solid #d3dcd6; border-radius: 8px; padding: 12px; display: grid; gap: 12px; }
|
|
103
108
|
.tidy-repeater legend { font-weight: 600; padding: 0 6px; }
|
|
104
109
|
.tidy-repeater-item { display: grid; gap: 10px; padding: 12px; background: #f6f7f5; border-radius: 6px; }
|
package/src/editor/ops.ts
CHANGED
|
@@ -1,28 +1,48 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Pure operations on a page's
|
|
3
|
-
* blocks
|
|
4
|
-
*
|
|
2
|
+
* Pure operations on a page's section list: an EmDash `blocks` field value,
|
|
3
|
+
* an array of typed blocks each carrying `_type`, `_version` and `_key`. The
|
|
4
|
+
* schemas are EmDash block type definitions (the seed's `blockTypes`), so the
|
|
5
|
+
* editor's forms, the admin's block editor and the MCP all read one contract.
|
|
6
|
+
* The editor runtime calls these; the tests cover them without a browser.
|
|
5
7
|
*/
|
|
6
|
-
export type Block = { _type: string; _key?: string } & Record<string, unknown>;
|
|
8
|
+
export type Block = { _type: string; _key?: string; _version?: number } & Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
/** The field types a block definition may use (EmDash 1.0). */
|
|
11
|
+
export type FieldType = "string" | "text" | "url" | "number" | "integer" | "boolean" | "datetime" | "select" | "multiSelect" | "portableText" | "image" | "file" | "repeater";
|
|
7
12
|
|
|
8
13
|
export interface FieldSchema {
|
|
9
|
-
|
|
10
|
-
action_id: string;
|
|
14
|
+
slug: string;
|
|
11
15
|
label: string;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
16
|
+
type: FieldType | string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
defaultValue?: unknown;
|
|
19
|
+
description?: string;
|
|
20
|
+
validation?: {
|
|
21
|
+
options?: string[];
|
|
22
|
+
subFields?: FieldSchema[];
|
|
23
|
+
minItems?: number;
|
|
24
|
+
maxItems?: number;
|
|
25
|
+
[key: string]: unknown;
|
|
26
|
+
};
|
|
18
27
|
}
|
|
19
28
|
|
|
20
|
-
|
|
21
|
-
|
|
29
|
+
/** One block type as the seed declares it. */
|
|
30
|
+
export interface BlockTypeSchema {
|
|
31
|
+
slug: string;
|
|
22
32
|
label: string;
|
|
23
|
-
category?: string;
|
|
24
33
|
description?: string;
|
|
25
|
-
|
|
34
|
+
category?: string;
|
|
35
|
+
currentVersion: number;
|
|
36
|
+
versions: Array<{ version: number; fields: FieldSchema[] }>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Field types the editor renders a control for; the rest are read-only here and edited in the admin. */
|
|
40
|
+
export const EDITABLE_TYPES: ReadonlySet<string> = new Set(["string", "text", "url", "number", "integer", "boolean", "datetime", "select", "multiSelect", "image", "repeater"]);
|
|
41
|
+
|
|
42
|
+
/** The fields of one retained version, or of the active version. */
|
|
43
|
+
export function fieldsOf(type: BlockTypeSchema, version?: number): FieldSchema[] {
|
|
44
|
+
const v = version ?? type.currentVersion;
|
|
45
|
+
return type.versions.find((x) => x.version === v)?.fields ?? type.versions.find((x) => x.version === type.currentVersion)?.fields ?? [];
|
|
26
46
|
}
|
|
27
47
|
|
|
28
48
|
export function newKey(random: () => number = Math.random): string {
|
|
@@ -35,9 +55,9 @@ export function findBlock(blocks: Block[], key: string): Block | undefined {
|
|
|
35
55
|
return blocks.find((b) => b._key === key);
|
|
36
56
|
}
|
|
37
57
|
|
|
38
|
-
/** The block with `key` gets `data` merged over it; `_type` and `
|
|
58
|
+
/** The block with `key` gets `data` merged over it; `_type`, `_key` and `_version` never change. */
|
|
39
59
|
export function updateBlock(blocks: Block[], key: string, data: Record<string, unknown>): Block[] {
|
|
40
|
-
return blocks.map((b) => (b._key === key ? { ...b, ...data, _type: b._type, _key: b._key } : b));
|
|
60
|
+
return blocks.map((b) => (b._key === key ? { ...b, ...data, _type: b._type, _key: b._key, ...(b._version !== undefined ? { _version: b._version } : {}) } : b));
|
|
41
61
|
}
|
|
42
62
|
|
|
43
63
|
export function moveBlock(blocks: Block[], key: string, direction: -1 | 1): Block[] {
|
|
@@ -55,7 +75,10 @@ export function removeBlock(blocks: Block[], key: string): Block[] {
|
|
|
55
75
|
return blocks.filter((b) => b._key !== key);
|
|
56
76
|
}
|
|
57
77
|
|
|
58
|
-
/**
|
|
78
|
+
/**
|
|
79
|
+
* Insert a new block of `type` after the block with `afterKey` (or at the end).
|
|
80
|
+
* No `_version`: EmDash stamps a new block with the type's active version.
|
|
81
|
+
*/
|
|
59
82
|
export function insertBlock(blocks: Block[], type: string, afterKey: string | null, data: Record<string, unknown>, key = newKey()): Block[] {
|
|
60
83
|
const block: Block = { ...data, _type: type, _key: key };
|
|
61
84
|
const i = afterKey ? blocks.findIndex((b) => b._key === afterKey) : -1;
|
|
@@ -63,32 +86,63 @@ export function insertBlock(blocks: Block[], type: string, afterKey: string | nu
|
|
|
63
86
|
return [...blocks.slice(0, i + 1), block, ...blocks.slice(i + 1)];
|
|
64
87
|
}
|
|
65
88
|
|
|
66
|
-
/**
|
|
67
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Starting values for a new block: the declared default where there is one,
|
|
91
|
+
* an empty string for text, false for a toggle, an empty list for a repeater,
|
|
92
|
+
* the first option for a select, and nothing at all for a number, image,
|
|
93
|
+
* file, date or rich text (EmDash accepts an absent optional field; it does
|
|
94
|
+
* not accept an empty string or null in a typed one).
|
|
95
|
+
*/
|
|
96
|
+
export function defaultsFor(type: BlockTypeSchema): Record<string, unknown> {
|
|
68
97
|
const out: Record<string, unknown> = {};
|
|
69
|
-
for (const f of
|
|
70
|
-
if (f.
|
|
71
|
-
else if (f.type === "
|
|
72
|
-
else if (f.type === "
|
|
73
|
-
else if (f.type === "
|
|
74
|
-
|
|
98
|
+
for (const f of fieldsOf(type)) {
|
|
99
|
+
if (f.defaultValue !== undefined) out[f.slug] = structuredClone(f.defaultValue);
|
|
100
|
+
else if (f.type === "boolean") out[f.slug] = false;
|
|
101
|
+
else if (f.type === "repeater" || f.type === "multiSelect") out[f.slug] = [];
|
|
102
|
+
else if (f.type === "select") {
|
|
103
|
+
const first = f.validation?.options?.[0];
|
|
104
|
+
if (first !== undefined) out[f.slug] = first;
|
|
105
|
+
} else if (f.type === "string" || f.type === "text" || f.type === "url") out[f.slug] = "";
|
|
75
106
|
}
|
|
76
107
|
return out;
|
|
77
108
|
}
|
|
78
109
|
|
|
79
|
-
/**
|
|
110
|
+
/**
|
|
111
|
+
* Coerce a raw form value into the field's stored type. Returns `undefined`
|
|
112
|
+
* for "no value" in a typed field so the key is left out of the saved block.
|
|
113
|
+
*/
|
|
80
114
|
export function coerce(field: FieldSchema, raw: unknown): unknown {
|
|
81
115
|
switch (field.type) {
|
|
82
|
-
case "
|
|
116
|
+
case "boolean":
|
|
83
117
|
return raw === true || raw === "on" || raw === "true";
|
|
84
|
-
case "
|
|
85
|
-
|
|
118
|
+
case "number":
|
|
119
|
+
case "integer": {
|
|
120
|
+
if (raw === "" || raw === null || raw === undefined) return undefined;
|
|
86
121
|
const n = Number(raw);
|
|
87
|
-
|
|
122
|
+
if (!Number.isFinite(n)) return undefined;
|
|
123
|
+
return field.type === "integer" ? Math.trunc(n) : n;
|
|
88
124
|
}
|
|
125
|
+
case "select":
|
|
126
|
+
return typeof raw === "string" && raw !== "" ? raw : undefined;
|
|
127
|
+
case "multiSelect":
|
|
128
|
+
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : [];
|
|
89
129
|
case "repeater":
|
|
90
130
|
return Array.isArray(raw) ? raw : [];
|
|
131
|
+
case "image":
|
|
132
|
+
case "file":
|
|
133
|
+
return raw && typeof raw === "object" ? raw : undefined;
|
|
134
|
+
case "datetime":
|
|
135
|
+
return typeof raw === "string" && raw !== "" ? raw : undefined;
|
|
136
|
+
case "portableText":
|
|
137
|
+
return Array.isArray(raw) ? raw : undefined;
|
|
91
138
|
default:
|
|
92
139
|
return typeof raw === "string" ? raw : raw === null || raw === undefined ? "" : String(raw);
|
|
93
140
|
}
|
|
94
141
|
}
|
|
142
|
+
|
|
143
|
+
/** Drop keys whose value is `undefined` so an absent optional field stays absent. */
|
|
144
|
+
export function compact(data: Record<string, unknown>): Record<string, unknown> {
|
|
145
|
+
const out: Record<string, unknown> = {};
|
|
146
|
+
for (const [k, v] of Object.entries(data)) if (v !== undefined) out[k] = v;
|
|
147
|
+
return out;
|
|
148
|
+
}
|
package/src/editor/runtime.ts
CHANGED
|
@@ -3,21 +3,25 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* The Tideworthy on-site editor. Runs on a page rendered in edit mode.
|
|
5
5
|
*
|
|
6
|
-
* Sections: every
|
|
7
|
-
* by the template); hovering shows a chip with
|
|
8
|
-
* and Edit opens a panel whose form comes from
|
|
6
|
+
* Sections: every block of the page's `blocks` field sits in a frame
|
|
7
|
+
* (`[data-tidy-block]`, rendered by the template); hovering shows a chip with
|
|
8
|
+
* Edit, Up, Down, Add and Remove, and Edit opens a panel whose form comes from
|
|
9
|
+
* the block type's field definitions (the same ones EmDash's admin and MCP
|
|
10
|
+
* read).
|
|
9
11
|
*
|
|
10
12
|
* Records: a region wrapped in `[data-tidy-record]` (a service's header, say)
|
|
11
13
|
* gets an "Edit … details" chip whose form comes from EmDash's manifest for
|
|
12
|
-
* that collection; the business record is always reachable from the
|
|
14
|
+
* that collection; the business record is always reachable from the bar.
|
|
13
15
|
*
|
|
14
|
-
* Images:
|
|
15
|
-
*
|
|
16
|
+
* Images: an `image` field, in a block or a record, gets a picker over the
|
|
17
|
+
* site's media library with upload and stores EmDash's media value.
|
|
16
18
|
*
|
|
17
|
-
* Saves go through EmDash's content API with the editor's session, as drafts
|
|
18
|
-
* Publish
|
|
19
|
+
* Saves go through EmDash's content API with the editor's session, as drafts.
|
|
20
|
+
* Publish reads the entry's draft state from EmDash (so a draft an agent
|
|
21
|
+
* staged over the API is publishable from the page too) and publishes it,
|
|
22
|
+
* plus any record whose publish failed in this browser session.
|
|
19
23
|
*/
|
|
20
|
-
import { coerce, defaultsFor, insertBlock, moveBlock, removeBlock, updateBlock, type Block, type
|
|
24
|
+
import { EDITABLE_TYPES, coerce, compact, defaultsFor, fieldsOf, insertBlock, moveBlock, removeBlock, updateBlock, type Block, type BlockTypeSchema, type FieldSchema } from "./ops";
|
|
21
25
|
|
|
22
26
|
interface RecordRef {
|
|
23
27
|
collection: string;
|
|
@@ -29,7 +33,7 @@ interface Config {
|
|
|
29
33
|
collection: string;
|
|
30
34
|
id: string;
|
|
31
35
|
field: string;
|
|
32
|
-
blocks:
|
|
36
|
+
blocks: BlockTypeSchema[];
|
|
33
37
|
value: Block[];
|
|
34
38
|
records?: RecordRef[];
|
|
35
39
|
/** Where "Done" goes: /_tidy/edit switching editing off and returning here. */
|
|
@@ -56,9 +60,8 @@ interface MediaItem {
|
|
|
56
60
|
const API = "/_emdash/api";
|
|
57
61
|
const CSRF = { "X-EmDash-Request": "1" };
|
|
58
62
|
const PENDING_KEY = "tidy-pending";
|
|
59
|
-
const IMAGE_FIELD = /(image|photo|logo|picture)url$/i;
|
|
60
63
|
/** Record fields the panel never shows: system, structural, or edited elsewhere. */
|
|
61
|
-
const HIDDEN_RECORD_FIELDS = new Set(["content", "sort", "brand_primary", "brand_accent", "font_display", "font_body"]);
|
|
64
|
+
const HIDDEN_RECORD_FIELDS = new Set(["content", "sections", "sort", "brand_primary", "brand_accent", "font_display", "font_body"]);
|
|
62
65
|
|
|
63
66
|
function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
|
|
64
67
|
const e = document.createElement(tag);
|
|
@@ -75,18 +78,25 @@ function mediaUrl(m: MediaItem): string {
|
|
|
75
78
|
return m.url ?? `${API}/media/file/${m.storageKey}`;
|
|
76
79
|
}
|
|
77
80
|
|
|
78
|
-
/** The value an image
|
|
81
|
+
/** The value an image field stores: EmDash's media value for a library item. */
|
|
79
82
|
function mediaValue(m: MediaItem): Record<string, unknown> {
|
|
80
83
|
return { provider: "local", id: m.id, alt: m.alt ?? "", width: m.width ?? undefined, height: m.height ?? undefined, mimeType: m.mimeType, filename: m.filename, meta: { storageKey: m.storageKey } };
|
|
81
84
|
}
|
|
82
85
|
|
|
86
|
+
/** A URL to preview a stored image value, whatever shape it came in. */
|
|
87
|
+
function imageSrc(v: unknown): string | null {
|
|
88
|
+
if (!v || typeof v !== "object") return typeof v === "string" && v ? v : null;
|
|
89
|
+
const o = v as { src?: string; url?: string; meta?: { storageKey?: string } };
|
|
90
|
+
return o.src ?? o.url ?? (o.meta?.storageKey ? `${API}/media/file/${o.meta.storageKey}` : null);
|
|
91
|
+
}
|
|
92
|
+
|
|
83
93
|
function api(path: string, init: RequestInit = {}): Promise<Response> {
|
|
84
94
|
return fetch(`${API}${path}`, { credentials: "same-origin", ...init, headers: { ...CSRF, ...(init.headers as Record<string, string> | undefined) } });
|
|
85
95
|
}
|
|
86
96
|
|
|
87
97
|
export function start(config: Config): void {
|
|
88
98
|
let value: Block[] = config.value;
|
|
89
|
-
const schemaOf = (type: string) => config.blocks.find((b) => b.
|
|
99
|
+
const schemaOf = (type: string) => config.blocks.find((b) => b.slug === type);
|
|
90
100
|
const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
|
|
91
101
|
|
|
92
102
|
// EmDash's inline editor is for prose pages; here the sections and records own their fields.
|
|
@@ -94,8 +104,10 @@ export function start(config: Config): void {
|
|
|
94
104
|
document.querySelectorAll("[data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
|
|
95
105
|
|
|
96
106
|
// --- pending publishes ---------------------------------------------------
|
|
107
|
+
// Records whose publish failed after a save; the page itself is read from EmDash.
|
|
97
108
|
const pending = new Set<string>(JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? "[]") as string[]);
|
|
98
109
|
const persistPending = () => sessionStorage.setItem(PENDING_KEY, JSON.stringify([...pending]));
|
|
110
|
+
let pageHasDraft = false;
|
|
99
111
|
|
|
100
112
|
// --- the bar's controls --------------------------------------------------
|
|
101
113
|
const status = document.getElementById("tidy-bar-actions") ?? document.body.appendChild(el("div", { class: "tidy-bar-actions" }));
|
|
@@ -108,15 +120,29 @@ export function start(config: Config): void {
|
|
|
108
120
|
bizBtn.hidden = !business;
|
|
109
121
|
bizBtn.addEventListener("click", () => business && void openRecord(business));
|
|
110
122
|
function refreshStatus(text?: string) {
|
|
111
|
-
const n = pending.size;
|
|
123
|
+
const n = pending.size + (pageHasDraft ? 1 : 0);
|
|
112
124
|
statusText.textContent = text ?? (n === 0 ? "" : `${n} unpublished change${n === 1 ? "" : "s"}`);
|
|
113
125
|
publishBtn.hidden = n === 0 && !text?.startsWith("Publish failed");
|
|
114
126
|
}
|
|
115
127
|
refreshStatus();
|
|
128
|
+
/** Ask EmDash whether this page has a draft that differs from what is live. */
|
|
129
|
+
async function readDraftState(): Promise<void> {
|
|
130
|
+
try {
|
|
131
|
+
const r = await api(`/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}/compare`);
|
|
132
|
+
if (!r.ok) return;
|
|
133
|
+
const j = (await r.json().catch(() => ({}))) as { data?: { hasChanges?: boolean } };
|
|
134
|
+
pageHasDraft = j.data?.hasChanges === true;
|
|
135
|
+
refreshStatus();
|
|
136
|
+
} catch {
|
|
137
|
+
// the bar just shows no count
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
void readDraftState();
|
|
116
141
|
publishBtn.addEventListener("click", async () => {
|
|
117
142
|
publishBtn.disabled = true;
|
|
118
143
|
refreshStatus("Publishing");
|
|
119
|
-
const targets =
|
|
144
|
+
const targets = new Set<string>(pending);
|
|
145
|
+
if (pageHasDraft || targets.size === 0) targets.add(`${config.collection}/${config.id}`);
|
|
120
146
|
let failed = 0;
|
|
121
147
|
for (const t of targets) {
|
|
122
148
|
const [collection, id] = t.split("/") as [string, string];
|
|
@@ -133,16 +159,17 @@ export function start(config: Config): void {
|
|
|
133
159
|
});
|
|
134
160
|
|
|
135
161
|
/**
|
|
136
|
-
* Page
|
|
137
|
-
* them live. Record details (the business, a service) are read by
|
|
138
|
-
* pages from their published version, so a draft would look like
|
|
139
|
-
* happened: they save and publish in one step.
|
|
162
|
+
* Page sections save as a draft the page previews in edit mode; Publish
|
|
163
|
+
* makes them live. Record details (the business, a service) are read by
|
|
164
|
+
* many pages from their published version, so a draft would look like
|
|
165
|
+
* nothing happened: they save and publish in one step.
|
|
140
166
|
*/
|
|
141
167
|
async function saveEntry(collection: string, id: string, data: Record<string, unknown>, publishNow = false): Promise<boolean> {
|
|
142
168
|
refreshStatus("Saving");
|
|
143
169
|
const r = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ data }) });
|
|
144
170
|
if (!r.ok) {
|
|
145
|
-
|
|
171
|
+
const j = (await r.json().catch(() => ({}))) as { error?: { message?: string } };
|
|
172
|
+
refreshStatus(`Save failed (${j.error?.message ?? r.status})`);
|
|
146
173
|
return false;
|
|
147
174
|
}
|
|
148
175
|
if (publishNow) {
|
|
@@ -153,9 +180,6 @@ export function start(config: Config): void {
|
|
|
153
180
|
refreshStatus(`Saved; publish failed (${p.status})`);
|
|
154
181
|
return false;
|
|
155
182
|
}
|
|
156
|
-
} else {
|
|
157
|
-
pending.add(`${collection}/${id}`);
|
|
158
|
-
persistPending();
|
|
159
183
|
}
|
|
160
184
|
refreshStatus("Saved. Reloading");
|
|
161
185
|
location.reload();
|
|
@@ -301,7 +325,7 @@ export function start(config: Config): void {
|
|
|
301
325
|
});
|
|
302
326
|
|
|
303
327
|
// --- image picker -----------------------------------------------------------
|
|
304
|
-
/** An image control
|
|
328
|
+
/** An image control: preview, choose from the library, upload, remove. `onPick` receives the library item or null. */
|
|
305
329
|
function imageControl(label: string, currentUrl: string | null, onPick: (item: MediaItem | null) => void): HTMLElement {
|
|
306
330
|
const wrap = el("div", { class: "tidy-field tidy-image" }, [el("span", { class: "tidy-field-label", text: label })]);
|
|
307
331
|
const preview = el("div", { class: "tidy-image-preview" });
|
|
@@ -366,93 +390,120 @@ export function start(config: Config): void {
|
|
|
366
390
|
}
|
|
367
391
|
|
|
368
392
|
// --- block forms ---------------------------------------------------------
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
393
|
+
type Reader = () => unknown;
|
|
394
|
+
|
|
395
|
+
/** One control for a block field (or a repeater row's sub-field). Returns the element and a reader of its coerced value. */
|
|
396
|
+
function fieldControl(f: FieldSchema, v: unknown): { node: HTMLElement; read: Reader } {
|
|
397
|
+
const label = f.label || f.slug;
|
|
398
|
+
if (f.type === "image") {
|
|
399
|
+
let picked: unknown = v && typeof v === "object" ? v : undefined;
|
|
400
|
+
const node = imageControl(label, imageSrc(v), (item) => {
|
|
401
|
+
picked = item ? mediaValue(item) : undefined;
|
|
402
|
+
});
|
|
403
|
+
return { node, read: () => picked };
|
|
404
|
+
}
|
|
405
|
+
const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
|
|
406
|
+
if (f.type === "boolean") {
|
|
407
|
+
const cb = el("input", { type: "checkbox" });
|
|
373
408
|
cb.checked = v === true;
|
|
374
409
|
wrap.className = "tidy-field tidy-field-toggle";
|
|
375
410
|
wrap.prepend(cb);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
411
|
+
return { node: wrap, read: () => cb.checked };
|
|
412
|
+
}
|
|
413
|
+
if (f.type === "select") {
|
|
414
|
+
const s = el("select");
|
|
415
|
+
if (!f.required) s.append(el("option", { value: "", text: "" }));
|
|
416
|
+
for (const o of f.validation?.options ?? []) {
|
|
417
|
+
const opt = el("option", { value: o, text: o });
|
|
418
|
+
if (o === v) opt.selected = true;
|
|
381
419
|
s.append(opt);
|
|
382
420
|
}
|
|
383
421
|
wrap.append(s);
|
|
384
|
-
|
|
385
|
-
|
|
422
|
+
return { node: wrap, read: () => coerce(f, s.value) };
|
|
423
|
+
}
|
|
424
|
+
if (f.type === "multiSelect") {
|
|
425
|
+
const box = el("div", { class: "tidy-multi" });
|
|
426
|
+
const chosen = new Set(Array.isArray(v) ? (v as string[]) : []);
|
|
427
|
+
const boxes: HTMLInputElement[] = [];
|
|
428
|
+
for (const o of f.validation?.options ?? []) {
|
|
429
|
+
const cb = el("input", { type: "checkbox", value: o });
|
|
430
|
+
cb.checked = chosen.has(o);
|
|
431
|
+
boxes.push(cb);
|
|
432
|
+
box.append(el("label", { class: "tidy-multi-item" }, [cb, o]));
|
|
433
|
+
}
|
|
434
|
+
wrap.append(box);
|
|
435
|
+
return { node: wrap, read: () => boxes.filter((b) => b.checked).map((b) => b.value) };
|
|
436
|
+
}
|
|
437
|
+
if (f.type === "number" || f.type === "integer") {
|
|
438
|
+
const i = el("input", { type: "number", ...(f.type === "integer" ? { step: "1" } : { step: "any" }) });
|
|
386
439
|
i.value = typeof v === "number" ? String(v) : "";
|
|
387
440
|
wrap.append(i);
|
|
388
|
-
|
|
389
|
-
|
|
441
|
+
return { node: wrap, read: () => coerce(f, i.value) };
|
|
442
|
+
}
|
|
443
|
+
if (f.type === "datetime") {
|
|
444
|
+
const i = el("input", { type: "datetime-local" });
|
|
445
|
+
i.value = typeof v === "string" ? v.slice(0, 16) : "";
|
|
446
|
+
wrap.append(i);
|
|
447
|
+
return { node: wrap, read: () => (i.value ? new Date(i.value).toISOString() : undefined) };
|
|
448
|
+
}
|
|
449
|
+
if (f.type === "text") {
|
|
450
|
+
const t = el("textarea", { rows: "4" });
|
|
390
451
|
t.value = typeof v === "string" ? v : "";
|
|
391
452
|
wrap.append(t);
|
|
392
|
-
|
|
393
|
-
const i = el("input", { type: "text", name });
|
|
394
|
-
i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
|
|
395
|
-
wrap.append(i);
|
|
453
|
+
return { node: wrap, read: () => t.value };
|
|
396
454
|
}
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
function blockField(f: FieldSchema, v: unknown, name: string): HTMLElement {
|
|
402
|
-
if (f.type !== "text_input" || !IMAGE_FIELD.test(f.action_id)) return fieldInput(f, v, name);
|
|
403
|
-
const hidden = el("input", { type: "hidden", name });
|
|
404
|
-
hidden.value = typeof v === "string" ? v : "";
|
|
405
|
-
const control = imageControl(f.label, hidden.value || null, (item) => {
|
|
406
|
-
hidden.value = item ? mediaUrl(item) : "";
|
|
407
|
-
});
|
|
408
|
-
control.append(hidden);
|
|
409
|
-
return control;
|
|
455
|
+
const i = el("input", { type: f.type === "url" ? "url" : "text" });
|
|
456
|
+
i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
|
|
457
|
+
wrap.append(i);
|
|
458
|
+
return { node: wrap, read: () => i.value };
|
|
410
459
|
}
|
|
411
460
|
|
|
412
|
-
function buildBlockForm(
|
|
461
|
+
function buildBlockForm(type: BlockTypeSchema, block: Block): { form: HTMLFormElement; read: () => Record<string, unknown> } {
|
|
413
462
|
const form = el("form", { class: "tidy-form" });
|
|
414
|
-
const
|
|
415
|
-
for (const f of
|
|
463
|
+
const readers: Array<[string, Reader]> = [];
|
|
464
|
+
for (const f of fieldsOf(type, block._version)) {
|
|
465
|
+
if (!EDITABLE_TYPES.has(f.type)) {
|
|
466
|
+
form.append(el("p", { class: "tidy-field-note", text: `${f.label || f.slug}: edit in the admin` }));
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
416
469
|
if (f.type === "repeater") {
|
|
417
|
-
const
|
|
470
|
+
const sub = f.validation?.subFields ?? [];
|
|
471
|
+
const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label || f.slug })]);
|
|
418
472
|
const items = el("div", { class: "tidy-repeater-items" });
|
|
419
|
-
const rows = Array.isArray(
|
|
473
|
+
const rows = Array.isArray(block[f.slug]) ? (block[f.slug] as Record<string, unknown>[]) : [];
|
|
474
|
+
const rowReaders = new Map<HTMLElement, Array<[string, Reader]>>();
|
|
420
475
|
const addItem = (row: Record<string, unknown>) => {
|
|
421
476
|
const item = el("div", { class: "tidy-repeater-item" });
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
477
|
+
const rs: Array<[string, Reader]> = [];
|
|
478
|
+
for (const s of sub) {
|
|
479
|
+
const c = fieldControl(s, row[s.slug]);
|
|
480
|
+
item.append(c.node);
|
|
481
|
+
rs.push([s.slug, c.read]);
|
|
482
|
+
}
|
|
483
|
+
const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: "Remove" });
|
|
484
|
+
rm.addEventListener("click", () => {
|
|
485
|
+
rowReaders.delete(item);
|
|
486
|
+
item.remove();
|
|
487
|
+
});
|
|
425
488
|
item.append(rm);
|
|
426
489
|
items.append(item);
|
|
490
|
+
rowReaders.set(item, rs);
|
|
427
491
|
};
|
|
428
492
|
rows.forEach(addItem);
|
|
429
|
-
const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text:
|
|
493
|
+
const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Add" });
|
|
430
494
|
add.addEventListener("click", () => addItem({}));
|
|
431
495
|
box.append(items, add);
|
|
432
496
|
form.append(box);
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
}
|
|
436
|
-
const readScope = (scope: ParentNode, fields: FieldSchema[]): Record<string, unknown> => {
|
|
437
|
-
const out: Record<string, unknown> = {};
|
|
438
|
-
for (const f of fields) {
|
|
439
|
-
if (f.type === "repeater") continue;
|
|
440
|
-
const input = scope.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`);
|
|
441
|
-
out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
|
|
497
|
+
readers.push([f.slug, () => Array.from(items.children).map((item) => compact(Object.fromEntries((rowReaders.get(item as HTMLElement) ?? []).map(([k, r]) => [k, r()]))))]);
|
|
498
|
+
continue;
|
|
442
499
|
}
|
|
443
|
-
|
|
444
|
-
|
|
500
|
+
const c = fieldControl(f, block[f.slug]);
|
|
501
|
+
form.append(c.node);
|
|
502
|
+
readers.push([f.slug, c.read]);
|
|
503
|
+
}
|
|
445
504
|
const read = (): Record<string, unknown> => {
|
|
446
505
|
const out: Record<string, unknown> = {};
|
|
447
|
-
for (const
|
|
448
|
-
if (f.type === "repeater") {
|
|
449
|
-
const rep = repeaters.get(f.action_id)!;
|
|
450
|
-
out[f.action_id] = Array.from(rep.items.children).map((item) => readScope(item, f.fields ?? []));
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
const input = Array.from(form.children).map((c) => c.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${f.action_id}"]`)).find(Boolean) ?? null;
|
|
454
|
-
out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
|
|
455
|
-
}
|
|
506
|
+
for (const [k, r] of readers) out[k] = r();
|
|
456
507
|
return out;
|
|
457
508
|
};
|
|
458
509
|
return { form, read };
|
|
@@ -460,26 +511,29 @@ export function start(config: Config): void {
|
|
|
460
511
|
|
|
461
512
|
function openBlock(key: string) {
|
|
462
513
|
const block = value.find((b) => b._key === key);
|
|
463
|
-
const
|
|
464
|
-
if (!block || !
|
|
465
|
-
const { form, read } = buildBlockForm(
|
|
514
|
+
const type = block ? schemaOf(block._type) : undefined;
|
|
515
|
+
if (!block || !type) return;
|
|
516
|
+
const { form, read } = buildBlockForm(type, block);
|
|
466
517
|
const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
|
|
467
518
|
const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
|
|
468
519
|
cancel.addEventListener("click", closePanel);
|
|
469
520
|
form.addEventListener("submit", (e) => {
|
|
470
521
|
e.preventDefault();
|
|
471
522
|
saveBtn.disabled = true;
|
|
472
|
-
|
|
523
|
+
// Fields the form read as "no value" are removed from the block, not written as null.
|
|
524
|
+
const data = read();
|
|
525
|
+
const next = updateBlock(value, key, data).map((b) => (b._key === key ? compact(b) : b)) as Block[];
|
|
526
|
+
void saveBlocks(next);
|
|
473
527
|
});
|
|
474
528
|
form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
|
|
475
|
-
openPanel(
|
|
529
|
+
openPanel(type.label, type.description, form);
|
|
476
530
|
}
|
|
477
531
|
|
|
478
532
|
function openAdd(afterKey: string | null) {
|
|
479
533
|
const listEl = el("div", { class: "tidy-add-list" });
|
|
480
|
-
for (const s of config.blocks
|
|
534
|
+
for (const s of config.blocks) {
|
|
481
535
|
const b = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: s.label }), el("span", { text: s.description ?? "" })]);
|
|
482
|
-
b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.
|
|
536
|
+
b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.slug, afterKey, defaultsFor(s))));
|
|
483
537
|
listEl.append(b);
|
|
484
538
|
}
|
|
485
539
|
const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
|
|
@@ -507,13 +561,12 @@ export function start(config: Config): void {
|
|
|
507
561
|
const values: Record<string, unknown> = {};
|
|
508
562
|
const readers: Array<() => void> = [];
|
|
509
563
|
for (const [name, f] of Object.entries(fields)) {
|
|
510
|
-
if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime"].includes(f.kind)) continue;
|
|
564
|
+
if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime", "blocks"].includes(f.kind)) continue;
|
|
511
565
|
const label = f.label ?? name;
|
|
512
566
|
const v = data[name];
|
|
513
567
|
if (f.kind === "image") {
|
|
514
|
-
const cur = v as { src?: string; meta?: { storageKey?: string } } | null | undefined;
|
|
515
568
|
values[name] = v ?? null;
|
|
516
|
-
form.append(imageControl(label,
|
|
569
|
+
form.append(imageControl(label, imageSrc(v), (item) => {
|
|
517
570
|
values[name] = item ? mediaValue(item) : null;
|
|
518
571
|
}));
|
|
519
572
|
continue;
|
package/src/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ export const PLATFORM_ROUTES = [
|
|
|
29
29
|
{ pattern: "/_tidy/health", entrypoint: `${PACKAGE}/routes/health` },
|
|
30
30
|
{ pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
|
|
31
31
|
{ pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
|
|
32
|
+
{ pattern: "/_tidy/schema", entrypoint: `${PACKAGE}/routes/schema` },
|
|
32
33
|
{ pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` }
|
|
33
34
|
] as const;
|
|
34
35
|
|
package/src/lib/handlers.ts
CHANGED
|
@@ -12,6 +12,12 @@ export interface SeedOps {
|
|
|
12
12
|
load(): Promise<Record<string, unknown> & { settings?: Record<string, unknown> }>;
|
|
13
13
|
validate(seed: unknown): { valid: boolean; errors?: unknown };
|
|
14
14
|
apply(seed: Record<string, unknown>): Promise<{ collections?: unknown; content?: unknown }>;
|
|
15
|
+
/**
|
|
16
|
+
* Apply only the structural parts of a seed (block types, collections and
|
|
17
|
+
* their fields, relations) to a site that is already set up, updating what
|
|
18
|
+
* exists and adding what is missing. Never touches entries, settings or menus.
|
|
19
|
+
*/
|
|
20
|
+
applyStructure(seed: Record<string, unknown>): Promise<Record<string, unknown>>;
|
|
15
21
|
/** Upsert content entries by slug (existing entries are updated), downloading `$media` URLs into the site's storage. */
|
|
16
22
|
upsertContent(content: Record<string, FillEntry[]>): Promise<{ created: number; updated: number; media: number }>;
|
|
17
23
|
}
|
|
@@ -121,6 +127,36 @@ export async function bootstrap(input: BootstrapInput, deps: Deps, requestOrigin
|
|
|
121
127
|
return { ok: true, userId: user.id, token: t.raw, steps };
|
|
122
128
|
}
|
|
123
129
|
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
// Schema: bring an existing site's structure up to the template's
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
export interface SchemaResult {
|
|
135
|
+
ok: true;
|
|
136
|
+
applied: Record<string, unknown>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The seed keys that describe structure; everything else is left out before applying. */
|
|
140
|
+
const STRUCTURE_KEYS = ["$schema", "version", "defaultLocale", "blockTypes", "collections", "relations"] as const;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Apply the template seed's block types, collections, fields and relations to
|
|
144
|
+
* a bootstrapped site. Existing definitions are updated to the template's,
|
|
145
|
+
* missing ones are created, nothing is deleted, and entries, settings, menus,
|
|
146
|
+
* taxonomies and sections are untouched. This is how a template schema change
|
|
147
|
+
* reaches a site that was provisioned before it. Refuses before bootstrap.
|
|
148
|
+
*/
|
|
149
|
+
export async function applySchema(deps: Deps): Promise<SchemaResult> {
|
|
150
|
+
if ((await deps.store.getOption("emdash:setup_complete")) !== true) throw new HttpError(409, "site is not bootstrapped yet");
|
|
151
|
+
const full = await deps.seed.load();
|
|
152
|
+
const structure: Record<string, unknown> = {};
|
|
153
|
+
for (const k of STRUCTURE_KEYS) if (full[k] !== undefined) structure[k] = full[k];
|
|
154
|
+
const v = deps.seed.validate(structure);
|
|
155
|
+
if (!v.valid) throw new HttpError(500, "invalid seed", v.errors);
|
|
156
|
+
const applied = await deps.seed.applyStructure(structure);
|
|
157
|
+
return { ok: true, applied };
|
|
158
|
+
}
|
|
159
|
+
|
|
124
160
|
// ---------------------------------------------------------------------------
|
|
125
161
|
// Fill: write real content over the template's sample content
|
|
126
162
|
// ---------------------------------------------------------------------------
|
package/src/lib/runtime.ts
CHANGED
|
@@ -47,6 +47,15 @@ export async function buildDeps(): Promise<Deps> {
|
|
|
47
47
|
load: async () => (await loadSeed()) as unknown as Record<string, unknown> & { settings?: Record<string, unknown> },
|
|
48
48
|
validate: (seed) => validateSeed(seed),
|
|
49
49
|
apply: (seed) => applySeed(db, seed as unknown as Parameters<typeof applySeed>[1], { includeContent: true, onConflict: "skip", ...storageOpt }),
|
|
50
|
+
// Structure only: "update" rewrites an existing collection's definition
|
|
51
|
+
// and upserts its fields (never deletes one), adds missing block types
|
|
52
|
+
// and amends compatible block versions. The caller has already stripped
|
|
53
|
+
// content, settings and menus from the document.
|
|
54
|
+
applyStructure: async (seed) => {
|
|
55
|
+
const r = (await applySeed(db, seed as unknown as Parameters<typeof applySeed>[1], { includeContent: false, onConflict: "update" })) as unknown as Record<string, unknown>;
|
|
56
|
+
const pick = (k: string) => (r[k] !== undefined ? { [k]: r[k] } : {});
|
|
57
|
+
return { ...pick("blockTypes"), ...pick("collections"), ...pick("fields"), ...pick("relations") };
|
|
58
|
+
},
|
|
50
59
|
// A content-only seed document: no collections, menus, or settings, so
|
|
51
60
|
// applySeed touches nothing but entries. "update" makes it an upsert by
|
|
52
61
|
// slug. applySeed replaces an existing entry's data wholesale, so fields
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/** POST /_tidy/schema: the template's block types, collections and fields onto an existing site. */
|
|
2
|
+
import { applySchema } from "../lib/handlers";
|
|
3
|
+
import { json } from "../lib/http";
|
|
4
|
+
import { methodNotAllowed, platformRoute } from "./_shared";
|
|
5
|
+
|
|
6
|
+
export const prerender = false;
|
|
7
|
+
|
|
8
|
+
export const POST = platformRoute(async (_context, deps) => json(await applySchema(deps)));
|
|
9
|
+
|
|
10
|
+
export const ALL = methodNotAllowed("POST");
|
package/src/version.ts
CHANGED