@tideworthy/tidysites-platform 0.3.0 → 0.4.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,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.0
4
+
5
+ - The Tideworthy on-site editor: `@tideworthy/tidysites-platform/editor` is an Astro component a template renders in edit mode with the page's block schemas and block list. Every framed block (`data-tidy-block`, `data-tidy-type`, list in `data-tidy-blocks`) gets a hover chip with Edit, move up, move down, add a section below, and remove; Edit opens a side panel whose form comes from the block's Block Kit field schema (text, multiline, number, toggle, select, repeater). Saves go through EmDash's `PUT /_emdash/api/content/:collection/:id` as a draft with the editor's session; Publish is EmDash's publish call. Exists because EmDash's inline editor renders custom blocks as "edit in admin" placeholders. Pure block operations in `@tideworthy/tidysites-platform/editor/ops`.
6
+
3
7
  ## 0.3.0
4
8
 
5
9
  - `tidyPlatform()` registers a middleware: with a `TIDY_FRAME_ANCESTORS` variable on the site (space-separated https origins), responses carry `Content-Security-Policy: frame-ancestors 'self' <origins>` in place of EmDash's `X-Frame-Options: SAMEORIGIN`, so the platform can embed the staging preview. Inert without the variable. New export `@tideworthy/tidysites-platform/middleware`.
package/README.md CHANGED
@@ -43,6 +43,20 @@ export { default, PluginBridge } from "@emdash-cms/cloudflare/worker";
43
43
 
44
44
  EmDash answers every page with `X-Frame-Options: SAMEORIGIN` unless a Content-Security-Policy is present. `tidyPlatform()` registers a middleware that, when the site carries a `TIDY_FRAME_ANCESTORS` variable (space-separated https origins), replaces that header with `Content-Security-Policy: frame-ancestors 'self' <origins>` so the platform's website page can embed the site. Without the variable nothing changes.
45
45
 
46
+ ## The on-site editor
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:
49
+
50
+ ```astro
51
+ ---
52
+ import TidyEditor from "@tideworthy/tidysites-platform/editor";
53
+ const editMode = Astro.cookies.get("emdash-edit-mode")?.value === "true";
54
+ ---
55
+ {editMode && <TidyEditor collection="pages" id={page.data.id} blocks={blockSchema} value={content} />}
56
+ ```
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 calls EmDash's publish. Article bodies keep EmDash's inline editor by passing the original array.
59
+
46
60
  ## Bindings the routes read
47
61
 
48
62
  | Binding | Type | Purpose |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tideworthy/tidysites-platform",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -19,6 +19,8 @@
19
19
  ".": "./src/index.ts",
20
20
  "./plugin": "./src/plugin.ts",
21
21
  "./middleware": "./src/middleware.ts",
22
+ "./editor": "./src/editor/TidyEditor.astro",
23
+ "./editor/ops": "./src/editor/ops.ts",
22
24
  "./client": "./src/client.ts",
23
25
  "./routes/bootstrap": "./src/routes/bootstrap.ts",
24
26
  "./routes/session": "./src/routes/session.ts",
@@ -29,7 +31,7 @@
29
31
  "./routes/maintenance": "./src/routes/maintenance.ts"
30
32
  },
31
33
  "scripts": {
32
- "typecheck": "tsc --noEmit",
34
+ "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p tsconfig.editor.json",
33
35
  "test": "vitest run",
34
36
  "check": "npm run typecheck && npm run test"
35
37
  },
