@tideworthy/tidysites-platform 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - Editor: sections are an EmDash `blocks` field (`sections` by default), and the forms come from the seed's block type definitions (`slug`, `type`, `validation.options`, `subFields`) instead of Block Kit shapes. Fields render by EmDash type: string, text, url, number, integer, boolean, datetime, select, multiSelect, image (the media picker, storing EmDash's media value) and repeater; Portable Text and file fields say "edit in the admin". New blocks carry no `_version` so EmDash stamps the active one; a saved block keeps its `_type`, `_key` and `_version`, and fields the form read as empty are left out rather than written as null.
6
+ - Editor: Publish reads the page's draft state from EmDash (`compare`), so a draft an agent staged over the API is publishable from the page; the count no longer depends on this browser's session.
7
+ - `POST /_tidy/schema`: applies the template seed's block types, collections, fields and relations to a bootstrapped site (`onConflict: "update"`, no content, settings or menus). How a template schema change reaches a site provisioned before it. Refuses before bootstrap (409).
8
+
9
+ ## 0.8.0
10
+
11
+ - EmDash 1.0: the peer range is `>=1.0.0 <2.0.0` for `emdash` and `@emdash-cms/auth` (was 0.37 only). Nothing in the package changed: every export it depends on (`emdash/runtime` `getDb`, `emdash/seed` `loadSeed`, `emdash/middleware` `runScheduledTasks`, the repositories, `applySeed`, the auth token helpers) is still public in 1.0.1; only the `emdash/middleware/*` and `emdash/db/*-migrations` subpaths moved under `emdash/internal/*`, and this package never used them. Proven on a 0.37 database: the 1.0.1 runtime applied its 15 migrations on the first request (73 to 88), pages, sessions, editing, fill, maintenance and the content API all answered as before.
12
+
3
13
  ## 0.7.1
4
14
 
5
15
  - Editor: the class the bar puts on `<html>` is `tidy-has-bar`. 0.7.0 used `tidy-bar`, the bar's own class, so the bar's fixed styles applied to the document and pushed the top of the page off screen for a signed-in editor.
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.7.1",
3
+ "version": "0.9.0",
4
4
  "description": "The Tidysites platform integration for EmDash sites: bootstrap, sessions, maintenance, health, and publish hooks under /_tidy/*.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -29,6 +29,7 @@
29
29
  "./routes/health": "./src/routes/health.ts",
30
30
  "./routes/rotate-token": "./src/routes/rotate-token.ts",
31
31
  "./routes/fill": "./src/routes/fill.ts",
32
+ "./routes/schema": "./src/routes/schema.ts",
32
33
  "./routes/maintenance": "./src/routes/maintenance.ts"
33
34
  },
34
35
  "scripts": {
@@ -37,18 +38,18 @@
37
38
  "check": "npm run typecheck && npm run test"
38
39
  },
39
40
  "peerDependencies": {
40
- "@emdash-cms/auth": ">=0.37.0 <0.38.0",
41
+ "@emdash-cms/auth": ">=1.0.0 <2.0.0",
41
42
  "astro": "^7.0.0",
42
- "emdash": ">=0.37.0 <0.38.0",
43
+ "emdash": ">=1.0.0 <2.0.0",
43
44
  "kysely": ">=0.29.0"
44
45
  },
