hecks 3.9.0 → 3.10.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.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/lib/hecks/deploy/bluebook/deploy.bluebook +106 -0
  3. data/lib/hecks/deploy/bluebook/deploy.hecksagon +8 -0
  4. data/lib/hecks/deploy/oidc.json +16 -3
  5. data/lib/hecks/hecks/adapters/deploy_toolchain/clearing.rb +51 -0
  6. data/lib/hecks/hecks/adapters/deploy_toolchain.rb +2 -0
  7. data/lib/hecks/hecks/adapters/site_toolchain/role_prober.rb +78 -0
  8. data/lib/hecks/hecks/adapters/site_toolchain.rb +29 -4
  9. data/lib/hecks/hecks/adapters/world_clearing.rb +65 -0
  10. data/lib/hecks/hecks/hecks.hecksagon +8 -0
  11. data/lib/hecks/hecks/hecks.world +2 -1
  12. data/lib/hecks/projections/deploy/fargate/settings/tuning.rb +15 -0
  13. data/lib/hecks/projections/deploy/fargate/settings.rb +3 -1
  14. data/lib/hecks/projections/deploy/fargate/stack/readers.rb +1 -0
  15. data/lib/hecks/projections/deploy/templates/fargate/Makefile.tmpl +7 -4
  16. data/lib/hecks/projections/deploy/templates/lambda/template.yaml.tmpl +1 -1
  17. data/lib/hecks/projections/site/cms_editor/attributes.rb +75 -0
  18. data/lib/hecks/projections/site/cms_editor/checks.rb +64 -0
  19. data/lib/hecks/projections/site/cms_editor/media.rb +85 -0
  20. data/lib/hecks/projections/site/cms_editor/pretty.rb +40 -0
  21. data/lib/hecks/projections/site/cms_editor/schema.rb +84 -0
  22. data/lib/hecks/projections/site/cms_editor/setting.rb +87 -0
  23. data/lib/hecks/projections/site/cms_editor/templates/package.json.tmpl +19 -0
  24. data/lib/hecks/projections/site/cms_editor/templates/src/app.ts.tmpl +176 -0
  25. data/lib/hecks/projections/site/cms_editor/templates/src/auth/membership.ts.tmpl +79 -0
  26. data/lib/hecks/projections/site/cms_editor/templates/src/auth/session.ts.tmpl +36 -0
  27. data/lib/hecks/projections/site/cms_editor/templates/src/auth/sso.ts.tmpl +20 -0
  28. data/lib/hecks/projections/site/cms_editor/templates/src/commands.ts.tmpl +79 -0
  29. data/lib/hecks/projections/site/cms_editor/templates/src/config.ts.tmpl +23 -0
  30. data/lib/hecks/projections/site/cms_editor/templates/src/host.ts.tmpl +26 -0
  31. data/lib/hecks/projections/site/cms_editor/templates/src/media/handler.ts.tmpl +154 -0
  32. data/lib/hecks/projections/site/cms_editor/templates/src/media/multipart.ts.tmpl +53 -0
  33. data/lib/hecks/projections/site/cms_editor/templates/src/media/sniff.ts.tmpl +94 -0
  34. data/lib/hecks/projections/site/cms_editor/templates/src/media/storage.ts.tmpl +50 -0
  35. data/lib/hecks/projections/site/cms_editor/templates/src/schema.ts.tmpl +85 -0
  36. data/lib/hecks/projections/site/cms_editor/templates/src/server.ts.tmpl +65 -0
  37. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_model.js.tmpl +120 -0
  38. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_parse.js.tmpl +320 -0
  39. data/lib/hecks/projections/site/cms_editor/templates/src/ui/body_widget.js.tmpl +282 -0
  40. data/lib/hecks/projections/site/cms_editor/templates/src/ui/fields.ts.tmpl +80 -0
  41. data/lib/hecks/projections/site/cms_editor/templates/src/ui/html.ts.tmpl +102 -0
  42. data/lib/hecks/projections/site/cms_editor/templates/src/ui/input.ts.tmpl +75 -0
  43. data/lib/hecks/projections/site/cms_editor/templates/src/ui/media_picker.js.tmpl +109 -0
  44. data/lib/hecks/projections/site/cms_editor/templates/src/ui/outline.ts.tmpl +27 -0
  45. data/lib/hecks/projections/site/cms_editor/templates/src/ui/pages.ts.tmpl +123 -0
  46. data/lib/hecks/projections/site/cms_editor/templates/tsconfig.json.tmpl +16 -0
  47. data/lib/hecks/projections/site/cms_editor.rb +107 -0
  48. data/lib/hecks/projections/site/role_probe.rb +72 -0
  49. data/lib/hecks/site/bluebook/site.bluebook +28 -1
  50. data/lib/hecks/site/bluebook/site.ports.hecksagon +6 -0
  51. data/lib/hecks/tools/site_routes/cli.rb +1 -1
  52. data/lib/hecks/tools/site_routes/editor_files.rb +38 -0
  53. data/lib/hecks/tools/site_routes.rb +14 -7
  54. data/lib/hecks/version.rb +1 -1
  55. data/prebuilt/{verdicts-3230a251570aa03666708d16f92e646feab6a3f8cc2a3540c017d748e41d41a1.json → verdicts-d6d584ad43b5f3cebf88aafbbdbf024c38574acf18e06adffbc555f7786b1c2f.json} +1 -1
  56. data/rust/Cargo.toml +3 -1
  57. data/rust/codegen/src/bridging.rs +1 -1
  58. data/rust/codegen/src/domain_generator.rs +9 -9
  59. data/rust/codegen/src/main.rs +1 -1
  60. data/rust/codegen/src/mutations.rs +46 -1
  61. data/rust/codegen/src/naming.rs +30 -0
  62. data/rust/host/HECKS_RELEASE +1 -1
  63. data/rust/host/src/boot.rs +443 -0
  64. data/rust/host/src/lib.rs +1 -0
  65. data/rust/host/src/main.rs +4 -438
  66. data/rust/host/src/secrets.rs +15 -0
  67. data/rust/host/src/web/commerce.rs +11 -3
  68. data/rust/host/src/web/resend_webhook.rs +2 -1
  69. data/rust/src/kernel/expr.rs +7 -1
  70. data/rust/src/kernel/expression_operators/presence.rs +41 -3
  71. metadata +42 -5
  72. /data/prebuilt/{4e6c3cd1f578a38732ea5454dae52562d03d8e9886216077a5c34cbf589f3463.marshal → c28d6d9db14eea3e17161e9d9c262efee30b256cf97693a7cfd552bce24b2f7f.marshal} +0 -0
  73. /data/prebuilt/{911cdcc56b5a094ed26583a4c76401dba6455b1f434722ed22b850bc1b1bdd28.marshal → cb375cba61ca0dbe8ddb9739c306510fe05e5f4b7a9b6955caff8224c63ea0e0.marshal} +0 -0