@@ -0,0 +1,60 @@
1
+ ---
2
+ /**
3
+ * The Tideworthy on-site editor for block-built pages. Render it from the
4
+ * layout when the page is in edit mode, with the entry the page shows, the
5
+ * block schemas the template declares, and the block list as rendered. The
6
+ * template frames each custom block with `data-tidy-block` (its key) and
7
+ * `data-tidy-type`, and wraps the list in `data-tidy-blocks`.
8
+ */
9
+ import type { Block, BlockSchema } from "./ops";
10
+
11
+ interface Props {
12
+ collection: string;
13
+ id: string;
14
+ field?: string;
15
+ blocks: BlockSchema[];
16
+ value: Block[];
17
+ /** The entry's status as rendered: "draft" shows the Publish control at once. */
18
+ status?: string;
19
+ }
20
+ const { collection, id, field = "content", blocks, value, status } = Astro.props;
21
+ const config = JSON.stringify({ collection, id, field, blocks, value, status });
22
+ ---
23
+ <script type="application/json" id="tidy-editor-config" set:html={config.replace(/</g, "\\u003c")} />
24
+ <script src="./runtime.ts"></script>
25
+ <style is:global>
26
+ [data-tidy-block] { position: relative; outline: 2px dashed transparent; outline-offset: -2px; transition: outline-color 120ms; cursor: pointer; }
27
+ [data-tidy-block]:hover, [data-tidy-block].tidy-active { outline-color: #2b7fd1; }
28
+ .tidy-chip { position: absolute; z-index: 2147483000; display: flex; gap: 4px; align-items: center; padding: 4px 6px; background: #17211c; color: #fff; border-radius: 8px; font: 500 13px/1.2 system-ui, sans-serif; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
29
+ .tidy-chip[hidden], .tidy-panel[hidden], .tidy-status[hidden] { display: none; }
30
+ .tidy-chip-label { padding: 0 6px 0 4px; opacity: .85; }
31
+ .tidy-btn { appearance: none; border: 1px solid rgba(255,255,255,.25); background: rgba(255,255,255,.08); color: inherit; border-radius: 6px; padding: 4px 8px; font: inherit; cursor: pointer; }
32
+ .tidy-btn:hover { background: rgba(255,255,255,.18); }
33
+ .tidy-btn:disabled { opacity: .35; cursor: default; }
34
+ .tidy-btn-primary { background: #2b7fd1; border-color: #2b7fd1; color: #fff; }
35
+ .tidy-btn-primary:hover { background: #2470bd; }
36
+ .tidy-btn-danger:hover { background: #b3261e; border-color: #b3261e; }
37
+ .tidy-btn-sm { padding: 3px 8px; font-size: 12px; }
38
+ .tidy-add-end { display: block; width: calc(100% - 32px); margin: 16px; padding: 14px; border: 2px dashed #2b7fd1; border-radius: 10px; background: rgba(43,127,209,.06); color: #2b7fd1; font: 600 14px system-ui, sans-serif; cursor: pointer; }
39
+ .tidy-panel { position: fixed; top: 0; right: 0; bottom: 0; width: min(420px, 100vw); z-index: 2147483001; overflow: auto; background: #fff; color: #17211c; border-left: 1px solid #d3dcd6; box-shadow: -12px 0 40px rgba(0,0,0,.18); padding: 20px; font: 14px/1.45 system-ui, sans-serif; }
40
+ .tidy-panel-head h2 { margin: 0 0 4px; font-size: 18px; }
41
+ .tidy-panel-head p { margin: 0 0 16px; color: #4a5851; }
42
+ .tidy-form { display: grid; gap: 14px; }
43
+ .tidy-field { display: grid; gap: 6px; }
44
+ .tidy-field-label { font-weight: 600; font-size: 13px; }
45
+ .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; }
46
+ .tidy-field textarea { resize: vertical; min-height: 88px; }
47
+ .tidy-field-toggle { display: flex; align-items: center; gap: 10px; }
48
+ .tidy-repeater { border: 1px solid #d3dcd6; border-radius: 8px; padding: 12px; display: grid; gap: 12px; }
49
+ .tidy-repeater legend { font-weight: 600; padding: 0 6px; }
50
+ .tidy-repeater-item { display: grid; gap: 10px; padding: 12px; background: #f6f7f5; border-radius: 6px; }
51
+ .tidy-panel .tidy-btn { color: #17211c; border-color: #c9d2cc; background: #fff; }
52
+ .tidy-panel .tidy-btn-primary { color: #fff; background: #2b7fd1; border-color: #2b7fd1; }
53
+ .tidy-panel .tidy-btn-danger { color: #b3261e; }
54
+ .tidy-panel-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid #e6ede8; }
55
+ .tidy-add-list { display: grid; gap: 8px; margin-bottom: 16px; }
56
+ .tidy-add-item { display: grid; gap: 2px; text-align: left; padding: 10px 12px; border: 1px solid #d3dcd6; border-radius: 8px; background: #fff; cursor: pointer; font: inherit; }
57
+ .tidy-add-item:hover { border-color: #2b7fd1; background: rgba(43,127,209,.06); }
58
+ .tidy-add-item span { color: #4a5851; font-size: 13px; }
59
+ .tidy-status { position: fixed; left: 16px; bottom: 16px; z-index: 2147483000; display: flex; gap: 10px; align-items: center; padding: 8px 12px; background: #17211c; color: #fff; border-radius: 999px; font: 500 13px system-ui, sans-serif; box-shadow: 0 6px 20px rgba(0,0,0,.25); }
60
+ </style>
@@ -0,0 +1,94 @@
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.
5
+ */
6
+ export type Block = { _type: string; _key?: string } & Record<string, unknown>;
7
+
8
+ export interface FieldSchema {
9
+ type: "text_input" | "number_input" | "toggle" | "select" | "repeater" | string;
10
+ action_id: string;
11
+ 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;
18
+ }
19
+
20
+ export interface BlockSchema {
21
+ type: string;
22
+ label: string;
23
+ category?: string;
24
+ description?: string;
25
+ fields: FieldSchema[];
26
+ }
27
+
28
+ export function newKey(random: () => number = Math.random): string {
29
+ let s = "";
30
+ for (let i = 0; i < 12; i++) s += "abcdefghijklmnopqrstuvwxyz0123456789"[Math.floor(random() * 36)];
31
+ return s;
32
+ }
33
+
34
+ export function findBlock(blocks: Block[], key: string): Block | undefined {
35
+ return blocks.find((b) => b._key === key);
36
+ }
37
+
38
+ /** The block with `key` gets `data` merged over it; `_type` and `_key` never change. */
39
+ 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));
41
+ }
42
+
43
+ export function moveBlock(blocks: Block[], key: string, direction: -1 | 1): Block[] {
44
+ const i = blocks.findIndex((b) => b._key === key);
45
+ const j = i + direction;
46
+ if (i < 0 || j < 0 || j >= blocks.length) return blocks;
47
+ const out = blocks.slice();
48
+ const a = out[i] as Block;
49
+ out[i] = out[j] as Block;
50
+ out[j] = a;
51
+ return out;
52
+ }
53
+
54
+ export function removeBlock(blocks: Block[], key: string): Block[] {
55
+ return blocks.filter((b) => b._key !== key);
56
+ }
57
+
58
+ /** Insert a new block of `type` after the block with `afterKey` (or at the end). */
59
+ export function insertBlock(blocks: Block[], type: string, afterKey: string | null, data: Record<string, unknown>, key = newKey()): Block[] {
60
+ const block: Block = { ...data, _type: type, _key: key };
61
+ const i = afterKey ? blocks.findIndex((b) => b._key === afterKey) : -1;
62
+ if (i < 0) return [...blocks, block];
63
+ return [...blocks.slice(0, i + 1), block, ...blocks.slice(i + 1)];
64
+ }
65
+
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> {
68
+ 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] = "";
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** Coerce a raw form value into the field's stored type. */
80
+ export function coerce(field: FieldSchema, raw: unknown): unknown {
81
+ switch (field.type) {
82
+ case "toggle":
83
+ return raw === true || raw === "on" || raw === "true";
84
+ case "number_input": {
85
+ if (raw === "" || raw === null || raw === undefined) return null;
86
+ const n = Number(raw);
87
+ return Number.isFinite(n) ? n : null;
88
+ }
89
+ case "repeater":
90
+ return Array.isArray(raw) ? raw : [];
91
+ default:
92
+ return typeof raw === "string" ? raw : raw === null || raw === undefined ? "" : String(raw);
93
+ }
94
+ }
@@ -0,0 +1,273 @@
1
+ /// <reference lib="dom" />
2
+ /// <reference lib="dom.iterable" />
3
+ /**
4
+ * The Tideworthy on-site editor. Runs on a page rendered in edit mode. Every
5
+ * custom block on the page sits in a frame (`[data-tidy-block]`, rendered by
6
+ * the template); hovering a frame shows a chip with Edit, Up, Down, Remove,
7
+ * and Add; Edit opens a panel whose form comes from the block's field schema.
8
+ * Saves go through EmDash's own content API with the editor's session, as a
9
+ * draft; Publish is the same call EmDash's toolbar makes.
10
+ */
11
+ import { coerce, defaultsFor, insertBlock, moveBlock, removeBlock, updateBlock, type Block, type BlockSchema, type FieldSchema } from "./ops";
12
+
13
+ interface Config {
14
+ collection: string;
15
+ id: string;
16
+ field: string;
17
+ blocks: BlockSchema[];
18
+ value: Block[];
19
+ status?: string;
20
+ }
21
+
22
+ const API = "/_emdash/api";
23
+ const CSRF = { "X-EmDash-Request": "1" };
24
+
25
+ function el<K extends keyof HTMLElementTagNameMap>(tag: K, attrs: Record<string, string> = {}, children: Array<Node | string> = []): HTMLElementTagNameMap[K] {
26
+ const e = document.createElement(tag);
27
+ for (const [k, v] of Object.entries(attrs)) {
28
+ if (k === "class") e.className = v;
29
+ else if (k === "text") e.textContent = v;
30
+ else e.setAttribute(k, v);
31
+ }
32
+ for (const c of children) e.append(c);
33
+ return e;
34
+ }
35
+
36
+ export function start(config: Config): void {
37
+ let value: Block[] = config.value;
38
+ const schemaOf = (type: string) => config.blocks.find((b) => b.type === type);
39
+ const frames = () => Array.from(document.querySelectorAll<HTMLElement>("[data-tidy-block]"));
40
+
41
+ // --- status pill -------------------------------------------------------
42
+ const status = el("div", { class: "tidy-status", hidden: "" });
43
+ const statusText = el("span");
44
+ const publishBtn = el("button", { type: "button", class: "tidy-btn tidy-btn-primary", text: "Publish" });
45
+ status.append(statusText, publishBtn);
46
+ document.body.append(status);
47
+ function setStatus(text: string, showPublish: boolean) {
48
+ statusText.textContent = text;
49
+ publishBtn.hidden = !showPublish;
50
+ status.hidden = false;
51
+ }
52
+ publishBtn.addEventListener("click", async () => {
53
+ publishBtn.disabled = true;
54
+ setStatus("Publishing", true);
55
+ const r = await fetch(`${API}/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}/publish`, { method: "POST", credentials: "same-origin", headers: CSRF });
56
+ if (r.ok) location.reload();
57
+ else {
58
+ publishBtn.disabled = false;
59
+ setStatus(`Publish failed (${r.status})`, true);
60
+ }
61
+ });
62
+ // Saves make a draft; Publish is the same call EmDash's toolbar makes and is always at hand.
63
+ setStatus(config.status === "draft" ? "Unpublished changes" : "Editing", true);
64
+
65
+ async function save(next: Block[]): Promise<void> {
66
+ setStatus("Saving", false);
67
+ const r = await fetch(`${API}/content/${encodeURIComponent(config.collection)}/${encodeURIComponent(config.id)}`, {
68
+ method: "PUT",
69
+ credentials: "same-origin",
70
+ headers: { ...CSRF, "content-type": "application/json" },
71
+ body: JSON.stringify({ data: { [config.field]: next } })
72
+ });
73
+ if (!r.ok) {
74
+ setStatus(`Save failed (${r.status})`, false);
75
+ return;
76
+ }
77
+ value = next;
78
+ setStatus("Saved. Reloading", false);
79
+ location.reload();
80
+ }
81
+
82
+ // --- chip on the hovered frame ------------------------------------------
83
+ const chip = el("div", { class: "tidy-chip", hidden: "" });
84
+ const chipLabel = el("span", { class: "tidy-chip-label" });
85
+ const bEdit = el("button", { type: "button", class: "tidy-btn", text: "Edit" });
86
+ const bUp = el("button", { type: "button", class: "tidy-btn", title: "Move up", text: "↑" });
87
+ const bDown = el("button", { type: "button", class: "tidy-btn", title: "Move down", text: "↓" });
88
+ const bAdd = el("button", { type: "button", class: "tidy-btn", title: "Add a section below", text: "+" });
89
+ const bRemove = el("button", { type: "button", class: "tidy-btn tidy-btn-danger", title: "Remove this section", text: "Remove" });
90
+ chip.append(chipLabel, bEdit, bUp, bDown, bAdd, bRemove);
91
+ document.body.append(chip);
92
+ let current: HTMLElement | null = null;
93
+
94
+ function showChip(frame: HTMLElement) {
95
+ current = frame;
96
+ const key = frame.dataset.tidyBlock ?? "";
97
+ const type = frame.dataset.tidyType ?? "";
98
+ chipLabel.textContent = schemaOf(type)?.label ?? type;
99
+ const r = frame.getBoundingClientRect();
100
+ chip.style.top = `${Math.max(8, r.top + window.scrollY - 2)}px`;
101
+ chip.style.left = `${Math.max(8, r.left + window.scrollX + 8)}px`;
102
+ const i = value.findIndex((b) => b._key === key);
103
+ bUp.disabled = i <= 0;
104
+ bDown.disabled = i < 0 || i >= value.length - 1;
105
+ chip.hidden = false;
106
+ frames().forEach((f) => f.classList.toggle("tidy-active", f === frame));
107
+ }
108
+ for (const f of frames()) {
109
+ f.addEventListener("mouseenter", () => showChip(f));
110
+ f.addEventListener("click", (e) => {
111
+ // Links and buttons inside a section keep working; clicking anything else edits it.
112
+ if ((e.target as HTMLElement).closest("a,button,input,select,textarea,label")) return;
113
+ e.preventDefault();
114
+ showChip(f);
115
+ openPanel(f.dataset.tidyBlock ?? "");
116
+ });
117
+ }
118
+ chip.addEventListener("mouseenter", () => { if (current) current.classList.add("tidy-active"); });
119
+ bEdit.addEventListener("click", () => current && openPanel(current.dataset.tidyBlock ?? ""));
120
+ bUp.addEventListener("click", () => current && confirmSave(moveBlock(value, current.dataset.tidyBlock ?? "", -1)));
121
+ bDown.addEventListener("click", () => current && confirmSave(moveBlock(value, current.dataset.tidyBlock ?? "", 1)));
122
+ bRemove.addEventListener("click", () => {
123
+ if (!current) return;
124
+ if (confirm(`Remove this ${chipLabel.textContent} section?`)) void save(removeBlock(value, current.dataset.tidyBlock ?? ""));
125
+ });
126
+ bAdd.addEventListener("click", () => current && openAdd(current.dataset.tidyBlock ?? null));
127
+ function confirmSave(next: Block[]) {
128
+ if (next !== value) void save(next);
129
+ }
130
+
131
+ // "Add a section" at the end of the block list.
132
+ const list = document.querySelector<HTMLElement>("[data-tidy-blocks]");
133
+ if (list) {
134
+ const add = el("button", { type: "button", class: "tidy-add-end", text: "+ Add a section" });
135
+ add.addEventListener("click", () => openAdd(null));
136
+ list.after(add);
137
+ }
138
+
139
+ // --- panel ---------------------------------------------------------------
140
+ const panel = el("aside", { class: "tidy-panel", hidden: "" });
141
+ document.body.append(panel);
142
+ function closePanel() {
143
+ panel.hidden = true;
144
+ panel.replaceChildren();
145
+ }
146
+
147
+ function fieldInput(f: FieldSchema, v: unknown, name: string): HTMLElement {
148
+ const wrap = el("label", { class: "tidy-field" }, [el("span", { class: "tidy-field-label", text: f.label })]);
149
+ if (f.type === "toggle") {
150
+ const cb = el("input", { type: "checkbox", name });
151
+ cb.checked = v === true;
152
+ wrap.className = "tidy-field tidy-field-toggle";
153
+ wrap.prepend(cb);
154
+ } else if (f.type === "select") {
155
+ const s = el("select", { name });
156
+ for (const o of f.options ?? []) {
157
+ const opt = el("option", { value: o.value, text: o.label });
158
+ if (o.value === v) opt.selected = true;
159
+ s.append(opt);
160
+ }
161
+ wrap.append(s);
162
+ } else if (f.type === "number_input") {
163
+ const i = el("input", { type: "number", name });
164
+ i.value = typeof v === "number" ? String(v) : "";
165
+ wrap.append(i);
166
+ } else if (f.multiline) {
167
+ const t = el("textarea", { name, rows: "4" });
168
+ t.value = typeof v === "string" ? v : "";
169
+ wrap.append(t);
170
+ } else {
171
+ const i = el("input", { type: "text", name });
172
+ i.value = typeof v === "string" ? v : v === null || v === undefined ? "" : String(v);
173
+ wrap.append(i);
174
+ }
175
+ return wrap;
176
+ }
177
+
178
+ /** Builds the form for a block; returns a reader that yields the block's data from the inputs. */
179
+ function buildForm(schema: BlockSchema, data: Record<string, unknown>): { form: HTMLFormElement; read: () => Record<string, unknown> } {
180
+ const form = el("form", { class: "tidy-form" });
181
+ const repeaters = new Map<string, { field: FieldSchema; items: HTMLElement }>();
182
+ for (const f of schema.fields) {
183
+ if (f.type === "repeater") {
184
+ const box = el("fieldset", { class: "tidy-repeater" }, [el("legend", { text: f.label })]);
185
+ const items = el("div", { class: "tidy-repeater-items" });
186
+ const rows = Array.isArray(data[f.action_id]) ? (data[f.action_id] as Record<string, unknown>[]) : [];
187
+ const addItem = (row: Record<string, unknown>) => {
188
+ const item = el("div", { class: "tidy-repeater-item" });
189
+ for (const sub of f.fields ?? []) item.append(fieldInput(sub, row[sub.action_id], sub.action_id));
190
+ const rm = el("button", { type: "button", class: "tidy-btn tidy-btn-danger tidy-btn-sm", text: `Remove ${f.item_label ?? "item"}` });
191
+ rm.addEventListener("click", () => item.remove());
192
+ item.append(rm);
193
+ items.append(item);
194
+ };
195
+ rows.forEach(addItem);
196
+ const add = el("button", { type: "button", class: "tidy-btn tidy-btn-sm", text: `Add ${f.item_label ?? "item"}` });
197
+ add.addEventListener("click", () => addItem({}));
198
+ box.append(items, add);
199
+ form.append(box);
200
+ repeaters.set(f.action_id, { field: f, items });
201
+ } else form.append(fieldInput(f, data[f.action_id], f.action_id));
202
+ }
203
+ const read = (): Record<string, unknown> => {
204
+ const out: Record<string, unknown> = {};
205
+ for (const f of schema.fields) {
206
+ if (f.type === "repeater") {
207
+ const rep = repeaters.get(f.action_id)!;
208
+ out[f.action_id] = Array.from(rep.items.children).map((item) => {
209
+ const row: Record<string, unknown> = {};
210
+ for (const sub of f.fields ?? []) {
211
+ const input = item.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`[name="${sub.action_id}"]`);
212
+ row[sub.action_id] = coerce(sub, sub.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
213
+ }
214
+ return row;
215
+ });
216
+ continue;
217
+ }
218
+ const input = form.querySelector<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>(`:scope > .tidy-field [name="${f.action_id}"]`);
219
+ out[f.action_id] = coerce(f, f.type === "toggle" ? (input as HTMLInputElement | null)?.checked : input?.value);
220
+ }
221
+ return out;
222
+ };
223
+ return { form, read };
224
+ }
225
+
226
+ function openPanel(key: string) {
227
+ const block = value.find((b) => b._key === key);
228
+ const schema = block ? schemaOf(block._type) : undefined;
229
+ if (!block || !schema) return;
230
+ panel.replaceChildren();
231
+ const { form, read } = buildForm(schema, block);
232
+ const saveBtn = el("button", { type: "submit", class: "tidy-btn tidy-btn-primary", text: "Save" });
233
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
234
+ cancel.addEventListener("click", closePanel);
235
+ form.addEventListener("submit", (e) => {
236
+ e.preventDefault();
237
+ saveBtn.disabled = true;
238
+ void save(updateBlock(value, key, read()));
239
+ });
240
+ form.append(el("div", { class: "tidy-panel-actions" }, [saveBtn, cancel]));
241
+ panel.append(el("header", { class: "tidy-panel-head" }, [el("h2", { text: schema.label }), ...(schema.description ? [el("p", { text: schema.description })] : [])]), form);
242
+ panel.hidden = false;
243
+ (form.querySelector("input,textarea,select") as HTMLElement | null)?.focus();
244
+ }
245
+
246
+ function openAdd(afterKey: string | null) {
247
+ panel.replaceChildren();
248
+ const head = el("header", { class: "tidy-panel-head" }, [el("h2", { text: "Add a section" })]);
249
+ const listEl = el("div", { class: "tidy-add-list" });
250
+ for (const s of config.blocks.filter((b) => b.category !== "Inline")) {
251
+ const b = el("button", { type: "button", class: "tidy-add-item" }, [el("strong", { text: s.label }), el("span", { text: s.description ?? "" })]);
252
+ b.addEventListener("click", () => void save(insertBlock(value, s.type, afterKey, defaultsFor(s))));
253
+ listEl.append(b);
254
+ }
255
+ const cancel = el("button", { type: "button", class: "tidy-btn", text: "Cancel" });
256
+ cancel.addEventListener("click", closePanel);
257
+ panel.append(head, listEl, el("div", { class: "tidy-panel-actions" }, [cancel]));
258
+ panel.hidden = false;
259
+ }
260
+
261
+ document.addEventListener("keydown", (e) => {
262
+ if (e.key === "Escape" && !panel.hidden) closePanel();
263
+ });
264
+ }
265
+
266
+ const cfgEl = document.getElementById("tidy-editor-config");
267
+ if (cfgEl?.textContent) {
268
+ try {
269
+ start(JSON.parse(cfgEl.textContent) as Config);
270
+ } catch (err) {
271
+ console.error("[tidy-editor]", err);
272
+ }
273
+ }
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.3.0";
2
+ export const VERSION = "0.4.0";
3
3
  export const PLUGIN_ID = "tidy-platform";