@panphora/clayjs 1.13.1 → 1.14.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 (136) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69667 -41708
  3. package/dist/server/checkpoint.cjs +70690 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/rooms.cjs +72039 -0
  6. package/dist/server/rooms.mjs +66 -0
  7. package/docs/guide.md +94 -93
  8. package/entries/clay.js +22 -0
  9. package/package.json +16 -2
  10. package/packed-contract.json +17 -3
  11. package/src/core/conflict-download.js +11 -2
  12. package/src/core/host-meta.js +75 -0
  13. package/src/core/persist.js +11 -0
  14. package/src/core/save-core.js +266 -31
  15. package/src/core/save.js +62 -32
  16. package/src/core/snapshot.js +39 -13
  17. package/src/core/style-comparisons.js +37 -0
  18. package/src/dom/insert-style-tag.js +11 -12
  19. package/src/lib/effect.js +130 -0
  20. package/src/lib/style-adoption.js +9 -0
  21. package/src/lib/style.js +44 -0
  22. package/src/live/fast-lane.js +347 -60
  23. package/src/live/local-lane.js +32 -6
  24. package/src/live/ops.js +77 -0
  25. package/src/live/provenance.js +129 -0
  26. package/src/live/save-history.js +188 -0
  27. package/src/live/server-lane.js +9 -2
  28. package/src/live/store.js +194 -16
  29. package/src/loader-logic.js +7 -3
  30. package/src/loader.js +6 -0
  31. package/src/page/address.js +20 -0
  32. package/src/page/adopt-boot.js +189 -0
  33. package/src/page/adopt.js +240 -0
  34. package/src/page/ai-tool.js +188 -0
  35. package/src/page/announce.js +21 -0
  36. package/src/page/blocks.js +1121 -0
  37. package/src/page/blocksel.js +269 -0
  38. package/src/page/board-drag.js +226 -0
  39. package/src/page/board-panel.js +353 -0
  40. package/src/page/board-style.js +146 -0
  41. package/src/page/board-view.js +447 -0
  42. package/src/page/board.js +41 -0
  43. package/src/page/chart.js +220 -0
  44. package/src/page/checklist.js +143 -0
  45. package/src/page/chrome.js +587 -0
  46. package/src/page/csv-import.js +169 -0
  47. package/src/page/drag.js +202 -0
  48. package/src/page/drop.js +112 -0
  49. package/src/page/editor.js +384 -0
  50. package/src/page/highlight.js +268 -0
  51. package/src/page/inline.js +60 -1
  52. package/src/page/items.js +159 -0
  53. package/src/page/link.js +247 -0
  54. package/src/page/markdown.js +71 -0
  55. package/src/page/media.js +570 -0
  56. package/src/page/merge.js +130 -0
  57. package/src/page/model.js +71 -9
  58. package/src/page/placeholder.js +23 -0
  59. package/src/page/projection.js +647 -0
  60. package/src/page/recover.js +193 -0
  61. package/src/page/rich/cell.js +16 -0
  62. package/src/page/rich/chart.js +104 -0
  63. package/src/page/rich/csv.js +118 -0
  64. package/src/page/rich/page-tool.js +86 -0
  65. package/src/page/rich/tool-runtime.js +195 -0
  66. package/src/page/save-projection.js +53 -0
  67. package/src/page/save-states.js +85 -0
  68. package/src/page/schema.js +12 -5
  69. package/src/page/table-kinds.js +20 -0
  70. package/src/page/table-paste.js +57 -0
  71. package/src/page/table-select.js +244 -0
  72. package/src/page/table-style.js +25 -0
  73. package/src/page/table.js +650 -0
  74. package/src/page/themes/content.css +46 -0
  75. package/src/page/themes/light-1.css +269 -0
  76. package/src/page/themes/light-2.css +211 -0
  77. package/src/page/themes/light-3.css +130 -0
  78. package/src/page/themes/light-4.css +299 -0
  79. package/src/page/tool-data.js +25 -0
  80. package/src/page/tool-view.js +68 -0
  81. package/src/page/tools.js +133 -0
  82. package/src/page/view.js +136 -0
  83. package/src/page/ybind/author.js +25 -0
  84. package/src/page/ybind/binding.js +92 -0
  85. package/src/page/ybind/board-commands.js +170 -0
  86. package/src/page/ybind/board.js +89 -0
  87. package/src/page/ybind/bridge.js +382 -0
  88. package/src/page/ybind/conversion.js +26 -0
  89. package/src/page/ybind/core.js +7 -0
  90. package/src/page/ybind/decode.js +208 -0
  91. package/src/page/ybind/doc.js +58 -0
  92. package/src/page/ybind/external.js +190 -0
  93. package/src/page/ybind/group.js +124 -0
  94. package/src/page/ybind/index.js +12 -0
  95. package/src/page/ybind/layout.js +27 -0
  96. package/src/page/ybind/marks.js +18 -0
  97. package/src/page/ybind/migrate.js +152 -0
  98. package/src/page/ybind/moves.js +288 -0
  99. package/src/page/ybind/origins.js +257 -0
  100. package/src/page/ybind/parts.js +44 -0
  101. package/src/page/ybind/projection-map.js +92 -0
  102. package/src/page/ybind/provider.js +285 -0
  103. package/src/page/ybind/recovery.js +411 -0
  104. package/src/page/ybind/seed.js +27 -0
  105. package/src/page/ybind/table.js +166 -0
  106. package/src/page/ybind/tokens.js +93 -0
  107. package/src/page/ybind/walk.js +333 -0
  108. package/src/page/ybind/writer.js +307 -0
  109. package/src/plugins/indicator.js +16 -6
  110. package/src/plugins/live.js +742 -65
  111. package/src/plugins/page.js +547 -0
  112. package/src/plugins/undo.js +60 -3
  113. package/src/server/checkpoint/index.js +218 -0
  114. package/src/server/rooms/index.js +59 -0
  115. package/src/server/rooms/live-data-ops.js +369 -0
  116. package/src/server/rooms/live-data-rooms.js +965 -0
  117. package/src/server/rooms/live-data-tags.js +49 -0
  118. package/src/server/rooms/live-page-history.js +41 -0
  119. package/src/server/rooms/live-provenance.js +142 -0
  120. package/src/server/rooms/page-admission.js +79 -0
  121. package/src/server/rooms/page-conversion.js +62 -0
  122. package/src/server/rooms/page-rebase.js +108 -0
  123. package/src/server/rooms/page-state.js +91 -0
  124. package/src/server/rooms/page-write-admission.js +63 -0
  125. package/src/server/rooms/page-write-guard.js +23 -0
  126. package/src/server/rooms/recent.js +18 -0
  127. package/src/server/rooms/spec-wire.js +17 -0
  128. package/src/standalone.js +22 -0
  129. package/src/sync/live-sync.js +13 -2
  130. package/src/sync/stream.js +62 -16
  131. package/src/ui/index.js +4 -0
  132. package/src/ui/toast.js +21 -5
  133. package/src/vendor/pierre/pierre.vendor.js +3 -0
  134. package/src/vendor/yjs.vendor.js +7 -0
  135. package/src/page/binding.js +0 -647
  136. package/src/page/replay.js +0 -578
