@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 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` 0.37.x, `kysely`. Published on npm as `@tideworthy/tidysites-platform`; GitHub tags `vX.Y.Z` match each version.
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
- EmDash's inline editor edits plain rich text; a Block Kit block inside a Portable Text field renders as an "edit in admin" placeholder. Templates built from blocks use the Tideworthy editor instead:
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
- const editMode = Astro.cookies.get("emdash-edit-mode")?.value === "true";
53
+ import seed from "../../seed/seed.json";
54
54
  ---
55
- {editMode && <TidyEditor collection="pages" id={page.data.id} blocks={blockSchema} value={content} />}
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 block list inside `<div data-tidy-blocks>` and each custom block inside `<div data-tidy-block={_key} data-tidy-type={_type}>`, and passes `PortableText` a copy of the array (`[...blocks]`) so EmDash's inline editor does not mount on it. `blocks` is the plugin definition's `portableTextBlocks`. Hovering a block shows a chip (Edit, move, add below, remove); Edit opens a panel built from the block's fields; Save writes a draft through EmDash's content API with the signed-in editor's session and reloads; Publish publishes every entry saved in the session. A text field whose id ends in `imageUrl` (or `photoUrl`, `logoUrl`) gets an image picker over the media library with upload. 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 pill. 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 passing the original array and not mounting this component.
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.8.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 blocks (an
13
- * article) gets the bar only; EmDash's inline editing handles its prose and
14
- * its toolbar shows there. While editing is off, EmDash's toolbar is hidden:
15
- * the bar is the one control. The bar sets `--tidy-bar` on <html> (its
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, BlockSchema } from "./ops";
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
- blocks?: BlockSchema[];
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 = "content", blocks, value, records = [], label = "Staging" } = Astro.props;
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
- const editable = active && Boolean(collection && id && blocks && value);
37
- const config = editable ? JSON.stringify({ collection, id, field, blocks, value, records, done: toggle(false) }) : null;
38
- const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable ? "tidy-has-bar" : "tidy-has-bar tidy-inline";
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
- .tidy-editing #emdash-toolbar, .tidy-idle #emdash-toolbar { display: none !important; }
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 block list (a Portable Text array whose custom
3
- * blocks carry `_type` and `_key`). The editor runtime calls these; the tests
4
- * cover them without a browser.
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
- type: "text_input" | "number_input" | "toggle" | "select" | "repeater" | string;
10
- action_id: string;
14
+ slug: string;
11
15
  label: string;
12
- multiline?: boolean;
13
- options?: Array<{ label: string; value: string }>;
14
- fields?: FieldSchema[];
15
- item_label?: string;
16
- min_items?: number;
17
- max_items?: number;
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
- export interface BlockSchema {
21
- type: string;
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
- fields: FieldSchema[];
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 `_key` never change. */
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
- /** Insert a new block of `type` after the block with `afterKey` (or at the end). */
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
- /** Empty values for every field in a schema, so a new block renders without holes. */
67
- export function defaultsFor(schema: BlockSchema): Record<string, unknown> {
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 schema.fields) {
70
- if (f.type === "toggle") out[f.action_id] = false;
71
- else if (f.type === "number_input") out[f.action_id] = null;
72
- else if (f.type === "select") out[f.action_id] = f.options?.[0]?.value ?? "";
73
- else if (f.type === "repeater") out[f.action_id] = [];
74
- else out[f.action_id] = "";
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
- /** Coerce a raw form value into the field's stored type. */
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 "toggle":
116
+ case "boolean":
83
117
  return raw === true || raw === "on" || raw === "true";
84
- case "number_input": {
85
- if (raw === "" || raw === null || raw === undefined) return null;
118
+ case "number":
119
+ case "integer": {
120
+ if (raw === "" || raw === null || raw === undefined) return undefined;
86
121
  const n = Number(raw);
87
- return Number.isFinite(n) ? n : null;
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
+ }
@@ -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 custom block sits in a frame (`[data-tidy-block]`, rendered
7
- * by the template); hovering shows a chip with Edit, Up, Down, Add and Remove,
8
- * and Edit opens a panel whose form comes from the block's field schema.
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 pill.
14
+ * that collection; the business record is always reachable from the bar.
13
15
  *
14
- * Images: a block field whose id names an image URL, or a record field of
15
- * kind image, gets a picker over the site's media library with upload.
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 publishes every entry touched in this browser session.
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 BlockSchema, type FieldSchema } from "./ops";
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: BlockSchema[];
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 record field stores: EmDash's media value for a library item. */
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.type === type);
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
- // EmDash's inline editor is for prose pages; here the sections and records own their fields.
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 = pending.size ? [...pending] : [`${config.collection}/${config.id}`];
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 blocks save as a draft the page previews in edit mode; Publish makes
137
- * them live. Record details (the business, a service) are read by many
138
- * pages from their published version, so a draft would look like nothing
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
- refreshStatus(`Save failed (${r.status})`);
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. `get` returns the stored value (URL string or media value); set through `onPick`. */
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
- function fieldInput(f: FieldSchema, v: unknown, name: string): HTMLElement {
370
- const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: f.label })]);
371
- if (f.type === "toggle") {
372
- const cb = el("input", { type: "checkbox", name });
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
- } else if (f.type === "select") {
377
- const s = el("select", { name });
378
- for (const o of f.options ?? []) {
379
- const opt = el("option", { value: o.value, text: o.label });
380
- if (o.value === v) opt.selected = true;
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
- } else if (f.type === "number_input") {
385
- const i = el("input", { type: "number", name });
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
- } else if (f.multiline) {
389
- const t = el("textarea", { name, rows: "4" });
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
- } else {
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
- return wrap;
398
- }
399
-
400
- /** A text field that stores an image URL gets the picker; the hidden input keeps the URL for the reader. */
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(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
463
+ function buildBlockForm(type: BlockTypeSchema, block: Block): { form: HTMLFormElement; read: () => Record<string, unknown> } {
413
464
  const form = el("form", { class: "tidy-form" });
414
- const repeaters = new Map<string, { field: FieldSchema; items: HTMLElement }>();
415
- for (const f of schema.fields) {
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 box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label })]);
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(data[f.action_id]) ? (data[f.action_id] as Record<string, unknown>[]) : [];
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
- for (const sub of f.fields ?? []) item.append(blockField(sub, row[sub.action_id], sub.action_id));
423
- const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: `Remove ${f.item_label ?? "item"}` });
424
- rm.addEventListener("click", () => item.remove());
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: `Add ${f.item_label ?? "item"}` });
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
- repeaters.set(f.action_id, { field: f, items });
434
- } else form.append(blockField(f, data[f.action_id], f.action_id));
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
- return out;
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 f of schema.fields) {
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 schema = block ? schemaOf(block._type) : undefined;
464
- if (!block || !schema) return;
465
- const { form, read } = buildBlockForm(schema, block);
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
- void saveBlocks(updateBlock(value, key, read()));
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(schema.label, schema.description, form);
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.filter((b) => b.category !== "Inline")) {
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.type, afterKey, defaultsFor(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, cur?.src ?? (cur?.meta?.storageKey ? `${API}/media/file/${cur.meta.storageKey}` : null), (item) => {
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
 
@@ -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
  // ---------------------------------------------------------------------------
@@ -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
@@ -1,3 +1,3 @@
1
1
  /** Kept in step with package.json by the release checklist in MAINTAINING.md. */
2
- export const VERSION = "0.8.0";
2
+ export const VERSION = "0.9.1";
3
3
  export const PLUGIN_ID = "tidy-platform";