@@ -0,0 +1,282 @@
1
+ __BANNER__
2
+
3
+ // The rich-text widget, run in the browser. It edits the domain's own body tree: the surface holds
4
+ // the HTML `bodyToHtml` writes, and after every change `htmlToBody` reads it back and the result is
5
+ // written into hidden inputs named by dotted path, which is how every other value object and list in
6
+ // the form is posted. No editor format is kept or sent. Pasted or dropped HTML goes through
7
+ // `htmlToBodyWithNotes`, so whatever the body cannot hold is reduced and said so in the notes line.
8
+ import { ALIGNS, MAX_DEPTH, MAX_INDENT, bodyToFields, bodyToHtml, esc, fieldsToInputs, safeHref, spansToHtml } from "./body_model.js";
9
+ import { htmlToBodyWithNotes } from "./body_parse.js";
10
+
11
+ const KIND_TAGS = { paragraph: "p", heading1: "h1", heading2: "h2", heading3: "h3", heading4: "h4", quote: "blockquote" };
12
+
13
+ const BLOCK_CHOICES = [
14
+ ["paragraph", "Paragraph"], ["heading1", "Heading 1"], ["heading2", "Heading 2"], ["heading3", "Heading 3"], ["heading4", "Heading 4"],
15
+ ["quote", "Quote"], ["bullet_list", "Bulleted list"], ["numbered_list", "Numbered list"],
16
+ ];
17
+
18
+ const BUTTONS = [
19
+ ["bold", "B", "Bold (Ctrl+B)"], ["italic", "I", "Italic (Ctrl+I)"], ["underline", "U", "Underline (Ctrl+U)"],
20
+ ["strike", "S", "Strike-through (Ctrl+Shift+X)"], ["code", "</>", "Code (Ctrl+E)"], ["link", "Link", "Link (Ctrl+K)"],
21
+ ["break", "Line break", "Line break (Shift+Enter)"], ["left", "Left", "Align left"], ["center", "Center", "Align center"],
22
+ ["right", "Right", "Align right"], ["justify", "Justify", "Justify"], ["outdent", "Outdent", "Outdent"], ["indent", "Indent", "Indent"],
23
+ ["shallower", "Item up", "Move the list item up a level (Shift+Tab)"], ["deeper", "Item down", "Move the list item down a level (Tab)"],
24
+ ["itemkind", "Item kind", "Make the list item bulleted or numbered"], ["image", "Image", "Insert an image"], ["divider", "Divider", "Insert a divider"],
25
+ ];
26
+
27
+ const COMMANDS = { bold: "bold", italic: "italic", underline: "underline", strike: "strikeThrough" };
28
+
29
+ const placeholdered = (html) => html.replace(/<(p|h[1-4]|blockquote|li)((?: [^>]*)?)><\/\1>/g, "<$1$2><br></$1>");
30
+
31
+ function mount(root) {
32
+ const surface = root.querySelector("[data-surface]");
33
+ const fields = root.querySelector("[data-fields]");
34
+ const notes = root.querySelector("[data-notes]");
35
+ const name = root.dataset.name;
36
+ const picker = root.querySelector("[data-block-kind]");
37
+
38
+ const say = (messages) => {
39
+ notes.textContent = messages.length ? `Pasted content was adjusted to fit: ${messages.join("; ")}.` : "";
40
+ };
41
+
42
+ const lock = () => {
43
+ for (const figure of surface.querySelectorAll("figure")) figure.setAttribute("contenteditable", "false");
44
+ };
45
+
46
+ const sync = () => {
47
+ const { body } = htmlToBodyWithNotes(surface.innerHTML);
48
+ fields.innerHTML = fieldsToInputs(bodyToFields(body, name));
49
+ };
50
+
51
+ const blockOf = () => {
52
+ const sel = window.getSelection();
53
+ let node = sel && sel.anchorNode;
54
+ while (node && node.parentNode !== surface) node = node.parentNode;
55
+ return node && node.nodeType === 1 ? node : null;
56
+ };
57
+
58
+ const itemOf = () => {
59
+ let node = window.getSelection()?.anchorNode;
60
+ while (node && node !== surface && node.nodeName !== "LI") node = node.parentNode;
61
+ return node && node.nodeName === "LI" ? node : null;
62
+ };
63
+
64
+ const caretIn = (element) => {
65
+ const range = document.createRange();
66
+ range.selectNodeContents(element);
67
+ range.collapse(false);
68
+ const sel = window.getSelection();
69
+ sel.removeAllRanges();
70
+ sel.addRange(range);
71
+ };
72
+
73
+ const retag = (element, tag) => {
74
+ const made = document.createElement(tag);
75
+ for (const key of ["data-align", "data-indent"]) if (element.hasAttribute(key)) made.setAttribute(key, element.getAttribute(key));
76
+ made.append(...element.childNodes);
77
+ element.replaceWith(made);
78
+ return made;
79
+ };
80
+
81
+ const listFromBlock = (block, tag) => {
82
+ const list = document.createElement(tag);
83
+ for (const key of ["data-align", "data-indent"]) if (block.hasAttribute(key)) list.setAttribute(key, block.getAttribute(key));
84
+ const item = document.createElement("li");
85
+ item.append(...block.childNodes);
86
+ list.append(item);
87
+ block.replaceWith(list);
88
+ return list;
89
+ };
90
+
91
+ const itemToBlock = (item, tag) => {
92
+ const list = item.parentNode;
93
+ const made = document.createElement(tag);
94
+ made.append(...item.childNodes);
95
+ const tail = [...list.children].slice([...list.children].indexOf(item) + 1);
96
+ if (tail.length) {
97
+ const rest = list.cloneNode(false);
98
+ rest.append(...tail);
99
+ list.after(rest);
100
+ }
101
+ list.after(made);
102
+ item.remove();
103
+ if (!list.firstElementChild) list.remove();
104
+ return made;
105
+ };
106
+
107
+ const setKind = (choice) => {
108
+ const block = blockOf();
109
+ if (!block) return;
110
+ const listTag = choice === "bullet_list" ? "ul" : choice === "numbered_list" ? "ol" : null;
111
+ const isList = block.nodeName === "UL" || block.nodeName === "OL";
112
+ let made;
113
+ if (listTag) made = isList ? retag(block, listTag) : listFromBlock(block, listTag);
114
+ else made = isList ? itemToBlock(itemOf() ?? block.firstElementChild, KIND_TAGS[choice]) : retag(block, KIND_TAGS[choice]);
115
+ if (!listTag && made.nodeName !== KIND_TAGS[choice].toUpperCase()) made = retag(made, KIND_TAGS[choice]);
116
+ caretIn(made);
117
+ sync();
118
+ };
119
+
120
+ const bump = (element, attribute, step, low, high) => {
121
+ const next = Math.min(high, Math.max(low, Number(element.getAttribute(attribute) ?? 0) + step));
122
+ element.setAttribute(attribute, String(next));
123
+ sync();
124
+ };
125
+
126
+ const toggleCode = () => {
127
+ let node = window.getSelection()?.anchorNode;
128
+ while (node && node !== surface && node.nodeName !== "CODE") node = node.parentNode;
129
+ if (node && node.nodeName === "CODE") node.replaceWith(...node.childNodes);
130
+ else if (!window.getSelection().isCollapsed) document.execCommand("insertHTML", false, `<code>${esc(window.getSelection().toString())}</code>`);
131
+ sync();
132
+ };
133
+
134
+ const link = () => {
135
+ const given = window.prompt("Link address (a path, http://, https://, mailto: or tel:). Leave empty to remove the link.", "https://");
136
+ if (given === null) return;
137
+ const href = safeHref(given.trim());
138
+ if (given.trim() === "") document.execCommand("unlink");
139
+ else if (href) document.execCommand("createLink", false, href);
140
+ else notes.textContent = "That address is not allowed: use a path, http://, https://, mailto: or tel:.";
141
+ sync();
142
+ };
143
+
144
+ const figureHtml = (media, alt, caption) => bodyToHtml({ blocks: [{ kind: "image", media_ref: media, alt, caption }] });
145
+
146
+ // Puts an image block in place of `figure`, or after `anchor` (the block the caret was in), or at the end.
147
+ const placeImage = (media, alt, caption, figure, anchor) => {
148
+ const holder = document.createElement("div");
149
+ holder.innerHTML = figureHtml(media.trim(), alt, caption);
150
+ const made = holder.firstElementChild;
151
+ made.setAttribute("contenteditable", "false");
152
+ if (figure) figure.replaceWith(made);
153
+ else if (anchor && surface.contains(anchor)) anchor.after(made);
154
+ else surface.append(made);
155
+ sync();
156
+ };
157
+
158
+ // Without a picture store the image is named by its media key, typed in.
159
+ const promptImage = (figure, anchor) => {
160
+ const media = window.prompt("Media key of the image", figure?.dataset.mediaRef ?? "");
161
+ if (!media) return;
162
+ const alt = window.prompt("Alt text", figure?.dataset.alt ?? "") ?? "";
163
+ const caption = window.prompt("Caption (optional)", figure?.dataset.caption ?? "") ?? "";
164
+ placeImage(media, alt, caption, figure, anchor);
165
+ };
166
+
167
+ // With one, the picker uploads or chooses a registered picture; its module is loaded only then.
168
+ const pickImage = async (figure, anchor) => {
169
+ const { openPicker } = await import("./media_picker.js");
170
+ openPicker(root, {
171
+ url: root.dataset.media,
172
+ current: figure ? { key: figure.dataset.mediaRef ?? "", alt: figure.dataset.alt ?? "", caption: figure.dataset.caption ?? "" } : null,
173
+ onPick: (key, alt, caption) => placeImage(key, alt, caption, figure, anchor),
174
+ });
175
+ };
176
+
177
+ const askImage = (figure) => (root.dataset.media ? pickImage(figure, blockOf()) : promptImage(figure, blockOf()));
178
+
179
+ const insertDivider = () => {
180
+ const rule = document.createElement("hr");
181
+ const block = blockOf();
182
+ if (block) block.after(rule);
183
+ else surface.append(rule);
184
+ sync();
185
+ };
186
+
187
+ const align = (value) => {
188
+ const block = blockOf();
189
+ if (!block) return;
190
+ if (block.getAttribute("data-align") === value) block.removeAttribute("data-align");
191
+ else block.setAttribute("data-align", value);
192
+ sync();
193
+ };
194
+
195
+ const itemKind = () => {
196
+ const item = itemOf();
197
+ if (!item) return;
198
+ const own = item.parentNode.nodeName === "OL" ? "numbered_list" : "bullet_list";
199
+ const other = own === "numbered_list" ? "bullet_list" : "numbered_list";
200
+ if (item.hasAttribute("data-list-kind")) item.removeAttribute("data-list-kind");
201
+ else item.setAttribute("data-list-kind", other);
202
+ sync();
203
+ };
204
+
205
+ const act = (action) => {
206
+ surface.focus();
207
+ if (COMMANDS[action]) return document.execCommand(COMMANDS[action]), sync();
208
+ if (ALIGNS.includes(action)) return align(action);
209
+ const block = blockOf();
210
+ const handlers = {
211
+ code: toggleCode, link, divider: insertDivider, itemkind: itemKind, image: () => askImage(null),
212
+ break: () => { document.execCommand("insertLineBreak"); sync(); },
213
+ indent: () => block && bump(block, "data-indent", 1, 0, MAX_INDENT), outdent: () => block && bump(block, "data-indent", -1, 0, MAX_INDENT),
214
+ deeper: () => itemOf() && bump(itemOf(), "data-depth", 1, 0, MAX_DEPTH), shallower: () => itemOf() && bump(itemOf(), "data-depth", -1, 0, MAX_DEPTH),
215
+ };
216
+ return handlers[action]?.();
217
+ };
218
+
219
+ const insertParsed = (html) => {
220
+ const { body, notes: found } = htmlToBodyWithNotes(html);
221
+ say(found);
222
+ const single = body.blocks.length === 1 && body.blocks[0].spans;
223
+ document.execCommand("insertHTML", false, single ? spansToHtml(body.blocks[0].spans) : placeholdered(bodyToHtml(body)));
224
+ sync();
225
+ };
226
+
227
+ const fromTransfer = (data) => {
228
+ const html = data.getData("text/html");
229
+ if (html) return html;
230
+ return data.getData("text/plain").split(/\r?\n/).map((line) => `<p>${esc(line)}</p>`).join("");
231
+ };
232
+
233
+ const shortcuts = (event) => {
234
+ if (event.key === "Tab" && itemOf()) {
235
+ event.preventDefault();
236
+ return bump(itemOf(), "data-depth", event.shiftKey ? -1 : 1, 0, MAX_DEPTH);
237
+ }
238
+ if (!(event.ctrlKey || event.metaKey) || event.altKey) return;
239
+ const key = event.key.toLowerCase();
240
+ const action = event.shiftKey ? { x: "strike" }[key] : { b: "bold", i: "italic", u: "underline", e: "code", k: "link" }[key];
241
+ if (action) {
242
+ event.preventDefault();
243
+ act(action);
244
+ }
245
+ };
246
+
247
+ root.querySelector("[data-toolbar]").addEventListener("mousedown", (event) => { if (event.target.tagName !== "SELECT") event.preventDefault(); });
248
+ root.querySelector("[data-toolbar]").addEventListener("click", (event) => { const button = event.target.closest("button[data-action]"); if (button) act(button.dataset.action); });
249
+ picker.addEventListener("change", () => { surface.focus(); setKind(picker.value); });
250
+ surface.addEventListener("input", sync);
251
+ surface.addEventListener("keydown", shortcuts);
252
+ surface.addEventListener("paste", (event) => { event.preventDefault(); insertParsed(fromTransfer(event.clipboardData)); });
253
+ surface.addEventListener("drop", (event) => { event.preventDefault(); insertParsed(fromTransfer(event.dataTransfer)); });
254
+ surface.addEventListener("click", (event) => { const figure = event.target.closest("figure"); if (figure && surface.contains(figure)) askImage(figure); });
255
+ surface.addEventListener("blur", () => {
256
+ const { body } = htmlToBodyWithNotes(surface.innerHTML);
257
+ surface.innerHTML = placeholdered(bodyToHtml(body));
258
+ lock();
259
+ sync();
260
+ });
261
+ root.closest("form")?.addEventListener("submit", sync);
262
+
263
+ surface.setAttribute("contenteditable", "true");
264
+ document.execCommand("defaultParagraphSeparator", false, "p");
265
+ document.execCommand("styleWithCSS", false, "false");
266
+ if (!surface.firstElementChild) surface.innerHTML = "<p><br></p>";
267
+ surface.innerHTML = placeholdered(surface.innerHTML);
268
+ lock();
269
+ }
270
+
271
+ /** Builds the toolbar of a widget the server rendered, and wires it. */
272
+ function toolbar(root) {
273
+ const bar = root.querySelector("[data-toolbar]");
274
+ const options = BLOCK_CHOICES.map(([value, label]) => `<option value="${esc(value)}">${esc(label)}</option>`).join("");
275
+ const buttons = BUTTONS.map(([action, label, title]) => `<button type="button" data-action="${esc(action)}" title="${esc(title)}">${esc(label)}</button>`).join("");
276
+ bar.innerHTML = `<select data-block-kind aria-label="Block type">${options}</select>${buttons}`;
277
+ }
278
+
279
+ for (const root of document.querySelectorAll("[data-body-editor]")) {
280
+ toolbar(root);
281
+ mount(root);
282
+ }
@@ -0,0 +1,80 @@
1
+ __BANNER__
2
+
3
+ // The inputs of a form, derived from attributes. A value-object attribute is a nested fieldset of
4
+ // its parts; a `list_of` attribute is a run of repeatable rows (the rows it holds, then one blank
5
+ // row to fill); a lifecycle state is not an input at all. Each input is named by its dotted path
6
+ // (`byline.name`, `body.blocks.0.text`), which input.ts reads back into the shape the command takes.
7
+ import type { Aggregate, Attr } from "../schema.ts";
8
+ import { SCHEMA } from "../schema.ts";
9
+ import { esc, href } from "./html.ts";
10
+ import { bodyToFields, bodyToHtml, emptyBody, fieldsToInputs } from "./body_model.js";
11
+
12
+ type Value = unknown;
13
+
14
+ const label = (attr: Attr): string => attr.name.replace(/_/g, " ");
15
+
16
+ const optionalNote = (attr: Attr): string => (attr.optional ? ` <small>(optional)</small>` : "");
17
+
18
+ const inputType = (attr: Attr): string => (attr.kind === "integer" || attr.kind === "number" ? "number" : "text");
19
+
20
+ const member = (value: Value, key: string): Value => (value && typeof value === "object" ? (value as Record<string, Value>)[key] : undefined);
21
+
22
+ function scalar(attr: Attr, name: string, value: Value): string {
23
+ const text = esc(member(value, "value") ?? value ?? "");
24
+ if (attr.kind === "boolean") {
25
+ return `<label><input type="checkbox" name="${esc(name)}"${value === true ? " checked" : ""}> ${esc(label(attr))}${optionalNote(attr)}</label>`;
26
+ }
27
+ const step = attr.kind === "number" ? ` step="any"` : "";
28
+ return `<label>${esc(label(attr))}${optionalNote(attr)}<input type="${inputType(attr)}"${step} name="${esc(name)}" value="${text}"></label>`;
29
+ }
30
+
31
+ function parts(agg: Aggregate, attr: Attr, name: string, value: Value): string {
32
+ const members = agg.valueObjects[attr.type] ?? [];
33
+ return members.map((part) => field(agg, part, member(value, part.name), `${name}.`)).join("\n");
34
+ }
35
+
36
+ function one(agg: Aggregate, attr: Attr, name: string, value: Value): string {
37
+ if (attr.kind !== "object") return scalar(attr, name, value);
38
+ return `<fieldset><legend>${esc(label(attr))}${optionalNote(attr)}</legend>\n${parts(agg, attr, name, value)}\n</fieldset>`;
39
+ }
40
+
41
+ function listed(agg: Aggregate, attr: Attr, name: string, value: Value): string {
42
+ const held = Array.isArray(value) ? value : [];
43
+ const rows = [...held, undefined].map((item, index) => {
44
+ const body = attr.kind === "object" ? parts(agg, attr, `${name}.${index}`, item) : scalar({ ...attr, list: false }, `${name}.${index}`, item);
45
+ return `<fieldset class="row"><legend>${esc(label(attr))} ${index + 1}</legend>\n${body}\n</fieldset>`;
46
+ });
47
+ return `<fieldset><legend>${esc(label(attr))}${optionalNote(attr)}</legend>\n${rows.join("\n")}\n</fieldset>`;
48
+ }
49
+
50
+ // The rich-text widget. The server renders the body's current value as the surface's HTML and as the
51
+ // hidden inputs the form posts (the same dotted-path fields every value object and list uses), so a
52
+ // browser without the script still posts the body unchanged; body_widget.js then edits the surface
53
+ // and rewrites those inputs from the tree it reads back.
54
+ function body(attr: Attr, name: string, value: Value): string {
55
+ const tree = value && typeof value === "object" ? value : emptyBody();
56
+ const inputs = fieldsToInputs(bodyToFields(tree, name));
57
+ const media = SCHEMA.media ? ` data-media="${esc(href("media"))}"` : "";
58
+ return `<fieldset><legend>${esc(label(attr))}${optionalNote(attr)}</legend>
59
+ <div class="body-editor" data-body-editor data-name="${esc(name)}"${media}>
60
+ <div class="body-toolbar" data-toolbar role="toolbar" aria-label="Formatting"></div>
61
+ <div class="body-surface body" data-surface role="textbox" aria-multiline="true" aria-label="${esc(label(attr))}">${bodyToHtml(tree)}</div>
62
+ <p class="note" data-notes role="status" aria-live="polite"></p>
63
+ <div data-fields>
64
+ ${inputs}
65
+ </div>
66
+ </div>
67
+ </fieldset>`;
68
+ }
69
+
70
+ /** One attribute's inputs, `prefix` being the dotted path of the object that holds it. */
71
+ export function field(agg: Aggregate, attr: Attr, value: Value, prefix = ""): string {
72
+ const name = `${prefix}${attr.name}`;
73
+ if (attr.widget === "body") return body(attr, name, value);
74
+ return attr.list ? listed(agg, attr, name, value) : one(agg, attr, name, value);
75
+ }
76
+
77
+ /** The inputs for `attrs`, filled from `values` (an instance's state or a refused submission). */
78
+ export function fields(agg: Aggregate, attrs: Attr[], values: Record<string, Value> = {}): string {
79
+ return attrs.map((attr) => field(agg, attr, values[attr.name])).join("\n");
80
+ }
@@ -0,0 +1,102 @@
1
+ __BANNER__
2
+
3
+ // The page frame and the small helpers every page shares. Pages are plain strings built on the
4
+ // server; every value that came from the domain or the person is escaped on its way in.
5
+ import { EDITOR } from "../config.ts";
6
+ import { SCHEMA } from "../schema.ts";
7
+
8
+ /** Escapes text for an HTML body or a double-quoted attribute. */
9
+ export function esc(value: unknown): string {
10
+ return String(value ?? "").replace(/[&<>"']/g, (char) => `&#${char.charCodeAt(0)};`);
11
+ }
12
+
13
+ /** A path under the editor's base path; each segment is URL-encoded. */
14
+ export function href(...segments: string[]): string {
15
+ return [EDITOR.basePath, ...segments.map(encodeURIComponent)].join("/");
16
+ }
17
+
18
+ const STYLE = `
19
+ :root { color-scheme: light dark; --edge: #8886; --accent: #2457c5; }
20
+ body { font: 16px/1.5 system-ui, sans-serif; margin: 0; }
21
+ header, main { max-width: 64rem; margin: 0 auto; padding: 0.75rem 1rem; }
22
+ header { display: flex; flex-wrap: wrap; gap: 0.5rem 1.25rem; border-bottom: 1px solid var(--edge); align-items: baseline; }
23
+ header a.brand { font-weight: 700; }
24
+ a { color: var(--accent); }
25
+ table { border-collapse: collapse; width: 100%; }
26
+ th, td { text-align: left; padding: 0.35rem 0.5rem; border-bottom: 1px solid var(--edge); vertical-align: top; }
27
+ fieldset { border: 1px solid var(--edge); border-radius: 6px; margin: 0.5rem 0; padding: 0.5rem 0.75rem; }
28
+ label { display: block; margin: 0.4rem 0; }
29
+ label small { opacity: 0.7; }
30
+ input:not([type=checkbox]), select, textarea { display: block; width: 100%; max-width: 32rem; font: inherit; padding: 0.3rem; box-sizing: border-box; }
31
+ button { font: inherit; padding: 0.35rem 0.9rem; cursor: pointer; }
32
+ .badge { display: inline-block; padding: 0 0.6rem; border-radius: 1rem; border: 1px solid var(--edge); font-size: 0.85rem; }
33
+ .refusal { border: 1px solid #c33; border-radius: 6px; padding: 0.5rem 0.75rem; margin: 0.75rem 0; }
34
+ .outline { border-left: 3px solid var(--edge); margin: 0.25rem 0 0.25rem 0.25rem; padding-left: 0.75rem; }
35
+ .outline ul { margin: 0.2rem 0; padding-left: 1.25rem; }
36
+ .note { opacity: 0.7; font-size: 0.85rem; }
37
+ .body { overflow-wrap: anywhere; }
38
+ .body p, .body h1, .body h2, .body h3, .body h4, .body blockquote, .body li { white-space: pre-wrap; }
39
+ .body p, .body h1, .body h2, .body h3, .body h4, .body blockquote, .body ul, .body ol, .body figure { margin: 0.4rem 0; }
40
+ .body blockquote { border-left: 3px solid var(--edge); padding-left: 0.75rem; }
41
+ .body [data-align=left] { text-align: left; }
42
+ .body [data-align=center] { text-align: center; }
43
+ .body [data-align=right] { text-align: right; }
44
+ .body [data-align=justify] { text-align: justify; }
45
+ .body [data-indent="1"] { margin-left: 2rem; } .body [data-indent="2"] { margin-left: 4rem; } .body [data-indent="3"] { margin-left: 6rem; }
46
+ .body [data-indent="4"] { margin-left: 8rem; } .body [data-indent="5"] { margin-left: 10rem; } .body [data-indent="6"] { margin-left: 12rem; }
47
+ .body [data-indent="7"] { margin-left: 14rem; } .body [data-indent="8"] { margin-left: 16rem; }
48
+ .body li[data-depth="1"] { margin-left: 1.5rem; } .body li[data-depth="2"] { margin-left: 3rem; } .body li[data-depth="3"] { margin-left: 4.5rem; }
49
+ .body li[data-depth="4"] { margin-left: 6rem; } .body li[data-depth="5"] { margin-left: 7.5rem; }
50
+ .body .body-media { display: inline-block; border: 1px dashed var(--edge); padding: 0.2rem 0.5rem; }
51
+ .body-editor { max-width: 48rem; }
52
+ .body-toolbar { display: flex; flex-wrap: wrap; gap: 0.25rem; margin-bottom: 0.4rem; }
53
+ .body-toolbar button, .body-toolbar select { font-size: 0.85rem; padding: 0.15rem 0.5rem; width: auto; display: inline-block; }
54
+ .body-surface { min-height: 8rem; border: 1px solid var(--edge); border-radius: 6px; padding: 0.4rem 0.75rem; }
55
+ .body-surface:focus { outline: 2px solid var(--accent); }
56
+ .body-surface figure { cursor: pointer; }
57
+ .body-picker { border: 1px solid var(--edge); border-radius: 6px; padding: 0.5rem 0.75rem; margin-bottom: 0.5rem; }
58
+ .picker-list { display: flex; flex-wrap: wrap; gap: 0.5rem; list-style: none; margin: 0.25rem 0; padding: 0; max-height: 12rem; overflow: auto; }
59
+ .picker-list button { display: flex; flex-direction: column; align-items: center; max-width: 6rem; font-size: 0.8rem; }
60
+ .picker-list button[aria-pressed=true] { outline: 2px solid var(--accent); }
61
+ .picker-list img { object-fit: cover; }
62
+ `;
63
+
64
+ /** True when a page body holds the rich-text widget, which is the only page that needs its script. */
65
+ const hasWidget = (body: string): boolean => body.includes("data-body-editor");
66
+
67
+ /** The frame of every page: the editor's name, a link to each aggregate, and `body`. */
68
+ export function page(title: string, body: string): string {
69
+ const nav = SCHEMA.aggregates.map((agg) => `<a href="${esc(href(agg.name))}">${esc(agg.name)}</a>`).join("\n ");
70
+ return `<!doctype html>
71
+ <html lang="en">
72
+ <head>
73
+ <meta charset="utf-8">
74
+ <meta name="viewport" content="width=device-width, initial-scale=1">
75
+ <title>${esc(title)} - ${esc(EDITOR.title)}</title>
76
+ <style>${STYLE}</style>
77
+ </head>
78
+ <body>
79
+ <header>
80
+ <a class="brand" href="${esc(EDITOR.basePath)}">${esc(EDITOR.title)}</a>
81
+ <nav>
82
+ ${nav}
83
+ </nav>
84
+ </header>
85
+ <main>
86
+ ${body}
87
+ </main>
88
+ ${hasWidget(body) ? `<script type="module" src="${esc(href("assets", "body_widget.js"))}"></script>` : ""}
89
+ </body>
90
+ </html>
91
+ `;
92
+ }
93
+
94
+ /** The inline message a refused command shows above its form. */
95
+ export function refusal(message: string | null): string {
96
+ return message ? `<div class="refusal" role="alert">${esc(message)}</div>` : "";
97
+ }
98
+
99
+ /** A lifecycle state as a badge. */
100
+ export function badge(status: unknown): string {
101
+ return status ? `<span class="badge">${esc(status)}</span>` : "";
102
+ }
@@ -0,0 +1,75 @@
1
+ __BANNER__
2
+
3
+ // A submitted form turned back into what a command takes. The form's inputs are named by dotted
4
+ // path (see fields.ts); this reads them against the same attributes, so a nested value object comes
5
+ // back nested and a run of repeatable rows comes back as a list. A row left entirely blank is not a
6
+ // row, and an optional part left blank is left out; a required text part left blank is sent as
7
+ // empty text so that the domain's own rule refuses it in its own words. A rich-text body is read the
8
+ // same way: the widget posts its tree as the same dotted-path fields (`body.blocks.0.spans.1.text`),
9
+ // so there is no second encoding.
10
+ import type { Aggregate, Attr } from "../schema.ts";
11
+
12
+ export type Form = Map<string, string>;
13
+
14
+ /** The fields of an `application/x-www-form-urlencoded` body, the last of a repeated name winning. */
15
+ export function parseForm(body: string): Form {
16
+ const form: Form = new Map();
17
+ for (const [key, value] of new URLSearchParams(body)) form.set(key, value);
18
+ return form;
19
+ }
20
+
21
+ const blank = (value: unknown): boolean =>
22
+ value === "" ||
23
+ value === undefined ||
24
+ value === null ||
25
+ (Array.isArray(value) && value.every(blank)) ||
26
+ (typeof value === "object" && !Array.isArray(value) && Object.values(value as object).every(blank));
27
+
28
+ function indices(form: Form, path: string): number[] {
29
+ const found = new Set<number>();
30
+ for (const key of form.keys()) {
31
+ const match = key.startsWith(`${path}.`) ? /^(\d+)(\.|$)/.exec(key.slice(path.length + 1)) : null;
32
+ if (match) found.add(Number(match[1]));
33
+ }
34
+ return [...found].sort((a, b) => a - b);
35
+ }
36
+
37
+ function text(attr: Attr, form: Form, path: string, optional: boolean): unknown {
38
+ const raw = form.get(path);
39
+ if (attr.kind === "boolean") return form.has(path);
40
+ if (raw === undefined || (raw === "" && optional)) return undefined;
41
+ if ((attr.kind === "integer" || attr.kind === "number") && raw.trim() !== "" && !Number.isNaN(Number(raw))) return Number(raw);
42
+ return raw;
43
+ }
44
+
45
+ function object(agg: Aggregate, attr: Attr, form: Form, path: string, optional: boolean): unknown {
46
+ const built: Record<string, unknown> = {};
47
+ for (const part of agg.valueObjects[attr.type] ?? []) {
48
+ const value = build(agg, part, form, `${path}.`, optional);
49
+ if (value !== undefined) built[part.name] = value;
50
+ }
51
+ return Object.keys(built).length > 0 ? built : undefined;
52
+ }
53
+
54
+ function single(agg: Aggregate, attr: Attr, form: Form, path: string, optional: boolean): unknown {
55
+ return attr.kind === "object" ? object(agg, attr, form, path, optional) : text(attr, form, path, optional);
56
+ }
57
+
58
+ /** One attribute's value from the form, or undefined when it is to be left out. */
59
+ export function build(agg: Aggregate, attr: Attr, form: Form, prefix: string, optionalAbove = false): unknown {
60
+ const path = `${prefix}${attr.name}`;
61
+ const optional = optionalAbove || attr.optional;
62
+ if (!attr.list) return single(agg, attr, form, path, optional);
63
+ const rows = indices(form, path).map((index) => single(agg, attr, form, `${path}.${index}`, optional));
64
+ return rows.filter((row) => !blank(row));
65
+ }
66
+
67
+ /** The arguments a command takes, from a submitted form. */
68
+ export function commandArguments(agg: Aggregate, attrs: Attr[], form: Form): Record<string, unknown> {
69
+ const args: Record<string, unknown> = {};
70
+ for (const attr of attrs) {
71
+ const value = build(agg, attr, form, "");
72
+ if (value !== undefined) args[attr.name] = value;
73
+ }
74
+ return args;
75
+ }
@@ -0,0 +1,109 @@
1
+ __BANNER__
2
+
3
+ // The picture picker the rich-text widget opens for an image block. It lists the pictures the domain
4
+ // has registered (thumbnails come from the storage adapter's own addresses), uploads a new one by
5
+ // button or by dropping a file, and hands the widget a media key, alt text and an optional caption.
6
+ // Alt text is required before anything is inserted; the editor server and the domain refuse a picture
7
+ // without one, and whatever they refuse with is shown here, next to the form. Everything the server
8
+ // sends is put into the page as text, never as markup.
9
+
10
+ const make = (tag, props = {}, ...children) => {
11
+ const element = Object.assign(document.createElement(tag), props);
12
+ element.append(...children);
13
+ return element;
14
+ };
15
+
16
+ const field = (label, input) => make("label", {}, label, input);
17
+
18
+ async function getPictures(url) {
19
+ const response = await fetch(url, { headers: { Accept: "application/json" } });
20
+ if (!response.ok) throw new Error((await response.json().catch(() => ({}))).error ?? "The pictures could not be listed.");
21
+ return (await response.json()).pictures;
22
+ }
23
+
24
+ async function upload(url, file, alt) {
25
+ const form = new FormData();
26
+ form.append("alt", alt);
27
+ form.append("file", file);
28
+ const response = await fetch(url, { method: "POST", body: form, headers: { Accept: "application/json" } });
29
+ const answer = await response.json().catch(() => ({}));
30
+ if (!response.ok) throw new Error(answer.error ?? "The upload failed.");
31
+ return answer;
32
+ }
33
+
34
+ /** One registered picture as a button: its thumbnail, its alt text and a short key. */
35
+ function entry(picture, choose) {
36
+ const image = make("img", { src: picture.url, alt: "", loading: "lazy", width: 64, height: 64 });
37
+ const button = make("button", { type: "button", title: picture.alt }, image, make("span", {}, picture.alt || picture.key.slice(0, 12)));
38
+ button.addEventListener("click", () => choose(picture, button));
39
+ return make("li", {}, button);
40
+ }
41
+
42
+ /**
43
+ * Opens the picker inside `root` (the widget's element). `options.url` is the media endpoint,
44
+ * `options.current` the figure being replaced (or null), `options.onPick(key, alt, caption)` is called
45
+ * with the choice.
46
+ */
47
+ export function openPicker(root, options) {
48
+ root.querySelector("[data-picker]")?.remove();
49
+ const { url, current, onPick } = options;
50
+ let chosen = current && current.key ? current.key : null;
51
+ const list = make("ul", { className: "picker-list" });
52
+ const status = make("p", { className: "refusal", role: "alert", hidden: true });
53
+ const file = make("input", { type: "file", accept: "image/jpeg,image/png,image/webp,image/gif,image/avif" });
54
+ const alt = make("input", { type: "text", required: true, value: current?.alt ?? "" });
55
+ const caption = make("input", { type: "text", value: current?.caption ?? "" });
56
+ const drop = make("p", { className: "note" }, "Choose a file or drop one here. JPEG, PNG, WebP, GIF or AVIF.");
57
+ const insert = make("button", { type: "button" }, "Insert");
58
+ const cancel = make("button", { type: "button" }, "Cancel");
59
+ const panel = make("div", { className: "body-picker" }, make("strong", {}, "Insert a picture"), drop, field("Upload a new picture", file), make("p", { className: "note" }, "Or choose a registered picture:"), list, field("Alt text (required)", alt), field("Caption (optional)", caption), status, insert, " ", cancel);
60
+ panel.setAttribute("data-picker", "");
61
+
62
+ const say = (message) => {
63
+ status.textContent = message ?? "";
64
+ status.hidden = !message;
65
+ };
66
+ const close = () => panel.remove();
67
+
68
+ const choose = (picture, button) => {
69
+ chosen = picture.key;
70
+ file.value = "";
71
+ if (!alt.value.trim()) alt.value = picture.alt;
72
+ for (const other of list.querySelectorAll("button")) other.removeAttribute("aria-pressed");
73
+ button.setAttribute("aria-pressed", "true");
74
+ say(null);
75
+ };
76
+
77
+ const submit = async () => {
78
+ say(null);
79
+ if (!alt.value.trim()) return say("Alt text is required: describe the picture for someone who cannot see it.");
80
+ try {
81
+ const key = file.files[0] ? (await upload(url, file.files[0], alt.value.trim())).key : chosen;
82
+ if (!key) return say("Choose a registered picture or upload one.");
83
+ onPick(key, alt.value.trim(), caption.value.trim());
84
+ close();
85
+ } catch (err) {
86
+ say(err.message);
87
+ }
88
+ };
89
+
90
+ panel.addEventListener("dragover", (event) => event.preventDefault());
91
+ panel.addEventListener("drop", (event) => {
92
+ event.preventDefault();
93
+ if (event.dataTransfer.files.length) file.files = event.dataTransfer.files;
94
+ });
95
+ file.addEventListener("change", () => { chosen = null; });
96
+ panel.addEventListener("keydown", (event) => {
97
+ if (event.key !== "Enter" || event.target.tagName !== "INPUT" || event.target.type === "file") return;
98
+ event.preventDefault();
99
+ submit();
100
+ });
101
+ insert.addEventListener("click", submit);
102
+ cancel.addEventListener("click", close);
103
+
104
+ root.querySelector("[data-toolbar]").after(panel);
105
+ getPictures(url)
106
+ .then((pictures) => list.append(...pictures.map((picture) => entry(picture, choose))))
107
+ .catch((err) => say(err.message));
108
+ alt.focus();
109
+ }