45
46
  "devDependencies": {
46
47
  "@astrojs/react": "^6.0.0",
47
48
  "@cloudflare/workers-types": "^5.20260915.1",
48
- "@emdash-cms/auth": "0.37.0",
49
+ "@emdash-cms/auth": "1.0.1",
49
50
  "@emdash-cms/auth-atproto": ">=0.2.8",
50
51
  "astro": "^7.3.2",
51
- "emdash": "0.37.0",
52
+ "emdash": "1.0.1",
52
53
  "kysely": "^0.29.0",
53
54
  "react": "19.2.4",
54
55
  "react-dom": "19.2.4",
@@ -15,20 +15,22 @@
15
15
  * the bar is the one control. The bar sets `--tidy-bar` on <html> (its
16
16
  * height) so a template's sticky header can sit below it.
17
17
  */
18
- import type { Block, 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;
@@ -96,9 +98,12 @@ const htmlClass = !signedIn ? "" : !active ? "tidy-has-bar tidy-idle" : editable
96
98
  .tidy-form { display: grid; gap: 14px; }
97
99
  .tidy-field { display: grid; gap: 6px; }
98
100
  .tidy-field-label { font-weight: 600; font-size: 13px; }
99
- .tidy-field input[type="text"], .tidy-field input[type="number"], .tidy-field textarea, .tidy-field select { width: 100%; box-sizing: border-box; border: 1px solid #c9d2cc; border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; color: inherit; }
101
+ .tidy-field input[type="text"], .tidy-field input[type="url"], .tidy-field input[type="number"], .tidy-field input[type="datetime-local"], .tidy-field textarea, .tidy-field select { width: 100%; box-sizing: border-box; border: 1px solid #c9d2cc; border-radius: 6px; padding: 8px 10px; font: inherit; background: #fff; color: inherit; }
100
102
  .tidy-field textarea { resize: vertical; min-height: 88px; }
101
103
  .tidy-field-toggle { display: flex; align-items: center; gap: 10px; }
104
+ .tidy-field-note { margin: 0; color: #4a5851; font-size: 13px; }
105
+ .tidy-multi { display: grid; gap: 6px; }
106
+ .tidy-multi-item { display: flex; align-items: center; gap: 8px; font-weight: 400; }
102
107
  .tidy-repeater { border: 1px solid #d3dcd6; border-radius: 8px; padding: 12px; display: grid; gap: 12px; }
103
108
  .tidy-repeater legend { font-weight: 600; padding: 0 6px; }
104
109
  .tidy-repeater-item { display: grid; gap: 10px; padding: 12px; background: #f6f7f5; border-radius: 6px; }
package/src/editor/ops.ts CHANGED
@@ -1,28 +1,48 @@
1
1
  /**
2
- * Pure operations on a page's 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,18 +78,25 @@ function mediaUrl(m: MediaItem): string {
75
78
  return m.url ?? `${API}/media/file/${m.storageKey}`;
76
79
  }
77
80
 
78
- /** The value an image 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
102
  // EmDash's inline editor is for prose pages; here the sections and records own their fields.
@@ -94,8 +104,10 @@ export function start(config: Config): void {
94
104
  document.querySelectorAll("[data-emdash-ref]").forEach((n) => n.removeAttribute("data-emdash-ref"));
95
105
 
96
106
  // --- pending publishes ---------------------------------------------------
107
+ // Records whose publish failed after a save; the page itself is read from EmDash.
97
108
  const pending = new Set<string>(JSON.parse(sessionStorage.getItem(PENDING_KEY) ?? "[]") as string[]);
98
109
  const persistPending = () => sessionStorage.setItem(PENDING_KEY, JSON.stringify([...pending]));
110
+ let pageHasDraft = false;
99
111
 
100
112
  // --- the bar's controls --------------------------------------------------
101
113
  const status = document.getElementById("tidy-bar-actions") ?? document.body.appendChild(el("div", { class: "tidy-bar-actions" }));
@@ -108,15 +120,29 @@ export function start(config: Config): void {
108
120
  bizBtn.hidden = !business;
109
121
  bizBtn.addEventListener("click", () => business && void openRecord(business));
110
122
  function refreshStatus(text?: string) {
111
- const n = pending.size;
123
+ const n = pending.size + (pageHasDraft ? 1 : 0);
112
124
  statusText.textContent = text ?? (n === 0 ? "" : `${n} unpublished change${n === 1 ? "" : "s"}`);
113
125
  publishBtn.hidden = n === 0 && !text?.startsWith("Publish failed");
114
126
  }
115
127
  refreshStatus();
128
+ /** Ask EmDash whether this page has a draft that differs from what is live. */
129
+ async function readDraftState(): Promise<void> {
130
+ try {
131
+ const r = await api(`/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}/compare`);
132
+ if (!r.ok) return;
133
+ const j = (await r.json().catch(() => ({}))) as { data?: { hasChanges?: boolean } };
134
+ pageHasDraft = j.data?.hasChanges === true;
135
+ refreshStatus();
136
+ } catch {
137
+ // the bar just shows no count
138
+ }
139
+ }
140
+ void readDraftState();
116
141
  publishBtn.addEventListener("click", async () => {
117
142
  publishBtn.disabled = true;
118
143
  refreshStatus("Publishing");
119
- const targets = pending.size ? [...pending] : [`${config.collection}/${config.id}`];
144
+ const targets = new Set<string>(pending);
145
+ if (pageHasDraft || targets.size === 0) targets.add(`${config.collection}/${config.id}`);
120
146
  let failed = 0;
121
147
  for (const t of targets) {
122
148
  const [collection, id] = t.split("/") as [string, string];
@@ -133,16 +159,17 @@ export function start(config: Config): void {
133
159
  });
134
160
 
135
161
  /**
136
- * Page 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.
162
+ * Page sections save as a draft the page previews in edit mode; Publish
163
+ * makes them live. Record details (the business, a service) are read by
164
+ * many pages from their published version, so a draft would look like
165
+ * nothing happened: they save and publish in one step.
140
166
  */
141
167
  async function saveEntry(collection: string, id: string, data: Record<string, unknown>, publishNow = false): Promise<boolean> {
142
168
  refreshStatus("Saving");
143
169
  const r = await api(`/content/${encodeURIComponent(collection)}/${encodeURIComponent(id)}`, { method: "PUT", headers: { "content-type": "application/json" }, body: JSON.stringify({ data }) });
144
170
  if (!r.ok) {
145
- refreshStatus(`Save failed (${r.status})`);
171
+ const j = (await r.json().catch(() => ({}))) as { error?: { message?: string } };
172
+ refreshStatus(`Save failed (${j.error?.message ?? r.status})`);
146
173
  return false;
147
174
  }
148
175
  if (publishNow) {
@@ -153,9 +180,6 @@ export function start(config: Config): void {
153
180
  refreshStatus(`Saved; publish failed (${p.status})`);
154
181
  return false;
155
182
  }
156
- } else {
157
- pending.add(`${collection}/${id}`);
158
- persistPending();
159
183
  }
160
184
  refreshStatus("Saved. Reloading");
161
185
  location.reload();
@@ -301,7 +325,7 @@ export function start(config: Config): void {
301
325
  });
302
326
 
303
327
  // --- image picker -----------------------------------------------------------
304
- /** An image control. `get` returns the stored value (URL string or media value); set through `onPick`. */
328
+ /** An image control: preview, choose from the library, upload, remove. `onPick` receives the library item or null. */
305
329
  function imageControl(label: string, currentUrl: string | null, onPick: (item: MediaItem | null) => void): HTMLElement {
306
330
  const wrap = el("div", { class: "tidy-field tidy-image" }, [el("span", { class: "tidy-field-label", text: label })]);
307
331
  const preview = el("div", { class: "tidy-image-preview" });
@@ -366,93 +390,120 @@ export function start(config: Config): void {
366
390
  }
367
391
 
368
392
  // --- block forms ---------------------------------------------------------
369
- 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 });
393
+ type Reader = () => unknown;
394
+
395
+ /** One control for a block field (or a repeater row's sub-field). Returns the element and a reader of its coerced value. */
396
+ function fieldControl(f: FieldSchema, v: unknown): { node: HTMLElement; read: Reader } {
397
+ const label = f.label || f.slug;
398
+ if (f.type === "image") {
399
+ let picked: unknown = v && typeof v === "object" ? v : undefined;
400
+ const node = imageControl(label, imageSrc(v), (item) => {
401
+ picked = item ? mediaValue(item) : undefined;
402
+ });
403
+ return { node, read: () => picked };
404
+ }
405
+ const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: label })]);
406
+ if (f.type === "boolean") {
407
+ const cb = el("input", { type: "checkbox" });
373
408
  cb.checked = v === true;
374
409
  wrap.className = "tidy-field tidy-field-toggle";
375
410
  wrap.prepend(cb);
376
- } 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;
411
+ return { node: wrap, read: () => cb.checked };
412
+ }
413
+ if (f.type === "select") {
414
+ const s = el("select");
415
+ if (!f.required) s.append(el("option", { value: "", text: "" }));
416
+ for (const o of f.validation?.options ?? []) {
417
+ const opt = el("option", { value: o, text: o });
418
+ if (o === v) opt.selected = true;
381
419
  s.append(opt);
382
420
  }
383
421
  wrap.append(s);
384
- } else if (f.type === "number_input") {
385
- const i = el("input", { type: "number", name });
422
+ return { node: wrap, read: () => coerce(f, s.value) };
423
+ }
424
+ if (f.type === "multiSelect") {
425
+ const box = el("div", { class: "tidy-multi" });
426
+ const chosen = new Set(Array.isArray(v) ? (v as string[]) : []);
427
+ const boxes: HTMLInputElement[] = [];
428
+ for (const o of f.validation?.options ?? []) {
429
+ const cb = el("input", { type: "checkbox", value: o });
430
+ cb.checked = chosen.has(o);
431
+ boxes.push(cb);
432
+ box.append(el("label", { class: "tidy-multi-item" }, [cb, o]));
433
+ }
434
+ wrap.append(box);
435
+ return { node: wrap, read: () => boxes.filter((b) => b.checked).map((b) => b.value) };
436
+ }
437
+ if (f.type === "number" || f.type === "integer") {
438
+ const i = el("input", { type: "number", ...(f.type === "integer" ? { step: "1" } : { step: "any" }) });
386
439
  i.value = typeof v === "number" ? String(v) : "";
387
440
  wrap.append(i);
388
- } else if (f.multiline) {
389
- const t = el("textarea", { name, rows: "4" });
441
+ return { node: wrap, read: () => coerce(f, i.value) };
442
+ }
443
+ if (f.type === "datetime") {
444
+ const i = el("input", { type: "datetime-local" });
445
+ i.value = typeof v === "string" ? v.slice(0, 16) : "";
446
+ wrap.append(i);
447
+ return { node: wrap, read: () => (i.value ? new Date(i.value).toISOString() : undefined) };
448
+ }
449
+ if (f.type === "text") {
450
+ const t = el("textarea", { rows: "4" });
390
451
  t.value = typeof v === "string" ? v : "";
391
452
  wrap.append(t);
392
- } 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);
453
+ return { node: wrap, read: () => t.value };
396
454
  }
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;
455
+ const i = el("input", { type: f.type === "url" ? "url" : "text" });
456
+ i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
457
+ wrap.append(i);
458
+ return { node: wrap, read: () => i.value };
410
459
  }
411
460
 
412
- function buildBlockForm(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
461
+ function buildBlockForm(type: BlockTypeSchema, block: Block): { form: HTMLFormElement; read: () => Record<string, unknown> } {
413
462
  const form = el("form", { class: "tidy-form" });
414
- const repeaters = new Map<string, { field: FieldSchema; items: HTMLElement }>();
415
- for (const f of schema.fields) {
463
+ const readers: Array<[string, Reader]> = [];
464
+ for (const f of fieldsOf(type, block._version)) {
465
+ if (!EDITABLE_TYPES.has(f.type)) {
466
+ form.append(el("p", { class: "tidy-field-note", text: `${f.label || f.slug}: edit in the admin` }));
467
+ continue;
468
+ }
416
469
  if (f.type === "repeater") {
417
- const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label })]);
470
+ const sub = f.validation?.subFields ?? [];
471
+ const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label || f.slug })]);
418
472
  const items = el("div", { class: "tidy-repeater-items" });
