@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
package/src/live/store.js CHANGED
@@ -4,7 +4,7 @@
4
4
  // Local ops change state at once. When authoritative moves, state is rebuilt in
5
5
  // place, so an object or list page code holds a reference to stays live.
6
6
 
7
- import { validateOp, checkOp, applyOp, applyOps, cloneJson, assignDeep, coalesce, serializeLive } from "./ops.js";
7
+ import { validateOp, checkOp, applyOp, applyOps, applyTracked, undoTracked, cloneJson, assignDeep, coalesce, overlaps, serializeLive } from "./ops.js";
8
8
 
9
9
  const MUTATORS = new Set(["push", "pop", "shift", "unshift", "splice", "sort", "reverse", "fill", "copyWithin"]);
10
10
  const INDEX = /^(0|[1-9]\d*)$/;
@@ -21,6 +21,79 @@ function readPath(root, path) {
21
21
  return node;
22
22
  }
23
23
 
24
+ const has = (obj, key) => Object.prototype.hasOwnProperty.call(obj, key);
25
+ const sameOp = (a, b) => serializeLive(a) === serializeLive(b);
26
+
27
+ // Applies ops in place, all or none: the records of what changed, or null
28
+ // (and root as it was) when one does not apply.
29
+ function track(root, ops) {
30
+ const done = [];
31
+ try {
32
+ for (const op of ops) {
33
+ const rec = applyTracked(root, op);
34
+ if (rec) {
35
+ done.push(rec);
36
+ root = rec.root;
37
+ }
38
+ }
39
+ return done;
40
+ } catch {
41
+ untrack(done);
42
+ return null;
43
+ }
44
+ }
45
+
46
+ function untrack(done) {
47
+ let root;
48
+ for (let i = done.length - 1; i >= 0; i--) root = undoTracked(done[i]);
49
+ return root;
50
+ }
51
+
52
+ // What a frame's ops did, slot by slot, when page state can follow them one
53
+ // slot at a time instead of a rebuild: each object slot the frame changed once
54
+ // or more (path, whether it held a value before and its value then), none
55
+ // inside another, no list changed at its own level, no whole value replaced,
56
+ // and no key taken out and put back (that moves the key to the end). Pending
57
+ // ops this tab has not seen confirmed must not touch those slots, nor add or
58
+ // remove keys beside a key the frame adds. With each slot, the object in state
59
+ // that mirrors it (into). null: rebuild.
60
+ function slotsOf(done, pending, state) {
61
+ const slots = [];
62
+ for (const rec of done) {
63
+ if (rec.at === null || Array.isArray(rec.at)) return null;
64
+ const slot = slots.find((x) => x.path.length === rec.path.length && overlaps(x.path, rec.path));
65
+ if (slot) {
66
+ // Taken out and put back in one frame: the key moves to the end.
67
+ if (slot.removed && rec.kind !== "remove") return null;
68
+ if (rec.kind === "remove") slot.removed = true;
69
+ continue;
70
+ }
71
+ if (slots.some((x) => overlaps(x.path, rec.path))) return null;
72
+ slots.push({ path: rec.path, at: rec.at, key: rec.key, had: rec.kind !== "add", old: rec.old, removed: rec.kind === "remove" });
73
+ }
74
+ for (const slot of slots) {
75
+ slot.has = has(slot.at, slot.key);
76
+ if (slot.had && slot.has && slot.removed) return null;
77
+ slot.into = readPath(state, slot.path.slice(0, -1));
78
+ if (!isContainer(slot.into) || Array.isArray(slot.into) || has(slot.into, slot.key) !== slot.had) return null;
79
+ const parent = slot.path.slice(0, -1);
80
+ for (const entry of pending) {
81
+ const path = entry.op.path;
82
+ if (overlaps(slot.path, path)) return null;
83
+ // A pending delete in a list above the slot shifts the indices the
84
+ // frame's path counts in.
85
+ if (entry.op.op === "del" && path.length <= slot.path.length && overlaps(path.slice(0, -1), slot.path) && Array.isArray(readPath(state, path.slice(0, -1)))) return null;
86
+ // A key the frame adds goes last; a pending op that adds or takes out a
87
+ // key of the same object would order them differently in a rebuild.
88
+ if (!slot.had && slot.has && path.length > parent.length && overlaps(parent, path)) {
89
+ const child = path[parent.length];
90
+ if (path.length === parent.length + 1 ? entry.op.op === "del" || !has(slot.at, String(child)) : !has(slot.at, String(child))) return null;
91
+ }
92
+ }
93
+ }
94
+ return slots;
95
+ }
96
+
24
97
  // applyOp, except that a set replacing a container with one of the same kind
