@tideworthy/tidysites-platform 0.8.0 → 0.9.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/CHANGELOG.md +10 -0
- package/README.md +9 -5
- package/package.json +2 -1
- package/src/editor/TidyEditor.astro +21 -12
- package/src/editor/ops.ts +86 -32
- package/src/editor/runtime.ts +151 -96
- 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,15 @@
|
|
|
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
|
+
|
|
7
|
+
## 0.9.0
|
|
8
|
+
|
|
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.
|
|
10
|
+
- 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.
|
|
11
|
+
- `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).
|
|
12
|
+
|
|
3
13
|
## 0.8.0
|
|
4
14
|
|
|
5
15
|
- 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.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",
|
|
@@ -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": {
|
|
@@ -9,33 +9,38 @@
|
|
|
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
|
-
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;
|
|
35
37
|
const toggle = (on: boolean) => `/_tidy/edit?on=${on ? 1 : 0}&to=${encodeURIComponent(here)}`;
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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";
|
|
39
44
|
---
|
|
40
45
|
{signedIn && (
|
|
41
46
|
<>
|
|
@@ -56,7 +61,8 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
56
61
|
</>
|
|
57
62
|
)}
|
|
58
63
|
<style is:global>
|
|
59
|
-
|
|
64
|
+
/* EmDash's own pill never shows under the bar; its inline prose editing keeps working underneath. */
|
|
65
|
+
html.tidy-has-bar #emdash-toolbar { display: none !important; }
|
|
60
66
|
html.tidy-has-bar { --tidy-bar: 44px; }
|
|
61
67
|
html.tidy-has-bar body { padding-top: var(--tidy-bar); }
|
|
62
68
|
.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); }
|
|
@@ -96,9 +102,12 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
|
|
|
96
102
|
.tidy-form { display: grid; gap: 14px; }
|
|
97
103
|
.tidy-field { display: grid; gap: 6px; }
|
|
98
104
|
.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; }
|
|
105
|
+
.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
106
|
.tidy-field textarea { resize: vertical; min-height: 88px; }
|
|
101
107
|
.tidy-field-toggle { display: flex; align-items: center; gap: 10px; }
|
|
108
|
+
.tidy-field-note { margin: 0; color: #4a5851; font-size: 13px; }
|
|
109
|
+
.tidy-multi { display: grid; gap: 6px; }
|
|
110
|
+
.tidy-multi-item { display: flex; align-items: center; gap: 8px; font-weight: 400; }
|
|
102
111
|
.tidy-repeater { border: 1px solid #d3dcd6; border-radius: 8px; padding: 12px; display: grid; gap: 12px; }
|
|
103
112
|
.tidy-repeater legend { font-weight: 600; padding: 0 6px; }
|
|
104
113
|
.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,27 +78,38 @@ 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
|
+
// 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.
|
|
93
105
|
document.documentElement.classList.add("tidy-editing");
|
|
94
|
-
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"));
|
|
95
107
|
|
|
96
108
|
// --- pending publishes ---------------------------------------------------
|
|
109
|
+
// Records whose publish failed after a save; the page itself is read from EmDash.
|
|
97
110
|
const pending = new Set<string>(JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? "[]") as string[]);
|
|
98
111
|
const persistPending = () => sessionStorage.setItem(PENDING_KEY, JSON.stringify([...pending]));
|
|
112
|
+
let pageHasDraft = false;
|
|
99
113
|
|
|
100
114
|
// --- the bar's controls --------------------------------------------------
|
|
101
115
|
const status = document.getElementById("tidy-bar-actions") ?? document.body.appendChild(el("div", { class: "tidy-bar-actions" }));
|
|
@@ -108,15 +122,29 @@ export function start(config: Config): void {
|
|
|
108
122
|
bizBtn.hidden = !business;
|
|
109
123
|
bizBtn.addEventListener("click", () => business && void openRecord(business));
|
|
110
124
|
function refreshStatus(text?: string) {
|
|
111
|
-
const n = pending.size;
|
|
125
|
+
const n = pending.size + (pageHasDraft ? 1 : 0);
|
|
112
126
|
statusText.textContent = text ?? (n === 0 ? "" : `${n} unpublished change${n === 1 ? "" : "s"}`);
|
|
113
127
|
publishBtn.hidden = n === 0 && !text?.startsWith("Publish failed");
|
|
114
128
|
}
|
|
115
129
|
refreshStatus();
|
|
130
|
+
/** Ask EmDash whether this page has a draft that differs from what is live. */
|
|
131
|
+
async function readDraftState(): Promise<void> {
|
|
132
|
+
try {
|
|
133
|
+
const r = await api(`/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}/compare`);
|
|
134
|
+
if (!r.ok) return;
|
|
135
|
+
const j = (await r.json().catch(() => ({}))) as { data?: { hasChanges?: boolean } };
|
|
136
|
+
pageHasDraft = j.data?.hasChanges === true;
|
|
137
|
+
refreshStatus();
|
|
138
|
+
} catch {
|
|
139
|
+
// the bar just shows no count
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
void readDraftState();
|
|
116
143
|
publishBtn.addEventListener("click", async () => {
|
|
117
144
|
publishBtn.disabled = true;
|
|
118
145
|
refreshStatus("Publishing");
|
|
119
|
-
const targets =
|
|
146
|
+
const targets = new Set<string>(pending);
|
|
147
|
+
if (pageHasDraft || targets.size === 0) targets.add(`${config.collection}/${config.id}`);
|
|
120
148
|
let failed = 0;
|
|
121
149
|
for (const t of targets) {
|
|
122
150
|
const [collection, id] = t.split("/") as [string, string];
|
|
@@ -133,16 +161,17 @@ export function start(config: Config): void {
|
|
|
133
161
|
});
|
|
134
162
|
|
|
135
163
|
/**
|
|
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.
|
|
164
|
+
* Page sections save as a draft the page previews in edit mode; Publish
|
|
165
|
+
* makes them live. Record details (the business, a service) are read by
|
|
166
|
+
* many pages from their published version, so a draft would look like
|
|
167
|
+
* nothing happened: they save and publish in one step.
|
|
140
168
|
*/
|
|
141
169
|
async function saveEntry(collection: string, id: string, data: Record<string, unknown>, publishNow = false): Promise<boolean> {
|
|
142
170
|
refreshStatus("Saving");
|
|
143
171
|
const r = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ data }) });
|
|
144
172
|
if (!r.ok) {
|
|
145
|
-
|
|
173
|
+
const j = (await r.json().catch(() => ({}))) as { error?: { message?: string } };
|
|
174
|
+
refreshStatus(`Save failed (${j.error?.message ?? r.status})`);
|
|
146
175
|
return false;
|
|
147
176
|
}
|
|
148
177
|
if (publishNow) {
|
|
@@ -153,9 +182,6 @@ export function start(config: Config): void {
|
|
|
153
182
|
refreshStatus(`Saved; publish failed (${p.status})`);
|
|
154
183
|
return false;
|
|
155
184
|
}
|
|
156
|
-
} else {
|
|
157
|
-
pending.add(`${collection}/${id}`);
|
|
158
|
-
persistPending();
|
|
159
185
|
}
|
|
160
186
|
refreshStatus("Saved. Reloading");
|
|
161
187
|
location.reload();
|
|
@@ -301,7 +327,7 @@ export function start(config: Config): void {
|
|
|
301
327
|
});
|
|
302
328
|
|
|
303
329
|
// --- image picker -----------------------------------------------------------
|
|
304
|
-
/** An image control
|
|
330
|
+
/** An image control: preview, choose from the library, upload, remove. `onPick` receives the library item or null. */
|
|
305
331
|
function imageControl(label: string, currentUrl: string | null, onPick: (item: MediaItem | null) => void): HTMLElement {
|
|
306
332
|
const wrap = el("div", { class: "tidy-field tidy-image" }, [el("span", { class: "tidy-field-label", text: label })]);
|
|
307
333
|
const preview = el("div", { class: "tidy-image-preview" });
|
|
@@ -366,93 +392,120 @@ export function start(config: Config): void {
|
|
|
366
392
|
}
|
|
367
393
|
|
|
368
394
|
// --- block forms ---------------------------------------------------------
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
395
|
+
type Reader = () => unknown;
|
|
396
|
+
|
|
397
|
+
/** One control for a block field (or a repeater row's sub-field). Returns the element and a reader of its coerced value. */
|
|
398
|
+
function fieldControl(f: FieldSchema, v: unknown): { node: HTMLElement; read: Reader } {
|
|
399
|
+
const label = f.label || f.slug;
|
|
400
|
+
if (f.type === "image") {
|
|
401
|
+
let picked: unknown = v && typeof v === "object" ? v : undefined;
|
|
402
|
+
const node = imageControl(label, imageSrc(v), (item) => {
|
|
403
|
+
picked = item ? mediaValue(item) : undefined;
|
|
404
|
+
});
|
|
405
|
+
return { node, read: () => picked };
|
|
406
|
+
}
|
|
407
|
+
const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
|
|
408
|
+
if (f.type === "boolean") {
|
|
409
|
+
const cb = el("input", { type: "checkbox" });
|
|
373
410
|
cb.checked = v === true;
|
|
374
411
|
wrap.className = "tidy-field tidy-field-toggle";
|
|
375
412
|
wrap.prepend(cb);
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
413
|
+
return { node: wrap, read: () => cb.checked };
|
|
414
|
+
}
|
|
415
|
+
if (f.type === "select") {
|
|
416
|
+
const s = el("select");
|
|
417
|
+
if (!f.required) s.append(el("option", { value: "", text: "" }));
|
|
418
|
+
for (const o of f.validation?.options ?? []) {
|
|
419
|
+
const opt = el("option", { value: o, text: o });
|
|
420
|
+
if (o === v) opt.selected = true;
|
|
381
421
|
s.append(opt);
|
|
382
422
|
}
|
|
383
423
|
wrap.append(s);
|
|
384
|
-
|
|
385
|
-
|
|
424
|
+
return { node: wrap, read: () => coerce(f, s.value) };
|
|
425
|
+
}
|
|
426
|
+
if (f.type === "multiSelect") {
|
|
427
|
+
const box = el("div", { class: "tidy-multi" });
|
|
428
|
+
const chosen = new Set(Array.isArray(v) ? (v as string[]) : []);
|
|
429
|
+
const boxes: HTMLInputElement[] = [];
|
|
430
|
+
for (const o of f.validation?.options ?? []) {
|
|
431
|
+
const cb = el("input", { type: "checkbox", value: o });
|
|
432
|
+
cb.checked = chosen.has(o);
|
|
433
|
+
boxes.push(cb);
|
|
434
|
+
box.append(el("label", { class: "tidy-multi-item" }, [cb, o]));
|
|
435
|
+
}
|
|
436
|
+
wrap.append(box);
|
|
437
|
+
return { node: wrap, read: () => boxes.filter((b) => b.checked).map((b) => b.value) };
|
|
438
|
+
}
|
|
439
|
+
if (f.type === "number" || f.type === "integer") {
|
|
440
|
+
const i = el("input", { type: "number", ...(f.type === "integer" ? { step: "1" } : { step: "any" }) });
|
|
386
441
|
i.value = typeof v === "number" ? String(v) : "";
|
|
387
442
|
wrap.append(i);
|
|
388
|
-
|
|
389
|
-
|
|
443
|
+
return { node: wrap, read: () => coerce(f, i.value) };
|
|
444
|
+
}
|
|
445
|
+
if (f.type === "datetime") {
|
|
446
|
+
const i = el("input", { type: "datetime-local" });
|
|
447
|
+
i.value = typeof v === "string" ? v.slice(0, 16) : "";
|
|
448
|
+
wrap.append(i);
|
|
449
|
+
return { node: wrap, read: () => (i.value ? new Date(i.value).toISOString() : undefined) };
|
|
450
|
+
}
|
|
451
|
+
if (f.type === "text") {
|
|
452
|
+
const t = el("textarea", { rows: "4" });
|
|
390
453
|
t.value = typeof v === "string" ? v : "";
|
|
391
454
|
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);
|
|
455
|
+
return { node: wrap, read: () => t.value };
|
|
396
456
|
}
|
|
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;
|
|
457
|
+
const i = el("input", { type: f.type === "url" ? "url" : "text" });
|
|
458
|
+
i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
|
|
459
|
+
wrap.append(i);
|
|
460
|
+
return { node: wrap, read: () => i.value };
|
|
410
461
|
}
|
|
411
462
|
|
|
412
|
-
function buildBlockForm(
|
|
463
|
+
function buildBlockForm(type: BlockTypeSchema, block: Block): { form: HTMLFormElement; read: () => Record<string, unknown> } {
|
|
413
464
|
const form = el("form", { class: "tidy-form" });
|
|
414
|
-
const
|
|
415
|
-
for (const f of
|
|
465
|
+
const readers: Array<[string, Reader]> = [];
|
|
466
|
+
for (const f of fieldsOf(type, block._version)) {
|
|
467
|
+
if (!EDITABLE_TYPES.has(f.type)) {
|
|
468
|
+
form.append(el("p", { class: "tidy-field-note", text: `${f.label || f.slug}: edit in the admin` }));
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
416
471
|
if (f.type === "repeater") {
|
|
417
|
-
const
|
|
472
|
+
const sub = f.validation?.subFields ?? [];
|
|
473
|
+
const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label || f.slug })]);
|
|
418
474
|
const items = el("div", { class: "tidy-repeater-items" });
|
|
419
|
-
const rows = Array.isArray(
|
|
475
|
+
const rows = Array.isArray(block[f.slug]) ? (block[f.slug] as Record<string, unknown>[]) : [];
|
|
476
|
+
const rowReaders = new Map<HTMLElement, Array<[string, Reader]>>();
|
|
420
477
|
const addItem = (row: Record<string, unknown>) => {
|
|
421
478
|
const item = el("div", { class: "tidy-repeater-item" });
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
479
|
+
const rs: Array<[string, Reader]> = [];
|
|
480
|
+
for (const s of sub) {
|
|
481
|
+
const c = fieldControl(s, row[s.slug]);
|
|
482
|
+
item.append(c.node);
|
|
483
|
+
rs.push([s.slug, c.read]);
|
|
484
|
+
}
|
|
485
|
+
const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: "Remove" });
|
|
486
|
+
rm.addEventListener("click", () => {
|
|
487
|
+
rowReaders.delete(item);
|
|
488
|
+
item.remove();
|
|
489
|
+
});
|
|
425
490
|
item.append(rm);
|
|
426
491
|
items.append(item);
|
|
492
|
+
rowReaders.set(item, rs);
|
|
427
493
|
};
|
|
428
494
|
rows.forEach(addItem);
|
|
429
|
-
const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text:
|
|
495
|
+
const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Add" });
|
|
430
496
|
add.addEventListener("click", () => addItem({}));
|
|
431
497
|
box.append(items, add);
|
|
432
498
|
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);
|
|
499
|
+
readers.push([f.slug, () => Array.from(items.children).map((item) => compact(Object.fromEntries((rowReaders.get(item as HTMLElement) ?? []).map(([k, r]) => [k, r()]))))]);
|
|
500
|
+
continue;
|
|
442
501
|
}
|
|
443
|
-
|
|
444
|
-
|
|
502
|
+
const c = fieldControl(f, block[f.slug]);
|
|
503
|
+
form.append(c.node);
|
|
504
|
+
readers.push([f.slug, c.read]);
|
|
505
|
+
}
|
|
445
506
|
const read = (): Record<string, unknown> => {
|
|
446
507
|
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
|
-
}
|
|
508
|
+
for (const [k, r] of readers) out[k] = r();
|
|
456
509
|
return out;
|
|
457
510
|
};
|
|
458
511
|
return { form, read };
|
|
@@ -460,26 +513,29 @@ export function start(config: Config): void {
|
|
|
460
513
|
|
|
461
514
|
function openBlock(key: string) {
|
|
462
515
|
const block = value.find((b) => b._key === key);
|
|
463
|
-
const
|
|
464
|
-
if (!block || !
|
|
465
|
-
const { form, read } = buildBlockForm(
|
|
516
|
+
const type = block ? schemaOf(block._type) : undefined;
|
|
517
|
+
if (!block || !type) return;
|
|
518
|
+
const { form, read } = buildBlockForm(type, block);
|
|
466
519
|
const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
|
|
467
520
|
const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
|
|
468
521
|
cancel.addEventListener("click", closePanel);
|
|
469
522
|
form.addEventListener("submit", (e) => {
|
|
470
523
|
e.preventDefault();
|
|
471
524
|
saveBtn.disabled = true;
|
|
472
|
-
|
|
525
|
+
// Fields the form read as "no value" are removed from the block, not written as null.
|
|
526
|
+
const data = read();
|
|
527
|
+
const next = updateBlock(value, key, data).map((b) => (b._key === key ? compact(b) : b)) as Block[];
|
|
528
|
+
void saveBlocks(next);
|
|
473
529
|
});
|
|
474
530
|
form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
|
|
475
|
-
openPanel(
|
|
531
|
+
openPanel(type.label, type.description, form);
|
|
476
532
|
}
|
|
477
533
|
|
|
478
534
|
function openAdd(afterKey: string | null) {
|
|
479
535
|
const listEl = el("div", { class: "tidy-add-list" });
|
|
480
|
-
for (const s of config.blocks
|
|
536
|
+
for (const s of config.blocks) {
|
|
481
537
|
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.
|
|
538
|
+
b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.slug, afterKey, defaultsFor(s))));
|
|
483
539
|
listEl.append(b);
|
|
484
540
|
}
|
|
485
541
|
const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
|
|
@@ -507,13 +563,12 @@ export function start(config: Config): void {
|
|
|
507
563
|
const values: Record<string, unknown> = {};
|
|
508
564
|
const readers: Array<() => void> = [];
|
|
509
565
|
for (const [name, f] of Object.entries(fields)) {
|
|
510
|
-
if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime"].includes(f.kind)) continue;
|
|
566
|
+
if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime", "blocks"].includes(f.kind)) continue;
|
|
511
567
|
const label = f.label ?? name;
|
|
512
568
|
const v = data[name];
|
|
513
569
|
if (f.kind === "image") {
|
|
514
|
-
const cur = v as { src?: string; meta?: { storageKey?: string } } | null | undefined;
|
|
515
570
|
values[name] = v ?? null;
|
|
516
|
-
form.append(imageControl(label,
|
|
571
|
+
form.append(imageControl(label, imageSrc(v), (item) => {
|
|
517
572
|
values[name] = item ? mediaValue(item) : null;
|
|
518
573
|
}));
|
|
519
574
|
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