419
- const rows = Array.isArray(data[f.action_id]) ? (data[f.action_id] as Record<string, unknown>[]) : [];
473
+ const rows = Array.isArray(block[f.slug]) ? (block[f.slug] as Record<string, unknown>[]) : [];
474
+ const rowReaders = new Map<HTMLElement, Array<[string, Reader]>>();
420
475
  const addItem = (row: Record<string, unknown>) => {
421
476
  const item = el("div", { class: "tidy-repeater-item" });
422
- 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());
477
+ const rs: Array<[string, Reader]> = [];
478
+ for (const s of sub) {
479
+ const c = fieldControl(s, row[s.slug]);
480
+ item.append(c.node);
481
+ rs.push([s.slug, c.read]);
482
+ }
483
+ const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: "Remove" });
484
+ rm.addEventListener("click", () => {
485
+ rowReaders.delete(item);
486
+ item.remove();
487
+ });
425
488
  item.append(rm);
426
489
  items.append(item);
490
+ rowReaders.set(item, rs);
427
491
  };
428
492
  rows.forEach(addItem);
429
- const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: `Add ${f.item_label ?? "item"}` });
493
+ const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: "Add" });
430
494
  add.addEventListener("click", () => addItem({}));
431
495
  box.append(items, add);
432
496
  form.append(box);