@@ -0,0 +1,647 @@
1
+ // The page's readable projection: the saved <article> rendered from the live
2
+ // record, parsed back, and a revision hash over what it shows.
3
+ //
4
+ // No ProseMirror here: the view-mode renderer and the server can load it.
5
+ // Everything works on the projection model, an ordered list of items
6
+ // { id, t, parent?, ...fields }
7
+ // which is a block's JSON without pos (order replaces it), without w, with
8
+ // defaults left out exactly as model.js blockJson leaves them out, with marks
9
+ // as runs, and with tables and boards holding ordered arrays instead of
10
+ // pos-keyed maps. parse(render(blocks)) equals project(blocks) for every kind.
11
+ import { chartModel, renderChartSvg } from "./rich/chart.js";
12
+ import { cellText } from "./rich/cell.js";
13
+
14
+ export const ATOM = "";
15
+ export const BREAK = '@{"type":"hard_break"}';
16
+ const CODES = ["b", "i", "s", "c", "a"];
17
+ const TAGS = { b: "strong", i: "em", s: "s", c: "code" };
18
+ const NEST = ["a", "b", "i", "s", "c"];
19
+
20
+ // Mirrors schema.js attrs minus id and pos (a test pins the two together).
21
+ export const DEFAULTS = {
22
+ paragraph: { parent: null },
23
+ heading: { parent: null, level: 2 },
24
+ list_item: { parent: null, kind: "bullet", indent: 0, checked: false, owner: null, due: null },
25
+ callout: { parent: null },
26
+ code_block: { parent: null, lang: "" },
27
+ toggle: { parent: null, open: false },
28
+ divider: { parent: null },
29
+ image: { parent: null, src: "", alt: "", caption: "", width: null, align: null },
30
+ file: { parent: null, src: "", name: "", size: null, mime: null },
31
+ table: { parent: null, cols: {}, sort: null },
32
+ chart: { parent: null, source: null, kind: "line", x: null, series: [], title: "", highlight: null },
33
+ board: { parent: null, columns: {}, cards: {} },
34
+ tool: { parent: null, artifact: null, rev: null, state: {} },
35
+ preserved: { parent: null, html: "" },
36
+ };
37
+ const TEXT = new Set(["paragraph", "heading", "list_item", "callout", "code_block", "toggle"]);
38
+
39
+ const same = (a, b) => canon(a) === canon(b);
40
+
41
+ // ---- order (model.js orderedIds, without ProseMirror) ----------------------
42
+
43
+ export function orderedIds(map) {
44
+ return Object.keys(map).sort((x, y) => {
45
+ const px = map[x].pos ?? "", py = map[y].pos ?? "";
46
+ if (px !== py) return px < py ? -1 : 1;
47
+ return x < y ? -1 : x > y ? 1 : 0;
48
+ });
49
+ }
50
+
51
+ // ---- marks ------------------------------------------------------------------
52
+
53
+ // inline.js unitsOf: runs [[n, key]] or per-unit keys -> one key per unit, or null.
54
+ export function unitsOf(m, length) {
55
+ if (!Array.isArray(m)) return null;
56
+ if (!Array.isArray(m[0])) return m.length === length ? m : null;
57
+ const out = [];
58
+ for (const run of m) {
59
+ if (!Array.isArray(run) || !Number.isInteger(run[0]) || run[0] < 1 || typeof run[1] !== "string" || out.length + run[0] > length) return null;
60
+ for (let i = 0; i < run[0]; i++) out.push(run[1]);
61
+ }
62
+ return out.length === length ? out : null;
63
+ }
64
+
65
+ export function runsOf(units) {
66
+ const out = [];
67
+ for (const key of units) {
68
+ const last = out[out.length - 1];
69
+ if (last && last[1] === key) last[0]++;
70
+ else out.push([1, key]);
71
+ }
72
+ return out;
73
+ }
74
+
75
+ // A mark key as { b: true, a: ["href"] ... }; null for an atom key.
76
+ function marksOfKey(key) {
77
+ const out = {};
78
+ if (!key || key[0] === "@") return out;
79
+ for (let i = 0; i < key.length;) {
80
+ const code = key[i++];
81
+ if (key[i] === "[") {
82
+ let depth = 0, str = false, j = i;
83
+ for (; j < key.length; j++) {
84
+ const ch = key[j];
85
+ if (str) { if (ch === "\\") j++; else if (ch === '"') str = false; }
86
+ else if (ch === '"') str = true;
87
+ else if (ch === "[") depth++;
88
+ else if (ch === "]" && --depth === 0) { j++; break; }
89
+ }
90
+ out[code] = JSON.parse(key.slice(i, j));
91
+ i = j;
92
+ } else out[code] = true;
93
+ }
94
+ return out;
95
+ }
96
+
97
+ // A link to a script URL is never kept: it would run on the page's origin when a
98
+ // reader clicks it in the saved article.
99
+ const SCRIPT_URL = /^(javascript|vbscript|data):/i;
100
+ const safeMarks = (marks) => {
101
+ if (marks.a && SCRIPT_URL.test(String(marks.a[0]).replace(/[\u0000-\u0020]/g, ""))) delete marks.a;
102
+ return marks;
103
+ };
104
+
105
+ function keyOfMarks(marks) {
106
+ let key = "";
107
+ for (const code of CODES) {
108
+ if (!marks[code]) continue;
109
+ key += code;
110
+ if (Array.isArray(marks[code])) key += JSON.stringify(marks[code]);
111
+ }
112
+ return key;
113
+ }
114
+
115
+ // ---- text normalization -----------------------------------------------------
116
+
117
+ // Whitespace that holds a newline is formatting in every block but code: it
118
+ // collapses to one space, and to nothing at either end. The editor never puts
119
+ // a newline in such a block (a line break is a hard_break atom), so its own
120
+ // text passes through unchanged.
121
+ export function normalizeText(c, units) {
122
+ if (!c.includes("\n") && !c.includes("\r")) return { c, units };
123
+ let out = "", keys = [];
124
+ for (let i = 0; i < c.length;) {
125
+ if (/\s/.test(c[i]) && c[i] !== " ") {
126
+ let j = i;
127
+ while (j < c.length && /\s/.test(c[j]) && c[j] !== " ") j++;
128
+ const run = c.slice(i, j);
129
+ if (/[\n\r]/.test(run)) {
130
+ if (i > 0 && j < c.length) { out += " "; keys.push(units ? units[i] : ""); }
131
+ } else {
132
+ out += run;
133
+ if (units) keys.push(...units.slice(i, j)); else keys.push(...run.split("").map(() => ""));
134
+ }
135
+ i = j;
136
+ } else {
137
+ out += c[i];
138
+ keys.push(units ? units[i] : "");
139
+ i++;
140
+ }
141
+ }
142
+ return { c: out, units: keys };
143
+ }
144
+
145
+ function textFields(c, units) {
146
+ const out = { c };
147
+ if (units && units.some(Boolean)) out.m = runsOf(units);
148
+ return out;
149
+ }
150
+
151
+ // ---- the record and its layout ----------------------------------------------
152
+
153
+ // Every live tag's value merged: { id: block } without w, tombstoned blocks gone.
154
+ export function blocksOfValues(values) {
155
+ const blocks = {};
156
+ for (const value of values) {
157
+ if (!value || typeof value !== "object") continue;
158
+ const deleted = value.deleted || {};
159
+ for (const [id, block] of Object.entries(value.blocks || {})) {
160
+ if (deleted[id] || !block || typeof block !== "object") continue;
161
+ const { w, ...rest } = block;
162
+ blocks[id] = rest;
163
+ }
164
+ }
165
+ return blocks;
166
+ }
167
+
168
+ // Blocks in order, each with the parent and indent the article can show: a
169
+ // parent is kept only when that toggle is open on the container stack (the
170
+ // outline's rule), and a list item's indent is clamped to one more than the
171
+ // item before it in its run, 0 for a run's first item.
172
+ function layout(blocks) {
173
+ const out = [];
174
+ const stack = [];
175
+ let prev = null;
176
+ for (const id of orderedIds(blocks)) {
177
+ const b = blocks[id];
178
+ const t = DEFAULTS[b.t] ? b.t : null;
179
+ let parent = b.parent ?? null;
180
+ if (parent !== null) {
181
+ const at = stack.lastIndexOf(parent);
182
+ if (at < 0) parent = null;
183
+ stack.length = at < 0 ? 0 : at + 1;
184
+ } else stack.length = 0;
185
+ let indent = 0;
186
+ if (t === "list_item") {
187
+ const runs = prev && prev.t === "list_item" && prev.parent === parent;
188
+ const want = Number.isInteger(b.indent) && b.indent > 0 ? b.indent : 0;
189
+ indent = runs ? Math.min(want, prev.indent + 1) : 0;
190
+ }
191
+ const item = { id, b, t, parent, indent };
192
+ out.push(item);
193
+ prev = item;
194
+ if (t === "toggle") stack.push(id);
195
+ }
196
+ return out;
197
+ }
198
+
199
+ const omitDefaults = (t, fields) => {
200
+ const out = {};
201
+ for (const [k, v] of Object.entries(fields)) if (!(k in DEFAULTS[t]) || !same(v, DEFAULTS[t][k])) out[k] = v;
202
+ return out;
203
+ };
204
+
205
+ const columnWidth = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : null);
206
+
207
+ function cellModel(value) {
208
+ if (value === null || typeof value !== "object" || typeof value.c !== "string") return normalizeText(cellText(value), null).c;
209
+ const raw = unitsOf(value.m, value.c.length);
210
+ const units = raw && raw.map((k, i) => (!k ? "" : k[0] === "@" ? (k === BREAK && value.c[i] === ATOM ? k : "") : keyOfMarks(safeMarks(marksOfKey(k)))));
211
+ if (!units) return normalizeText(cellText(value), null).c;
212
+ const n = normalizeText(value.c, units);
213
+ const fields = textFields(n.c, n.units);
214
+ return fields.m ? fields : fields.c;
215
+ }
216
+
217
+ function projectTable(b) {
218
+ const cols = b.cols && typeof b.cols === "object" ? b.cols : {};
219
+ const rows = b.rows && typeof b.rows === "object" ? b.rows : {};
220
+ const colIds = orderedIds(cols);
221
+ return {
222
+ cols: colIds.map((id) => ({ id, name: normalizeText(String(cols[id].name ?? ""), null).c, ...(cols[id].kind != null ? { kind: String(cols[id].kind) } : {}), ...(columnWidth(cols[id].width) ? { width: columnWidth(cols[id].width) } : {}) })),
223
+ rows: orderedIds(rows).map((id) => ({ id, cells: Object.fromEntries(colIds.map((col) => [col, cellModel(rows[id].cells?.[col])])) })),
224
+ };
225
+ }
226
+
227
+ function projectBoard(b) {
228
+ const columns = b.columns && typeof b.columns === "object" ? b.columns : {};
229
+ const cards = b.cards && typeof b.cards === "object" ? b.cards : {};
230
+ const colIds = orderedIds(columns);
231
+ const extra = [...new Set(Object.values(cards).map((k) => String(k.col ?? "")))].filter((c) => !columns[c]).sort();
232
+ const order = [...colIds, ...extra];
233
+ const byCol = (col) => orderedIds(Object.fromEntries(Object.entries(cards).filter(([, k]) => String(k.col ?? "") === col)));
234
+ return {
235
+ columns: colIds.map((id) => ({ id, name: normalizeText(String(columns[id].name ?? ""), null).c })),
236
+ cards: order.flatMap((col) => byCol(col).map((id) => {
237
+ const k = cards[id];
238
+ return { id, col, title: normalizeText(String(k.title ?? ""), null).c, ...(k.owner != null ? { owner: String(k.owner) } : {}), ...(k.due != null ? { due: String(k.due) } : {}) };
239
+ })),
240
+ };
241
+ }
242
+
243
+ function projectOne({ id, b, t, parent, indent }) {
244
+ if (!t) return { id, t: "preserved", html: "" };
245
+ const { t: _t, pos, c, m, rows, cols, columns, cards, parent: _p, indent: _i, ...attrs } = b;
246
+ for (const k of Object.keys(attrs)) if (!(k in DEFAULTS[t])) delete attrs[k];
247
+ const fields = { ...attrs, parent };
248
+ if (t === "list_item") fields.indent = indent;
249
+ if (t === "heading") fields.level = clampLevel(attrs.level ?? 2);
250
+ if (t === "image" && fields.width != null) fields.width = widthOf(String(fields.width));
251
+ if (t === "image") fields.align = alignOf(fields.align);
252
+ if (t === "image" && typeof fields.caption === "string") fields.caption = normalizeText(fields.caption, null).c;
253
+ if (t === "chart" && typeof fields.title === "string") fields.title = normalizeText(fields.title, null).c;
254
+ if (t === "table") Object.assign(fields, projectTable(b));
255
+ else if (t === "board") Object.assign(fields, projectBoard(b));
256
+ const item = { id, t, ...omitDefaults(t, fields) };
257
+ if (t === "table") { item.cols = fields.cols; item.rows = fields.rows; }
258
+ if (t === "board") { item.columns = fields.columns; item.cards = fields.cards; }
259
+ if (TEXT.has(t)) {
260
+ const text = typeof c === "string" ? c : "";
261
+ const raw = unitsOf(m, text.length);
262
+ const units = raw && raw.map((k, i) => (!k ? "" : k[0] === "@" ? (text[i] === ATOM ? k : "") : keyOfMarks(safeMarks(marksOfKey(k)))));
263
+ if (t === "code_block") Object.assign(item, { c: text.replace(/\r\n?/g, "\n") });
264
+ else {
265
+ const n = normalizeText(text, units);
266
+ Object.assign(item, textFields(n.c, n.units));
267
+ }
268
+ }
269
+ return item;
270
+ }
271
+
272
+ const clampLevel = (l) => Math.min(6, Math.max(1, Number.isInteger(l) ? l : 2));
273
+ const widthOf = (s) => (/^\d+$/.test(s) ? Number(s) : s);
274
+ export const alignOf = (a) => (a === "left" || a === "right" ? a : null);
275
+
276
+ export function project(blocks) {
277
+ return layout(blocks).map(projectOne);
278
+ }
279
+
280
+ // ---- revision -----------------------------------------------------------------
281
+
282
+ export const canon = (v) => (Array.isArray(v) ? `[${v.map(canon).join(",")}]` : v && typeof v === "object" ? `{${Object.keys(v).sort().map((k) => JSON.stringify(k) + ":" + canon(v[k])).join(",")}}` : JSON.stringify(v ?? null));
283
+
284
+ // 64 bits from two FNV-1a lanes over UTF-16 units, as 16 hex digits.
285
+ export function hash64(text) {
286
+ let a = 0x811c9dc5, b = 0x01000193 ^ 0x9e3779b9;
287
+ for (let i = 0; i < text.length; i++) {
288
+ const u = text.charCodeAt(i);
289
+ a = Math.imul(a ^ u, 0x01000193) >>> 0;
290
+ b = Math.imul(b ^ u, 0x5bd1e995) >>> 0;
291
+ b = (b ^ (b >>> 15)) >>> 0;
292
+ }
293
+ return a.toString(16).padStart(8, "0") + b.toString(16).padStart(8, "0");
294
+ }
295
+
296
+ export const revOf = (model) => hash64(canon(model));
297
+
298
+ // ---- render ---------------------------------------------------------------------
299
+
300
+ const escText = (s) => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/\r/g, "&#13;");
301
+ const escAttr = (s) => escText(String(s)).replace(/"/g, "&quot;");
302
+ const attr = (name, value) => (value === null || value === undefined ? "" : ` ${name}="${escAttr(value)}"`);
303
+
304
+ export function renderInline(c, m) {
305
+ const units = unitsOf(m, c.length) || [];
306
+ let html = "";
307
+ const open = [];
308
+ const closeTo = (n) => { while (open.length > n) html += `</${open.pop().tag}>`; };
309
+ for (let i = 0; i < c.length;) {
310
+ const key = units[i] || "";
311
+ if (c[i] === ATOM && key[0] === "@") {
312
+ closeTo(0);
313
+ html += key === BREAK ? "<br>" : `<span data-atom="${escAttr(key)}"></span>`;
314
+ i++;
315
+ continue;
316
+ }
317
+ let j = i + 1;
318
+ while (j < c.length && (units[j] || "") === key && !(c[j] === ATOM && key[0] === "@")) j++;
319
+ const marks = marksOfKey(key);
320
+ const want = NEST.filter((code) => marks[code]).map((code) => ({ code, sig: code + JSON.stringify(marks[code]), tag: code === "a" ? "a" : TAGS[code], href: code === "a" ? marks.a[0] : null }));
321
+ let keep = 0;
322
+ while (keep < open.length && keep < want.length && open[keep].sig === want[keep].sig) keep++;
323
+ closeTo(keep);
324
+ for (const w of want.slice(keep)) { html += w.tag === "a" ? `<a${attr("href", w.href)}>` : `<${w.tag}>`; open.push(w); }
325
+ html += escText(c.slice(i, j));
326
+ i = j;
327
+ }
328
+ closeTo(0);
329
+ return html;
330
+ }
331
+
332
+ const inlineOf = (item) => renderInline(item.c ?? "", item.m);
333
+ const toolData = (item) => item.artifact || item.rev || (item.state && Object.keys(item.state).length)
334
+ ? `<script type="application/json" data-tool>${canon({ artifact: item.artifact ?? null, rev: item.rev ?? null, state: item.state ?? {} }).replace(/</g, "\\u003c")}</script>`
335
+ : "";
336
+ const cellHtml = (cell) => (typeof cell === "string" ? escText(cell) : cell && typeof cell === "object" ? renderInline(String(cell.c ?? ""), cell.m) : "");
337
+ const dataAttrs = (obj) => Object.entries(obj).map(([k, v]) => attr(`data-${k}`, v)).join("");
338
+
339
+ function renderLeaf(item, clean, tables) {
340
+ const id = attr("data-id", item.id);
341
+ switch (item.t) {
342
+ case "paragraph": return `<p${id}>${inlineOf(item)}</p>`;
343
+ case "heading": { const h = `h${item.level ?? 2}`; return `<${h}${id}>${inlineOf(item)}</${h}>`; }
344
+ case "callout": return `<aside${id} class="page-callout">${inlineOf(item)}</aside>`;
345
+ case "code_block": return `<pre${id}${attr("data-lang", item.lang || null)}><code>${escText(item.c ?? "")}</code></pre>`;
346
+ case "divider": return `<hr${id}>`;
347
+ case "image": return `<figure${id} data-type="image"${attr("data-align", item.align ?? null)}><img${attr("src", item.src ?? "")}${attr("alt", item.alt ?? "")}${attr("width", item.width ?? null)}>${item.caption ? `<figcaption>${escText(item.caption)}</figcaption>` : ""}</figure>`;
348
+ case "file": {
349
+ const href = /^(https?:\/\/|\/(?!\/))/i.test(item.src ?? "") ? item.src : null;
350
+ return `<div${id} data-type="file"${attr("data-src", item.src ?? "")}${attr("data-name", item.name ?? "")}${attr("data-size", item.size ?? null)}${attr("data-mime", item.mime ?? null)}><a${attr("href", href)} rel="noopener noreferrer">${escText(item.name || "file")}</a></div>`;
351
+ }
352
+ case "table": {
353
+ const head = item.cols.map((c) => `<th${attr("data-col", c.id)}${attr("data-kind", c.kind ?? null)}${attr("data-width", c.width ?? null)}${attr("style", c.width ? `width:${c.width}px` : null)}>${escText(c.name)}</th>`).join("");
354
+ const body = item.rows.map((r) => `<tr${attr("data-id", r.id)}>${item.cols.map((c) => `<td>${cellHtml(r.cells[c.id])}</td>`).join("")}</tr>`).join("\n");
355
+ return `<table${id}${attr("data-sort", item.sort != null ? JSON.stringify(item.sort) : null)}>\n<thead><tr>${head}</tr></thead>\n<tbody>\n${body}\n</tbody>\n</table>`;
356
+ }
357
+ case "chart": {
358
+ const data = dataAttrs({ source: item.source ?? null, kind: item.kind ?? null, x: item.x ?? null, series: item.series ? JSON.stringify(item.series) : null, highlight: item.highlight ?? null });
359
+ const chart = chartModel({ id: item.id, type: item.kind ?? "line", x: item.x, series: item.series ?? [], title: item.title ?? "", highlight: item.highlight }, tables.get(item.source));
360
+ return `<figure${id} data-type="chart"${data}>${renderChartSvg(chart, { width: 640, height: 320 })}<figcaption>${escText(item.title ?? "")}</figcaption></figure>`;
361
+ }
362
+ case "board": {
363
+ const cols = [...item.columns.map((c) => ({ ...c, known: true })), ...[...new Set(item.cards.map((k) => k.col))].filter((col) => !item.columns.some((c) => c.id === col)).map((col) => ({ id: col, known: false }))];
364
+ const parts = cols.map((col) => {
365
+ const cards = item.cards.filter((k) => k.col === col.id).map((k) => `<li${attr("data-id", k.id)}${attr("data-owner", k.owner ?? null)}${attr("data-due", k.due ?? null)}>${escText(k.title)}</li>`).join("");
366
+ return `<div${attr("data-col", col.id)}${col.known ? "" : " data-missing"}>${col.known ? `<h4>${escText(col.name)}</h4>` : ""}<ul>${cards}</ul></div>`;
367
+ });
368
+ return `<section${id} data-type="board">\n${parts.join("\n")}\n</section>`;
369
+ }
370
+ case "tool": return `<section${id} data-type="tool">${toolData(item)}</section>`;
371
+ case "preserved": return preservedHtml(item, clean);
372
+ default: return "";
373
+ }
374
+ }
375
+
376
+ // Markup the schema has no node for, kept as authored inside a marked wrapper.
377
+ const preservedHtml = (item, clean) => `<div${attr("data-id", item.id)} data-type="preserved">${clean.preserved(item.html || "")}</div>`;
378
+
379
+ const RAW = { preserved: (html) => html, fallback: (html) => html };
380
+
381
+ // Raw fragments as the parser of doc reads them back: unclosed or misnested tags
382
+ // balanced, so they cannot swallow the blocks after them. A tool's fallback is
383
+ // static markup (its code mounts from the store), so scripts, event handlers and
384
+ // script URLs are removed from it; preserved markup is kept as authored.
385
+ export function fragmentCleaner(doc) {
386
+ const balance = (html, strip) => {
387
+ if (!html) return "";
388
+ const t = doc.createElement("template");
389
+ t.innerHTML = html;
390
+ if (strip) {
391
+ for (const el of t.content.querySelectorAll("script")) el.remove();
392
+ for (const el of t.content.querySelectorAll("*")) {
393
+ for (const { name, value } of [...el.attributes]) {
394
+ if (/^on/i.test(name) || (/^(href|src|action|formaction|xlink:href)$/i.test(name) && SCRIPT_URL.test(value.replace(/[\u0000-\u0020]/g, "")))) el.removeAttribute(name);
395
+ }
396
+ }
397
+ }
398
+ return t.innerHTML;
399
+ };
400
+ return { preserved: (html) => balance(html, false), fallback: (html) => balance(html, true) };
401
+ }
402
+
403
+ // The article's inner HTML: one line per block, and one line per list run
404
+ // (whitespace inside an <li> would be text). Tool fallbacks come from fallbackOf(id).
405
+ export function renderModel(model, { fallbackOf = () => "", clean = RAW } = {}) {
406
+ const lines = [];
407
+ const tables = new Map(model.filter((item) => item.t === "table").map((item) => [item.id, item]));
408
+ const stack = [null];
409
+ const lists = [];
410
+ const add = (s) => { if (lists.length) lines[lines.length - 1] += s; else lines.push(s); };
411
+ const closeLists = () => { while (lists.length) { const tag = lists[lists.length - 1]; lines[lines.length - 1] += `</li></${tag}>`; lists.pop(); } };
412
+ for (const item of model) {
413
+ const parent = item.parent ?? null;
414
+ while (stack.length > 1 && stack[stack.length - 1] !== parent) { closeLists(); add("</details>"); stack.pop(); }
415
+ if (item.t !== "list_item") closeLists();
416
+ if (item.t === "list_item") {
417
+ const d = item.indent ?? 0;
418
+ const tag = item.kind === "ordered" ? "ol" : "ul";
419
+ while (lists.length > d + 1) { add(`</li></${lists[lists.length - 1]}>`); lists.pop(); }
420
+ if (lists.length === d + 1) {
421
+ add(lists[d] === tag ? "</li>" : `</li></${lists[d]}>`);
422
+ if (lists[d] !== tag) lists.pop();
423
+ }
424
+ if (lists.length < d + 1) { add(`<${tag}>`); lists.push(tag); }
425
+ const box = item.kind === "check" ? `<input type="checkbox" disabled${item.checked ? " checked" : ""}>` : "";
426
+ add(`<li${attr("data-id", item.id)}${attr("data-owner", item.owner ?? null)}${attr("data-due", item.due ?? null)}>${box}${inlineOf(item)}`);
427
+ continue;
428
+ }
429
+ if (item.t === "toggle") {
430
+ add(`<details${attr("data-id", item.id)}${item.open ? " open" : ""}><summary>${inlineOf(item)}</summary>`);
431
+ stack.push(item.id);
432
+ continue;
433
+ }
434
+ if (item.t === "tool") { add(`<section${attr("data-id", item.id)} data-type="tool">${toolData(item)}${clean.fallback(fallbackOf(item.id))}</section>`); continue; }
435
+ add(renderLeaf(item, clean, tables));
436
+ }
437
+ closeLists();
438
+ while (stack.length > 1) { add("</details>"); stack.pop(); }
439
+ return lines.join("\n");
440
+ }
441
+
442
+ export function renderBlocks(blocks, opts) {
443
+ const model = project(blocks);
444
+ return { html: renderModel(model, opts), rev: revOf(model), model };
445
+ }
446
+
447
+ // ---- parse ------------------------------------------------------------------------
448
+
449
+ const DROP_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "NOSCRIPT"]);
450
+ const INLINE_TAGS = new Set(["A", "ABBR", "B", "BDI", "BDO", "BR", "CITE", "CODE", "DATA", "DEL", "DFN", "EM", "FONT", "I", "INS", "KBD", "LABEL", "MARK", "Q", "S", "SAMP", "SMALL", "SPAN", "STRIKE", "STRONG", "SUB", "SUP", "TIME", "U", "VAR", "WBR"]);
451
+
452
+ // Inline content of el as { c, units }, minus the children skip(child) names.
453
+ function inlineOfEl(el, skip = () => false) {
454
+ let c = "";
455
+ const units = [];
456
+ const walk = (node, marks) => {
457
+ for (const child of node.childNodes) {
458
+ if (skip(child)) continue;
459
+ if (child.nodeType === 3) {
460
+ c += child.data;
461
+ const key = keyOfMarks(safeMarks({ ...marks }));
462
+ for (let i = 0; i < child.data.length; i++) units.push(key);
463
+ continue;
464
+ }
465
+ if (child.nodeType !== 1 || DROP_TAGS.has(child.tagName)) continue;
466
+ const tag = child.tagName.toLowerCase();
467
+ if (tag === "br") { c += ATOM; units.push(BREAK); continue; }
468
+ if (tag === "span" && child.hasAttribute("data-atom")) { c += ATOM; units.push(child.getAttribute("data-atom")); continue; }
469
+ const next = { ...marks };
470
+ if (tag === "strong" || tag === "b") next.b = true;
471
+ else if (tag === "em" || tag === "i") next.i = true;
472
+ else if (tag === "s" || tag === "del" || tag === "strike") next.s = true;
473
+ else if (tag === "code") next.c = true;
474
+ else if (tag === "a" && child.hasAttribute("href")) next.a = [child.getAttribute("href")];
475
+ walk(child, next);
476
+ }
477
+ };
478
+ walk(el, {});
479
+ return normalizeText(c, units);
480
+ }
481
+
482
+ const text = (el) => normalizeText(el ? el.textContent : "", null).c;
483
+
484
+ export function parseArticle(article) {
485
+ const items = [];
486
+ const walk = (container, parent) => {
487
+ let loose = null;
488
+ const flushLoose = () => {
489
+ if (!loose) return;
490
+ const n = inlineOfEl(loose);
491
+ if (n.c) items.push({ id: null, t: "paragraph", parent, ...textFields(n.c, n.units) });
492
+ loose = null;
493
+ };
494
+ for (const node of [...container.childNodes]) {
495
+ if (node.nodeType === 3 && !node.data.trim()) { if (loose) loose.appendChild(node.cloneNode()); continue; }
496
+ if (node.nodeType === 8 || (node.nodeType === 1 && DROP_TAGS.has(node.tagName))) continue;
497
+ if (node.nodeType === 3 || (node.nodeType === 1 && INLINE_TAGS.has(node.tagName))) {
498
+ loose ??= container.ownerDocument.createElement("p");
499
+ loose.appendChild(node.cloneNode(true));
500
+ continue;
501
+ }
502
+ flushLoose();
503
+ if (node.nodeType !== 1) continue;
504
+ blockOf(node, parent);
505
+ }
506
+ flushLoose();
507
+ };
508
+ const idOf = (el) => el.getAttribute("data-id") || null;
509
+ const push = (el, t, parent, fields) => items.push({ id: idOf(el), t, parent, ...fields });
510
+ const list = (el, parent, depth) => {
511
+ for (const li of el.children) {
512
+ if (li.tagName !== "LI") continue;
513
+ const box = [...li.childNodes].find((n) => n.nodeType === 1 && n.tagName === "INPUT" && (n.getAttribute("type") || "").toLowerCase() === "checkbox");
514
+ const nested = [...li.children].filter((n) => n.tagName === "UL" || n.tagName === "OL");
515
+ const n = inlineOfEl(li, (child) => child === box || nested.includes(child));
516
+ const kind = box ? "check" : el.tagName === "OL" ? "ordered" : "bullet";
517
+ const fields = { parent, kind, indent: depth, checked: !!(box && box.hasAttribute("checked")), owner: li.getAttribute("data-owner"), due: li.getAttribute("data-due") };
518
+ items.push({ id: idOf(li), t: "list_item", ...omitDefaults("list_item", fields), ...textFields(n.c, n.units) });
519
+ for (const sub of nested) list(sub, parent, depth + 1);
520
+ }
521
+ };
522
+ const blockOf = (el, parent) => {
523
+ const tag = el.tagName;
524
+ const P = { parent };
525
+ const onlyImage = tag === "P" && el.children.length === 1 && el.children[0].tagName === "IMG" && !el.textContent.trim();
526
+ if (tag === "P" && !onlyImage) return push(el, "paragraph", parent, { ...omitDefaults("paragraph", P), ...tf(el) });
527
+ if (/^H[1-6]$/.test(tag)) return push(el, "heading", parent, { ...omitDefaults("heading", { ...P, level: +tag[1] }), ...tf(el) });
528
+ if (tag === "ASIDE") return push(el, "callout", parent, { ...omitDefaults("callout", P), ...tf(el) });
529
+ if (tag === "PRE") {
530
+ const code = el.querySelector(":scope > code");
531
+ return push(el, "code_block", parent, { ...omitDefaults("code_block", { ...P, lang: el.getAttribute("data-lang") || "" }), c: (code || el).textContent });
532
+ }
533
+ if (tag === "HR") return push(el, "divider", parent, omitDefaults("divider", P));
534
+ if (tag === "UL" || tag === "OL") return list(el, parent, 0);
535
+ if (tag === "DETAILS") {
536
+ const summary = el.querySelector(":scope > summary");
537
+ const at = items.length;
538
+ push(el, "toggle", parent, { ...omitDefaults("toggle", { ...P, open: el.hasAttribute("open") }), ...tf(summary) });
539
+ const body = el.ownerDocument.createElement("div");
540
+ for (const n of [...el.childNodes]) if (n !== summary) body.appendChild(n.cloneNode(true));
541
+ walk(body, { ref: at });
542
+ return;
543
+ }
544
+ if (tag === "FIGURE" && el.getAttribute("data-type") === "chart") {
545
+ const d = (k) => el.getAttribute(`data-${k}`);
546
+ let series = [];
547
+ try { series = JSON.parse(d("series") || "[]"); } catch {}
548
+ return push(el, "chart", parent, omitDefaults("chart", { ...P, source: d("source"), kind: d("kind") || "line", x: d("x"), series, title: text(el.querySelector(":scope > figcaption")), highlight: d("highlight") }));
549
+ }
550
+ if (tag === "DIV" && el.getAttribute("data-type") === "file") {
551
+ const size = el.getAttribute("data-size");
552
+ return push(el, "file", parent, omitDefaults("file", { ...P, src: el.getAttribute("data-src") || "", name: el.getAttribute("data-name") || "", size: size === null || size === "" || !Number.isFinite(+size) ? null : +size, mime: el.getAttribute("data-mime") || null }));
553
+ }
554
+ const img = tag === "IMG" ? el : (tag === "FIGURE" || onlyImage) ? el.querySelector(":scope > img") : null;
555
+ if (img) {
556
+ const w = img.getAttribute("width");
557
+ return push(el, "image", parent, omitDefaults("image", { ...P, src: img.getAttribute("src") || "", alt: img.getAttribute("alt") || "", caption: tag === "FIGURE" ? text(el.querySelector(":scope > figcaption")) : "", width: w === null ? null : widthOf(w), align: tag === "FIGURE" ? alignOf(el.getAttribute("data-align")) : null }));
558
+ }
559
+ if (tag === "TABLE") return push(el, "table", parent, tableOf(el, P));
560
+ if (tag === "SECTION" && el.getAttribute("data-type") === "board") return push(el, "board", parent, boardOf(el, P));
561
+ if (tag === "SECTION" && el.getAttribute("data-type") === "tool") {
562
+ const data = el.querySelector(':scope > script[type="application/json"][data-tool]');
563
+ let tool = {};
564
+ try { tool = data ? JSON.parse(data.textContent) : {}; } catch {}
565
+ return push(el, "tool", parent, omitDefaults("tool", { ...P, artifact: tool.artifact ?? null, rev: tool.rev ?? null, state: tool.state ?? {} }));
566
+ }
567
+ if (tag === "DIV" && el.getAttribute("data-type") === "preserved") return push(el, "preserved", parent, omitDefaults("preserved", { ...P, html: el.innerHTML }));
568
+ if (tag === "DIV" && [...el.children].every((c) => INLINE_TAGS.has(c.tagName))) return push(el, "paragraph", parent, { ...omitDefaults("paragraph", P), ...tf(el) });
569
+ const copy = el.cloneNode(true);
570
+ copy.removeAttribute("data-id");
571
+ return push(el, "preserved", parent, omitDefaults("preserved", { ...P, html: copy.outerHTML }));
572
+ };
573
+ const tf = (el) => { const n = el ? inlineOfEl(el) : { c: "", units: [] }; return textFields(n.c, n.units); };
574
+ const tableOf = (el, P) => {
575
+ const headRow = el.querySelector(":scope > thead > tr") || null;
576
+ const ths = headRow ? [...headRow.children] : [];
577
+ const cols = ths.map((th) => ({ id: th.getAttribute("data-col"), name: text(th), ...(th.hasAttribute("data-kind") ? { kind: th.getAttribute("data-kind") } : {}), ...(columnWidth(Number(th.getAttribute("data-width"))) ? { width: columnWidth(Number(th.getAttribute("data-width"))) } : {}) }));
578
+ const trs = [...el.querySelectorAll(":scope > tbody > tr, :scope > tr")];
579
+ const width = Math.max(cols.length, ...trs.map((tr) => tr.children.length), 0);
580
+ while (cols.length < width) cols.push({ id: null, name: "" });
581
+ const rows = trs.map((tr) => ({ id: tr.getAttribute("data-id"), cellsAt: [...tr.children].map((td) => { const fields = tf(td); return fields.m ? fields : fields.c; }) }));
582
+ let sort = null;
583
+ try { sort = el.hasAttribute("data-sort") ? JSON.parse(el.getAttribute("data-sort")) : null; } catch {}
584
+ return { ...omitDefaults("table", { ...P, sort }), cols, rows };
585
+ };
586
+ const boardOf = (el, P) => {
587
+ const columns = [], cards = [];
588
+ for (const div of el.querySelectorAll(":scope > div[data-col], :scope > div")) {
589
+ const col = div.getAttribute("data-col");
590
+ const missing = div.hasAttribute("data-missing");
591
+ const ref = missing ? { id: col } : { id: col, name: text(div.querySelector(":scope > h4")) };
592
+ if (!missing) columns.push(ref);
593
+ for (const li of div.querySelectorAll(":scope > ul > li")) {
594
+ cards.push({ id: li.getAttribute("data-id"), colRef: ref, title: text(li), ...(li.hasAttribute("data-owner") ? { owner: li.getAttribute("data-owner") } : {}), ...(li.hasAttribute("data-due") ? { due: li.getAttribute("data-due") } : {}) });
595
+ }
596
+ }
597
+ return { ...omitDefaults("board", P), columns, cards };
598
+ };
599
+ walk(article, null);
600
+ return items;
601
+ }
602
+
603
+ // Ids for the parsed items. A data-id the page knows (known: id -> projected
604
+ // item) keeps its block; of two holders, the one whose content equals the
605
+ // known item keeps it, else the first. Everything else gets a fresh id that
606
+ // depends only on the seed and its place among the fresh ones, so the same
607
+ // file adopted twice gets the same ids.
608
+ export function resolveIds(items, { seed = "", known = new Map(), taken = new Set() } = {}) {
609
+ let fresh = 0;
610
+ const mint = (prefix) => `${prefix}-a${hash64(`${seed}|${prefix}|${fresh++}`).slice(0, 12)}`;
611
+ const holders = new Map();
612
+ items.forEach((item, i) => { if (item.id) (holders.get(item.id) ?? holders.set(item.id, []).get(item.id)).push(i); });
613
+ const keeper = new Map();
614
+ for (const [id, list] of holders) {
615
+ const k = known.get(id);
616
+ const eq = k ? list.find((i) => same(stripId(items[i]), stripId(k))) : undefined;
617
+ keeper.set(id, eq ?? list[0]);
618
+ }
619
+ const ids = items.map((item, i) => (item.id && !(/^~|~\d+$/).test(item.id) && keeper.get(item.id) === i ? item.id : mint("b")));
620
+ const out = items.map((item, i) => {
621
+ const { id, parent, ...rest } = item;
622
+ const p = parent && typeof parent === "object" ? ids[parent.ref] : parent;
623
+ const res = { id: ids[i], t: rest.t, ...(p ? { parent: p } : {}), ...without(rest, ["t"]) };
624
+ if (res.t === "table") {
625
+ const seenC = new Set(), seenR = new Set();
626
+ res.cols = res.cols.map((c) => ({ ...c, id: c.id && !seenC.has(c.id) ? (seenC.add(c.id), c.id) : mint("c") }));
627
+ res.rows = res.rows.map((r) => ({ id: r.id && !seenR.has(r.id) ? (seenR.add(r.id), r.id) : mint("r"), cells: Object.fromEntries(res.cols.map((c, j) => [c.id, r.cellsAt[j] ?? ""])) }));
628
+ }
629
+ if (res.t === "board") {
630
+ const seenK = new Set();
631
+ const seenCol = new Set();
632
+ for (const col of res.columns) { if (!col.id || seenCol.has(col.id)) col.id = mint("c"); seenCol.add(col.id); }
633
+ res.cards = res.cards.map(({ colRef, ...k }) => ({ id: k.id && !seenK.has(k.id) ? (seenK.add(k.id), k.id) : mint("k"), col: colRef.id ?? "", title: k.title, ...(k.owner != null ? { owner: k.owner } : {}), ...(k.due != null ? { due: k.due } : {}) }));
634
+ res.columns = res.columns.map(({ id, name }) => ({ id, name }));
635
+ }
636
+ return res;
637
+ });
638
+ void taken;
639
+ return out;
640
+ }
641
+
642
+ const without = (o, keys) => Object.fromEntries(Object.entries(o).filter(([k]) => !keys.includes(k)));
643
+ const stripId = (item) => without(item, ["id", "parent"]);
644
+
645
+ export function parseModel(article, opts = {}) {
646
+ return resolveIds(parseArticle(article), opts);
647
+ }