25
98
  // rewrites it in place, so a proxy over the old container still points at the
26
99
  // value page code sees.
@@ -40,6 +113,9 @@ export function createStore({ name, value, ephemeral = false }) {
40
113
 
41
114
  let authoritative = cloneJson(value);
42
115
  const state = cloneJson(value);
116
+ // state is authoritative with pending replayed over it. Only a confirm whose
117
+ // ops no longer apply breaks that, until the next rebuild.
118
+ let coherent = true;
43
119
  // This tab's unconfirmed ops, oldest first, each with a local id that only grows.
44
120
  let pending = [];
45
121
  let nextId = 1;
@@ -48,6 +124,7 @@ export function createStore({ name, value, ephemeral = false }) {
48
124
  const listeners = new Set();
49
125
  const sinks = new Set();
50
126
  const rebases = new Set();
127
+ const confirmations = new Set();
51
128
  const deliveries = new Set();
52
129
  const views = new WeakMap();
53
130
 
@@ -74,6 +151,47 @@ export function createStore({ name, value, ephemeral = false }) {
74
151
  }
75
152
  pending = kept;
76
153
  assignDeep(state, next);
154
+ coherent = true;
155
+ }
156
+
157
+ function tell(event) {
158
+ for (const fn of [...rebases]) {
159
+ try {
160
+ fn(event);
161
+ } catch (err) {
162
+ console.error(`clay.live("${name}"): a rebase listener threw`, err);
163
+ }
164
+ }
165
+ }
166
+
167
+ // drop: [first, last], the pending ops with ids in that range are taken out
168
+ // (an op the host refused); the others stay.
169
+ function rebase(next, { source = "remote", confirmed = 0, through, drop, dropIds, frame = null, room = null, cause = null } = {}) {
170
+ if (!isContainer(next) || kind(next) !== kind(state)) {
171
+ console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
172
+ return false;
173
+ }
174
+ const before = serializeLive(state);
175
+ const prev = authoritative;
176
+ authoritative = cloneJson(next);
177
+ if (drop) pending = pending.filter((entry) => entry.id < drop[0] || entry.id > drop[1]);
178
+ else if (dropIds) { const ids = new Set(dropIds); pending = pending.filter((entry) => !ids.has(entry.id)); }
179
+ else pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
180
+ rebuild();
181
+ tell({ source, frame, room, cause, prev, before: (path) => readPath(prev, path), next: authoritative });
182
+ if (serializeLive(state) !== before) emit(source, null);
183
+ return true;
184
+ }
185
+
186
+ // The value before the frame whose records are done: authoritative copied,
187
+ // with the records taken back in the copy, path by path.
188
+ function priorOf(done) {
189
+ const copy = cloneJson(authoritative);
190
+ for (let i = done.length - 1; i >= 0; i--) {
191
+ const rec = done[i];
192
+ undoTracked({ ...rec, at: readPath(copy, rec.path.slice(0, -1)) });
193
+ }
194
+ return copy;
77
195
  }
78
196
 
79
197
  function publish(ops) {
@@ -238,6 +356,8 @@ export function createStore({ name, value, ephemeral = false }) {
238
356
  get value() {
239
357
  return state;
240
358
  },
359
+ // The room's value. A frame changes it in place (rebaseOps), so a caller
360
+ // that keeps any part of it past the call copies it. Never mutate it.
241
361
  get authoritative() {
242
362
  return authoritative;
243
363
  },
@@ -270,31 +390,89 @@ export function createStore({ name, value, ephemeral = false }) {
270
390
  authoritative = applyOps(cloneJson(authoritative), taken.map((entry) => entry.op));
271
391
  } catch {
272
392
  authoritative = cloneJson(state);
393
+ coherent = false;
273
394
  }
274
395
  pending = pending.filter((entry) => entry.id > through);
396
+ for (const fn of [...confirmations]) {
397
+ try {
398
+ fn({ through });
399
+ } catch (err) {
400
+ console.error(`clay.live("${name}"): a confirmation listener threw`, err);
401
+ }
402
+ }
403
+ },
404
+ onConfirm(fn) {
405
+ confirmations.add(fn);
406
+ return () => confirmations.delete(fn);
275
407
  },
276
408
  // A new value from outside. Ops already in it are dropped: the first
277
409
  // `confirmed` pending ops, or every op up to the mark `through`. The rest
278
410
  // replay over it; a pending op that no longer applies is dropped too.
279
411
  // Listeners hear about it only when the value page code sees changed.
280
- rebase(next, { source = "remote", confirmed = 0, through, frame = null, room = null, cause = null } = {}) {
281
- if (!isContainer(next) || kind(next) !== kind(state)) {
282
- console.warn(`clay.live("${name}"): ignored an incoming value that is not ${kind(state)}`);
283
- return false;
412
+ rebase,
413
+ // The ops of a frame the room ordered, checked: the same ops validated, or
414
+ // null when they do not apply to authoritative as a whole.
415
+ checkOps(ops) {
416
+ let checked;
417
+ try {
418
+ checked = ops.map(validateOp);
419
+ } catch {
420
+ return null;
284
421
  }
285
- const before = serializeLive(state);
286
- const prev = authoritative;
287
- authoritative = cloneJson(next);
288
- pending = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
289
- rebuild();
290
- for (const fn of [...rebases]) {
291
- try {
292
- fn({ source, frame, room, cause, prev, next: authoritative });
293
- } catch (err) {
294
- console.error(`clay.live("${name}"): a rebase listener threw`, err);
422
+ const done = track(authoritative, checked);
423
+ if (!done) return null;
424
+ untrack(done);
425
+ return checked;
426
+ },
427
+ // rebase(authoritative with checked ops applied, options), without copying
428
+ // the whole value: the ops change authoritative in place, and page state
429
+ // follows slot by slot when slotsOf allows, or does not move when the ops
430
+ // are this tab's own pending ops coming back. Anything else rebuilds, as
431
+ // rebase does. Listeners get prev only when they read it, and before(path)
432
+ // for what one path held. next is authoritative itself, changed in place by
433
+ // later frames: a listener copies what it keeps; prev is its own copy.
434
+ rebaseOps(ops, { source = "remote", confirmed = 0, through, frame = null, room = null, cause = null } = {}) {
435
+ const done = track(authoritative, ops);
436
+ if (!done) return false;
437
+ const kept = through === undefined ? pending.slice(confirmed) : pending.filter((entry) => entry.id > through);
438
+ const dropped = pending.length - kept.length;
439
+ const whole = done.some((rec) => rec.at === null);
440
+ const own = coherent && !whole && dropped === ops.length && ops.every((op, i) => sameOp(op, pending[i].op));
441
+ const slots = coherent && !own && dropped === 0 ? slotsOf(done, kept, state) : null;
442
+ if (!own && !slots) {
443
+ untrack(done);
444
+ return rebase(applyOps(cloneJson(authoritative), ops), { source, confirmed, through, frame, room, cause });
445
+ }
446
+ pending = kept;
447
+ let changed = false;
448
+ for (const slot of slots ?? []) {
449
+ const at = slot.into;
450
+ if (slot.has) {
451
+ const value = slot.at[slot.key];
452
+ if (!slot.had || serializeLive(slot.old) !== serializeLive(value)) changed = true;
453
+ const cur = slot.had ? at[slot.key] : undefined;
454
+ const next = cloneJson(value);
455
+ const same = isContainer(cur) && isContainer(next) && Array.isArray(cur) === Array.isArray(next);
456
+ Object.defineProperty(at, slot.key, { value: same ? assignDeep(cur, next) : next, enumerable: true, writable: true, configurable: true });
457
+ } else if (slot.had) {
458
+ changed = true;
459
+ delete at[slot.key];
295
460
  }
296
461
  }
297
- if (serializeLive(state) !== before) emit(source, null);
462
+ let prior = null;
463
+ const prev = () => (prior ??= priorOf(done));
464
+ const shifted = done.some((rec) => Array.isArray(rec.at) && rec.kind !== "put");
465
+ const before = (path) => {
466
+ if (shifted) return readPath(prev(), path);
467
+ for (const rec of done) {
468
+ if (!overlaps(rec.path, path)) continue;
469
+ if (rec.path.length > path.length) return readPath(prev(), path);
470
+ return readPath(rec.old, path.slice(rec.path.length));
471
+ }
472
+ return readPath(authoritative, path);
473
+ };
474
+ tell({ source, frame, room, cause, get prev() { return prev(); }, before, next: authoritative });
475
+ if (changed) emit(source, null);
298
476
  return true;
299
477
  },
300
478
  // Every rebase, whether or not the value page code sees changed, with what
@@ -31,6 +31,7 @@ export const PLUGIN_PATHS = {
31
31
  // A live JSON tag as an in-memory object (clay.live). Not editOnly: a reader's
32
32
  // tab still shows the game, and its writes stay in that tab.
33
33
  live: { path: "plugins/live.js", editOnly: false, default: false },
34
+ page: { path: "plugins/page.js", editOnly: true, default: false },
34
35
  sortable: { path: "plugins/sortable.js", editOnly: true, default: false },
35
36
  undo: { path: "plugins/undo.js", editOnly: true, default: false },
36
37
  cms: { path: "plugins/cms.js", editOnly: false, default: false },
@@ -96,6 +97,7 @@ export const MODULES = {
96
97
  "plugins/indicator.js": () => import("./plugins/indicator.js"),
97
98
  "sync/live-sync.js": () => import("./sync/live-sync.js"),
98
99
  "plugins/live.js": () => import("./plugins/live.js"),
100
+ "plugins/page.js": () => import("./plugins/page.js"),
99
101
  "plugins/sortable.js": () => import("./plugins/sortable.js"),
100
102
  "plugins/undo.js": () => import("./plugins/undo.js"),
101
103
  "plugins/cms.js": () => import("./plugins/cms.js"),
@@ -112,7 +114,7 @@ export const MODULES = {
112
114
 
113
115
  // `source` is last on purpose: its install captures a save clone, so it wants every
114
116
  // plugin that registers a document transform to have registered it first.
115
- const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "message", "wire", "ai-edit", "demo", "source"];
117
+ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "people", "cms", "sync", "live", "page", "message", "wire", "ai-edit", "demo", "source"];
116
118
 
117
119
  // A plugin that cannot do its whole job alone. hypercms reads the cropper through
118
120
  // a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
@@ -134,7 +136,9 @@ const PLUGIN_ORDER = ["visitor-drafts", "richclay", "indicator", "sortable", "un
134
136
  // that asks for it has to have the wire. The wire loads first in the order above.
135
137
  // It does not bring undo: hyper-undo takes Cmd+Z for the whole window, text fields
136
138
  // included, which a page that never asked for it should not get by default.
137
- const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"] };
139
+ // A page's peers reach it through live sync's stream (live/stream-bridge.js), so
140
+ // a Document without sync saves whole files and never sees another tab type.
141
+ const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"], page: ["live", "sync"] };
138
142
 
139
143
  function parseCsv(params, key, enabled, apply) {
140
144
  const raw = params.get(key);
@@ -152,7 +156,7 @@ function parseCsv(params, key, enabled, apply) {
152
156
 
153
157
  // Each of these talks to the host or writes outside the visitor's own record, so a
154
158
  // page that keeps its edits in this browser cannot carry them.
155
- const DRAFT_DROPS = ["sync", "live", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
159
+ const DRAFT_DROPS = ["sync", "live", "page", "upload", "quickcrop", "cms", "people", "wire", "ai-edit", "demo", "source"];
156
160
 
157
161
  // [refetch-on-save] swaps its element for a fresh GET of the same URL after every
158
162
  // save, which on a visitor's own draft is a request to the host.
package/src/loader.js CHANGED
@@ -2,6 +2,8 @@ import { resolveModules, MODULES } from "./loader-logic.js";
2
2
  import onDomReady from "./lib/dom-ready.js";
3
3
  import { initTheme } from "./ui/theme.js";
4
4
  import { ephemeral } from "./lib/ephemeral.js";
5
+ import { effect } from "./lib/effect.js";
6
+ import { style } from "./lib/style.js";
5
7
 
6
8
  function domReady() {
7
9
  return new Promise((resolve) => onDomReady(resolve));
@@ -98,6 +100,8 @@ function assembleCore(loaded, { isDraftMode, isEditMode, isOwner, siteVisibility
98
100
  clay.readData = pageData.readData;
99
101
  clay.writeData = pageData.writeData;
100
102
  clay.ephemeral = ephemeral;
103
+ clay.effect = effect;
104
+ clay.style = style;
101
105
 
102
106
  if (save) {
103
107
  const saveFn = save.savePage || save.default;
@@ -133,6 +137,8 @@ function attachPluginMember(path, mod, loaded) {
133
137
  loaded["core/page-data.js"].setPageDataLiveSync(mod.liveSync);
134
138
  } else if (path === "plugins/live.js") {
135
139
  clay.live = mod.live;
140
+ } else if (path === "plugins/page.js") {
141
+ clay.page = mod.page;
136
142
  } else if (path === "plugins/cms.js") {
137
143
  clay.cms = mod.cms || mod.default;
138
144
  } else if (path === "plugins/upload.js") {
@@ -0,0 +1,20 @@
1
+ const SAFE_SCHEME = /^(https?:|mailto:|tel:)/i;
2
+
3
+ export function safeHref(raw) {
4
+ const s = String(raw ?? "").trim().replace(/[\u0000-\u001f\u007f]/g, "");
5
+ if (!s) return null;
6
+ if (s.startsWith("#") || (s.startsWith("/") && !s.startsWith("//"))) return s;
7
+ if (SAFE_SCHEME.test(s)) return s;
8
+ if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return null;
9
+ if (/^(\/\/)?[\w-]+(\.[\w-]+)+(:\d+)?([/?#]\S*)?$/.test(s)) return `https://${s.replace(/^\/\//, "")}`;
10
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s)) return `mailto:${s}`;
11
+ return null;
12
+ }
13
+
14
+ export const safeSrc = (s) => (s && /^data:image\/(png|jpeg|gif|webp);/i.test(s) && s.length <= 2_100_000 ? s : safeHref(s));
15
+
16
+ export function safeFileHref(raw) {
17
+ if (typeof raw !== "string") return null;
18
+ const href = safeHref(raw);
19
+ return href && /^(https?:|\/(?!\/))/i.test(href) ? href : null;
20
+ }
@@ -0,0 +1,189 @@
1
+ // When a hand-edited article is adopted: the file read at boot, before the
2
+ // editor mounts into the article, and every newer file that arrives. Only the
3
+ // checkpoint writer adopts (two adopters would both land the same splice). While
4
+ // an adoption waits for the binding or an IME composition, autosave is held, so
5
+ // a checkpoint cannot save the old article over the edit first.
6
+ import { suspendAutosave, resumeAutosave } from "../core/save.js";
7
+ import { history } from "../vendor/prosemirror.vendor.js";
8
+ import { ADOPT, classifyFile, planAdoption, adoptionTransaction, readBack } from "./adopt.js";
9
+ import { mergePageModels } from "./merge.js";
10
+ import { blockMap } from "./model.js";
11
+ import { parseModel } from "./projection.js";
12
+ import { ARTICLE, pageTags } from "./save-projection.js";
13
+ import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
14
+
15
+ const parseTag = (text) => { try { return HyperMorph.parseJsonRelaxed(text); } catch { return null; } };
16
+
17
+ export function readFile(root) {
18
+ const article = root.querySelector(ARTICLE);
19
+ return article ? { article: article.cloneNode(true), tags: pageTags(root).map((el) => parseTag(el.textContent)) } : null;
20
+ }
21
+
22
+ export function compositionIdle(view) {
23
+ return new Promise((resolve) => {
24
+ if (!view.composing) return resolve();
25
+ view.dom.addEventListener("compositionend", () => setTimeout(() => resolve(compositionIdle(view)), 0), { once: true });
26
+ });
27
+ }
28
+
29
+ export function installAdoption({
30
+ view,
31
+ source = null,
32
+ ready = Promise.resolve(),
33
+ isAdopter = () => true,
34
+ idle = () => compositionIdle(view),
35
+ requestCheckpoint = () => {},
36
+ notice = () => {},
37
+ snapshot = () => null,
38
+ mergeTagless = false,
39
+ writerReady = () => Promise.resolve(),
40
+ adopted = () => {},
41
+ hold = suspendAutosave,
42
+ release = resumeAutosave,
43
+ holdMs = 10_000,
44
+ doc = document,
45
+ }) {
46
+ let last = source ? source.article.innerHTML : null;
47
+ // A host that keeps no page data (HTML Clay) sends files with no page tag. What
48
+ // this tab saw on disk per data-rev, in the order it saw it: the boot article,
49
+ // every frame, and every article this tab's own save sent. The base for a frame
50
+ // is the last of those with its rev, else the last article on disk: an untouched
51
+ // older copy then merges to nothing, and a hand edit that kept the rev merges in.
52
+ let disk = source ? source.article : null;
53
+ const seen = new Map();
54
+ const record = (article) => {
55
+ const rev = article.getAttribute("data-rev");
56
+ if (!rev) return;
57
+ const list = seen.get(rev) ?? [];
58
+ seen.delete(rev);
59
+ seen.set(rev, list);
60
+ list.push(article);
61
+ if (list.length > 10) list.shift();
62
+ if (seen.size > 20) seen.delete(seen.keys().next().value);
63
+ };
64
+ if (disk) record(disk);
65
+ let queue = Promise.resolve();
66
+ let disposed = false;
67
+ let cancel;
68
+ const cancelled = new Promise((resolve) => (cancel = resolve));
69
+
70
+ // The hold never outlives the controller, and never lasts past holdMs: an
71
+ // editor that never settles must not stop every later save.
72
+ async function waitForEditor() {
73
+ let timer;
74
+ const timeout = new Promise((resolve) => { timer = setTimeout(() => resolve("timeout"), holdMs); });
75
+ try {
76
+ return await Promise.race([Promise.resolve(ready).then(() => idle()).then(() => "ready"), cancelled.then(() => "disposed"), timeout]);
77
+ } finally {
78
+ clearTimeout(timer);
79
+ }
80
+ }
81
+
82
+ // The editor merged three-way with what the file changed since base.
83
+ function land(blocks, current, baseItems, incoming) {
84
+ const merged = mergePageModels(baseItems, current, incoming, { doc, merge3: HyperMorph.merge3, mergeJson: HyperMorph.mergeJson });
85
+ const lost = merged.conflicts.filter((k) => k.kind === "text" || k.kind === "field");
86
+ const copy = lost.length ? snapshot() : null;
87
+ const plan = planAdoption(blocks, merged.items, current);
88
+ const tr = plan && adoptionTransaction(view.state, plan);
89
+ if (!tr) return null;
90
+ view.dispatch(tr);
91
+ view.dispatch(history.closeHistory(view.state.tr));
92
+ const counts = { edited: plan.edited.size, created: plan.created.size, deleted: plan.deleted.length, moved: plan.reordered };
93
+ const { displaced } = tr.getMeta(ADOPT);
94
+ if (Object.keys(displaced).length) counts.displaced = displaced;
95
+ if (lost.length) counts.conflicts = lost;
96
+ if (copy) counts.copy = copy;
97
+ return counts;
98
+ }
99
+
100
+ async function adopt(file) {
101
+ if (mergeTagless && !file.tags.length) return adoptTagless(file);
102
+ const found = classifyFile(file.article, file.tags);
103
+ if (found.kind === "unbased") console.warn("page: the article and its data changed together; kept the data");
104
+ if (found.kind !== "adopt" || !isAdopter()) return null;
105
+ hold();
106
+ try {
107
+ const waited = await waitForEditor();
108
+ if (waited === "timeout") console.warn("page: gave up waiting for the editor; the hand edit was not adopted");
109
+ if (waited !== "ready" || disposed || !isAdopter()) return null;
110
+ const blocks = blockMap(view.state.doc);
111
+ const counts = land(blocks, readBack(blocks, doc), found.baseItems, found.parsed);
112
+ if (counts) adopted(found.A);
113
+ return counts;
114
+ } finally {
115
+ release();
116
+ }
117
+ }
118
+
119
+ async function adoptTagless(file) {
120
+ const rev = file.article.getAttribute("data-rev");
121
+ const list = (rev && seen.get(rev)) || [];
122
+ const base = list.at(-1) ?? disk;
123
+ // An untouched older copy leaves the page as it is.
124
+ if (list.slice(0, -1).some((article) => article.innerHTML === file.article.innerHTML)) return null;
125
+ record(file.article);
126
+ disk = file.article;
127
+ if (!base) return null;
128
+ // Only the room's elected writer merges a frame, so it lands once however
129
+ // many tabs see it; and it lands even when the editor never settles, since
130
+ // the next save would otherwise write over the edit.
131
+ hold();
132
+ try {
133
+ await writerReady();
134
+ if (disposed || !isAdopter()) return null;
135
+ const waited = await waitForEditor();
136
+ if (waited === "disposed" || disposed || !isAdopter()) return null;
137
+ const blocks = blockMap(view.state.doc);
138
+ const current = readBack(blocks, doc);
139
+ const known = new Map(current.map((item) => [item.id, item]));
140
+ const parse = (article) => parseModel(article, { seed: article.getAttribute("data-rev") ?? "", known });
141
+ return land(blocks, current, parse(base), parse(file.article));
142
+ } finally {
143
+ release();
144
+ }
145
+ }
146
+
147
+ function run(file) {
148
+ queue = queue
149
+ .then(() => adopt(file))
150
+ .then((counts) => {
151
+ if (!counts) return;
152
+ requestCheckpoint();
153
+ notice(counts);
154
+ doc.dispatchEvent(new CustomEvent("clay:page-adopted", { detail: counts }));
155
+ })
156
+ .catch((error) => console.error("page: adoption failed", error));
157
+ return queue;
158
+ }
159
+
160
+ function onFrame(event) {
161
+ const html = event.detail?.html;
162
+ if (disposed || typeof html !== "string") return;
163
+ const file = readFile(new DOMParser().parseFromString(html, "text/html"));
164
+ if (!file || file.article.innerHTML === last) return;
165
+ last = file.article.innerHTML;
166
+ run(file);
167
+ }
168
+
169
+ doc.addEventListener("clay:sync-applied", onFrame);
170
+ if (source) run(source);
171
+
172
+ return {
173
+ idle: () => queue,
174
+ // A save of this tab landed: the file holds what it sent, so that article is
175
+ // the one an outside edit made on the saved file merges against.
176
+ saved(html) {
177
+ if (!mergeTagless || typeof html !== "string") return;
178
+ const article = new DOMParser().parseFromString(html, "text/html").querySelector(ARTICLE);
179
+ if (!article) return;
180
+ record(article);
181
+ disk = article;
182
+ },
183
+ dispose() {
184
+ disposed = true;
185
+ cancel();
186
+ doc.removeEventListener("clay:sync-applied", onFrame);
187
+ },
188
+ };
189
+ }