433
- 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);
497
+ readers.push([f.slug, () => Array.from(items.children).map((item) => compact(Object.fromEntries((rowReaders.get(item as HTMLElement) ?? []).map(([k, r]) => [k, r()]))))]);
498
+ continue;
442
499
  }
443
- return out;
444
- };
500
+ const c = fieldControl(f, block[f.slug]);
501
+ form.append(c.node);
502
+ readers.push([f.slug, c.read]);
503
+ }
445
504
  const read = (): Record<string, unknown> => {
446
505
  const out: Record<string, unknown> = {};
447
- for (const 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
- }
506
+ for (const [k, r] of readers) out[k] = r();
456
507
  return out;
457
508
  };
458
509
  return { form, read };
@@ -460,26 +511,29 @@ export function start(config: Config): void {
460
511
 
461
512
  function openBlock(key: string) {
462
513
  const block = value.find((b) => b._key === key);
463
- const schema = block ? schemaOf(block._type) : undefined;
464
- if (!block || !schema) return;
465
- const { form, read } = buildBlockForm(schema, block);
514
+ const type = block ? schemaOf(block._type) : undefined;
515
+ if (!block || !type) return;
516
+ const { form, read } = buildBlockForm(type, block);
466
517
  const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
467
518
  const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
468
519
  cancel.addEventListener("click", closePanel);
469
520
  form.addEventListener("submit", (e) => {
470
521
  e.preventDefault();
471
522
  saveBtn.disabled = true;
472
- void saveBlocks(updateBlock(value, key, read()));
523
+ // Fields the form read as "no value" are removed from the block, not written as null.
524
+ const data = read();
525
+ const next = updateBlock(value, key, data).map((b) => (b._key === key ? compact(b) : b)) as Block[];
526
+ void saveBlocks(next);
473
527
  });
474
528
  form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
475
- openPanel(schema.label, schema.description, form);
529
+ openPanel(type.label, type.description, form);
476
530
  }
477
531
 
478
532
  function openAdd(afterKey: string | null) {
479
533
  const listEl = el("div", { class: "tidy-add-list" });
480
- for (const s of config.blocks.filter((b) => b.category !== "Inline")) {
534
+ for (const s of config.blocks) {
481
535
  const b = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: s.label }), el("span", { text: s.description ?? "" })]);
482
- b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.type, afterKey, defaultsFor(s))));
536
+ b.addEventListener("click", () => void saveBlocks(insertBlock(value, s.slug, afterKey, defaultsFor(s))));
483
537
  listEl.append(b);
484
538
  }
485
539
  const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
@@ -507,13 +561,12 @@ export function start(config: Config): void {
507
561
  const values: Record<string, unknown> = {};
508
562
  const readers: Array<() => void> = [];
509
563
  for (const [name, f] of Object.entries(fields)) {
510
- if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime"].includes(f.kind)) continue;
564
+ if (HIDDEN_RECORD_FIELDS.has(name) || ["portableText", "reference", "json", "datetime", "blocks"].includes(f.kind)) continue;
511
565
  const label = f.label ?? name;
512
566
  const v = data[name];
513
567
  if (f.kind === "image") {
514
- const cur = v as { src?: string; meta?: { storageKey?: string } } | null | undefined;
515
568
  values[name] = v ?? null;
516
- form.append(imageControl(label, cur?.src ?? (cur?.meta?.storageKey ? `${API}/media/file/${cur.meta.storageKey}` : null), (item) => {
569
+ form.append(imageControl(label, imageSrc(v), (item) => {
517
570
  values[name] = item ? mediaValue(item) : null;
518
571
  }));
519
572
  continue;
package/src/index.ts CHANGED
@@ -29,6 +29,7 @@ export const PLATFORM_ROUTES = [
29
29
  { pattern: "/_tidy/health", entrypoint: `${PACKAGE}/routes/health` },
30
30
  { pattern: "/_tidy/rotate-token", entrypoint: `${PACKAGE}/routes/rotate-token` },
31
31
  { pattern: "/_tidy/fill", entrypoint: `${PACKAGE}/routes/fill` },
32
+ { pattern: "/_tidy/schema", entrypoint: `${PACKAGE}/routes/schema` },
32
33
  { pattern: "/_tidy/maintenance", entrypoint: `${PACKAGE}/routes/maintenance` }
33
34
  ] as const;
34
35
 
@@ -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.7.1";
2
+ export const VERSION = "0.9.0";
3
3
  export const PLUGIN_ID = "tidy-platform";