@panphora/clayjs 1.13.1 → 1.15.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 (146) hide show
  1. package/THIRD-PARTY-NOTICES.md +19 -0
  2. package/dist/clay.standalone.js +69385 -40992
  3. package/dist/server/checkpoint.cjs +1 -0
  4. package/dist/server/checkpoint.mjs +8 -0
  5. package/dist/server/core.cjs +72210 -0
  6. package/dist/server/rooms.cjs +1 -0
  7. package/dist/server/rooms.mjs +66 -0
  8. package/docs/guide.md +127 -99
  9. package/entries/clay.js +22 -0
  10. package/entries/sap.js +5 -5
  11. package/package.json +16 -2
  12. package/packed-contract.json +18 -3
  13. package/src/core/boot-phase.js +6 -0
  14. package/src/core/conflict-download.js +12 -3
  15. package/src/core/host-meta.js +75 -0
  16. package/src/core/persist.js +11 -0
  17. package/src/core/save-core.js +266 -31
  18. package/src/core/save.js +154 -36
  19. package/src/core/snapshot.js +51 -13
  20. package/src/core/style-comparisons.js +37 -0
  21. package/src/dom/insert-style-tag.js +11 -12
  22. package/src/lib/derived.js +116 -0
  23. package/src/lib/effect.js +130 -0
  24. package/src/lib/mutation.js +3 -1
  25. package/src/lib/style-adoption.js +9 -0
  26. package/src/lib/style.js +47 -0
  27. package/src/live/fast-lane.js +347 -60
  28. package/src/live/local-lane.js +32 -6
  29. package/src/live/ops.js +77 -0
  30. package/src/live/provenance.js +129 -0
  31. package/src/live/save-history.js +188 -0
  32. package/src/live/server-lane.js +9 -2
  33. package/src/live/store.js +194 -16
  34. package/src/loader-logic.js +16 -4
  35. package/src/loader.js +37 -1
  36. package/src/page/address.js +20 -0
  37. package/src/page/adopt-boot.js +189 -0
  38. package/src/page/adopt.js +240 -0
  39. package/src/page/ai-tool.js +188 -0
  40. package/src/page/announce.js +21 -0
  41. package/src/page/blocks.js +1170 -0
  42. package/src/page/blocksel.js +269 -0
  43. package/src/page/board-drag.js +226 -0
  44. package/src/page/board-panel.js +353 -0
  45. package/src/page/board-style.js +146 -0
  46. package/src/page/board-view.js +447 -0
  47. package/src/page/board.js +41 -0
  48. package/src/page/chart.js +220 -0
  49. package/src/page/checklist.js +143 -0
  50. package/src/page/chrome.js +587 -0
  51. package/src/page/csv-import.js +169 -0
  52. package/src/page/drag.js +202 -0
  53. package/src/page/drop.js +112 -0
  54. package/src/page/editor.js +386 -0
  55. package/src/page/highlight.js +268 -0
  56. package/src/page/inline.js +60 -1
  57. package/src/page/items.js +159 -0
  58. package/src/page/link.js +247 -0
  59. package/src/page/markdown.js +71 -0
  60. package/src/page/media.js +570 -0
  61. package/src/page/merge.js +130 -0
  62. package/src/page/model.js +71 -9
  63. package/src/page/placeholder.js +23 -0
  64. package/src/page/projection.js +647 -0
  65. package/src/page/recover.js +193 -0
  66. package/src/page/rich/cell.js +16 -0
  67. package/src/page/rich/chart.js +104 -0
  68. package/src/page/rich/csv.js +118 -0
  69. package/src/page/rich/page-tool.js +86 -0
  70. package/src/page/rich/tool-runtime.js +195 -0
  71. package/src/page/save-projection.js +53 -0
  72. package/src/page/save-states.js +85 -0
  73. package/src/page/schema.js +12 -5
  74. package/src/page/table-kinds.js +20 -0
  75. package/src/page/table-paste.js +57 -0
  76. package/src/page/table-select.js +244 -0
  77. package/src/page/table-style.js +25 -0
  78. package/src/page/table.js +650 -0
  79. package/src/page/themes/content.css +46 -0
  80. package/src/page/themes/light-1.css +269 -0
  81. package/src/page/themes/light-2.css +211 -0
  82. package/src/page/themes/light-3.css +130 -0
  83. package/src/page/themes/light-4.css +299 -0
  84. package/src/page/tool-data.js +25 -0
  85. package/src/page/tool-view.js +68 -0
  86. package/src/page/tools.js +133 -0
  87. package/src/page/view.js +136 -0
  88. package/src/page/ybind/author.js +25 -0
  89. package/src/page/ybind/binding.js +92 -0
  90. package/src/page/ybind/board-commands.js +170 -0
  91. package/src/page/ybind/board.js +89 -0
  92. package/src/page/ybind/bridge.js +382 -0
  93. package/src/page/ybind/conversion.js +26 -0
  94. package/src/page/ybind/core.js +7 -0
  95. package/src/page/ybind/decode.js +208 -0
  96. package/src/page/ybind/doc.js +58 -0
  97. package/src/page/ybind/external.js +190 -0
  98. package/src/page/ybind/group.js +124 -0
  99. package/src/page/ybind/index.js +12 -0
  100. package/src/page/ybind/layout.js +27 -0
  101. package/src/page/ybind/marks.js +18 -0
  102. package/src/page/ybind/migrate.js +152 -0
  103. package/src/page/ybind/moves.js +288 -0
  104. package/src/page/ybind/origins.js +257 -0
  105. package/src/page/ybind/parts.js +44 -0
  106. package/src/page/ybind/projection-map.js +92 -0
  107. package/src/page/ybind/provider.js +285 -0
  108. package/src/page/ybind/recovery.js +411 -0
  109. package/src/page/ybind/seed.js +27 -0
  110. package/src/page/ybind/table.js +166 -0
  111. package/src/page/ybind/tokens.js +93 -0
  112. package/src/page/ybind/walk.js +333 -0
  113. package/src/page/ybind/writer.js +307 -0
  114. package/src/plugins/derive.js +12 -0
  115. package/src/plugins/indicator.js +16 -6
  116. package/src/plugins/live.js +742 -65
  117. package/src/plugins/page.js +547 -0
  118. package/src/plugins/undo.js +62 -4
  119. package/src/server/checkpoint/index.js +218 -0
  120. package/src/server/rooms/index.js +59 -0
  121. package/src/server/rooms/live-data-ops.js +369 -0
  122. package/src/server/rooms/live-data-rooms.js +965 -0
  123. package/src/server/rooms/live-data-tags.js +49 -0
  124. package/src/server/rooms/live-page-history.js +41 -0
  125. package/src/server/rooms/live-provenance.js +142 -0
  126. package/src/server/rooms/page-admission.js +79 -0
  127. package/src/server/rooms/page-conversion.js +62 -0
  128. package/src/server/rooms/page-rebase.js +108 -0
  129. package/src/server/rooms/page-state.js +91 -0
  130. package/src/server/rooms/page-write-admission.js +63 -0
  131. package/src/server/rooms/page-write-guard.js +23 -0
  132. package/src/server/rooms/recent.js +18 -0
  133. package/src/server/rooms/spec-wire.js +17 -0
  134. package/src/standalone.js +22 -0
  135. package/src/sync/conflict-revert.js +2 -0
  136. package/src/sync/conflicts.js +2 -2
  137. package/src/sync/live-sync.js +36 -8
  138. package/src/sync/stream.js +62 -16
  139. package/src/ui/index.js +4 -0
  140. package/src/ui/toast.js +21 -5
  141. package/src/vendor/pierre/pierre.vendor.js +3 -0
  142. package/src/vendor/sap-core.vendor.js +9 -0
  143. package/src/vendor/sap-ownership.vendor.js +111 -0
  144. package/src/vendor/yjs.vendor.js +7 -0
  145. package/src/page/binding.js +0 -647
  146. package/src/page/replay.js +0 -578
@@ -0,0 +1,49 @@
1
+ import { load } from 'cheerio';
2
+ import { parseStamp } from './live-data-ops.js';
3
+ import { parseBy } from './live-provenance.js';
4
+
5
+ const NAME = /^[A-Za-z0-9_.:-]{1,64}$/;
6
+
7
+ // A browser with scripting on reads <noscript> as text, so a tag there is no live tag.
8
+ const inert = (node) => {
9
+ for (let parent = node.parent; parent; parent = parent.parent) if (parent.name === 'template' || parent.name === 'noscript') return true;
10
+ return false;
11
+ };
12
+
13
+ /**
14
+ * The live tags in a document: inline JSON scripts with a valid merge name and
15
+ * the live token, outside <template> and <noscript>. A name used twice is skipped
16
+ * entirely unless includeDuplicates is requested for admission checks.
17
+ * Parsed with parse5, as a browser parses it: htmlparser2 is ten times faster
18
+ * but splits a tag differently on input outside writers can produce (a JSON
19
+ * string holding `<!--<script>`, scripts inside <svg> or <iframe>).
20
+ * @param {string} html
21
+ * @param {{ includeDuplicates?: boolean }} options
22
+ * @returns {{ name: string, ephemeral: boolean, text: string, at: { epoch: string, seq: number, hash: string } | null, by: { client: string, n: number } | null }[]}
23
+ */
24
+ export function liveTags(html, { includeDuplicates = false } = {}) {
25
+ const $ = load(html);
26
+ const found = new Map();
27
+ const duplicated = new Set();
28
+ const all = [];
29
+ $('script[merge]').each((_, node) => {
30
+ const el = $(node);
31
+ if (inert(node) || el.attr('src') !== undefined) return;
32
+ const tokens = (el.attr('clay') || '').split(/\s+/);
33
+ if (!tokens.includes('live')) return;
34
+ const type = (el.attr('type') || '').split(';')[0].trim().toLowerCase();
35
+ if (type !== 'application/json' && !type.endsWith('+json')) return;
36
+ const name = el.attr('merge');
37
+ if (!NAME.test(name)) return;
38
+ const tag = { name, ephemeral: tokens.includes('ephemeral'), text: el.html() ?? '', at: parseStamp(el.attr('live-at')), by: parseBy(el.attr('live-by')) };
39
+ if (includeDuplicates) all.push(tag);
40
+ if (found.has(name)) {
41
+ duplicated.add(name);
42
+ return;
43
+ }
44
+ found.set(name, tag);
45
+ });
46
+ if (includeDuplicates) return all;
47
+ for (const name of duplicated) found.delete(name);
48
+ return [...found.values()];
49
+ }
@@ -0,0 +1,41 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { cloneJson, serializeLive } from './live-data-ops.js';
3
+
4
+ export function makePageTransition({ room = null, format = 'yjs', sources = [], receipts = new Map() } = {}) {
5
+ const archived = new Map([...receipts].map(([key, receipt]) => [key, cloneJson(receipt)]));
6
+ const history = [...(room?.pageTransition?.history ?? [])];
7
+ if (archived.size) history.push({
8
+ epoch: room.epoch,
9
+ generation: room.pageTransition?.id ?? null,
10
+ receiptGeneration: room.receiptGeneration ?? 0,
11
+ sources: [...sources],
12
+ receipts: archived,
13
+ });
14
+ const transition = { v: 2, id: randomUUID(), format, sources: [...sources], receipts: archived, history };
15
+ transition.receiptCount = history.reduce((count, entry) => count + entry.receipts.size, 0);
16
+ const entries = history.map(({ epoch, generation, receiptGeneration, sources, receipts }) =>
17
+ [epoch, generation, receiptGeneration, sources, [...receipts]]);
18
+ transition.historyBytes = entries.reduce((bytes, entry) => bytes + Buffer.byteLength(serializeLive(entry), 'utf8'), 0);
19
+ transition.bytes = Buffer.byteLength(serializeLive([transition.v, transition.id, format, transition.sources, entries]), 'utf8');
20
+ return transition;
21
+ }
22
+
23
+ function ownReceipts(receipts, clientId) {
24
+ const mine = {};
25
+ if (clientId) for (const [key, receipt] of receipts) {
26
+ const split = key.indexOf('\n');
27
+ if (key.slice(split + 1) !== clientId) continue;
28
+ mine[key.slice(0, split)] = { seen: { [clientId]: receipt.n }, ...(receipt.saved?.length && { saved: receipt.saved }) };
29
+ }
30
+ return mine;
31
+ }
32
+
33
+ export function pageTransitionState(transition, clientId) {
34
+ const { v, id, format, sources, receipts, history } = transition;
35
+ return {
36
+ v, id, format, sources, receipts: ownReceipts(receipts, clientId),
37
+ history: history.map(({ epoch, generation, receiptGeneration, sources, receipts }) => ({
38
+ epoch, generation, receiptGeneration, sources, receipts: ownReceipts(receipts, clientId),
39
+ })),
40
+ };
41
+ }
@@ -0,0 +1,142 @@
1
+ const CLIENT = /^[A-Za-z0-9_-]{1,64}$/;
2
+ const NUMBER = /^[1-9]\d{0,15}$/;
3
+ export const RANGE_LIMIT = 256;
4
+ export const RECEIPT_LIMIT = 4096;
5
+
6
+ const integer = (text) => NUMBER.test(text) && Number.isSafeInteger(Number(text));
7
+
8
+ export function unionRanges(...sets) {
9
+ const clients = new Map();
10
+ for (const set of sets) for (const [client, ranges] of set ?? []) {
11
+ if (!clients.has(client)) clients.set(client, []);
12
+ clients.get(client).push(...ranges.map(([lo, hi]) => [lo, hi]));
13
+ }
14
+ return [...clients].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0).map(([client, ranges]) => {
15
+ const out = [];
16
+ for (const [lo, hi] of ranges.sort((a, b) => a[0] - b[0])) {
17
+ const last = out[out.length - 1];
18
+ if (last && lo <= last[1] + 1) last[1] = Math.max(last[1], hi);
19
+ else out.push([lo, hi]);
20
+ }
21
+ return [client, out];
22
+ }).filter(([, ranges]) => ranges.length);
23
+ }
24
+
25
+ export const rangeCount = (set) => set.reduce((n, [, ranges]) => n + ranges.length, 0);
26
+ export const hasNumber = (ranges, n) => !!ranges?.some(([lo, hi]) => lo <= n && n <= hi);
27
+ export const contains = (set, client, n) => hasNumber(set?.find(([id]) => id === client)?.[1], n);
28
+ export const identitySet = ([client, n]) => [[client, [[n, n]]]];
29
+ export const frameSet = (by) => by?.v === 2 ? by.frames : by ? [[by.client, [[1, by.n]]]] : [];
30
+ export const descendants = (by) => by?.v === 2 ? unionRanges(by.ancestors, identitySet(by.capture)) : [];
31
+
32
+ function parseRanges(text) {
33
+ if (!text) return [];
34
+ const out = [];
35
+ let count = 0;
36
+ for (const part of text.split(';')) {
37
+ const fields = part.split(':');
38
+ if (fields.length !== 2 || !CLIENT.test(fields[0]) || !fields[1]) return null;
39
+ const ranges = [];
40
+ for (const raw of fields[1].split(',')) {
41
+ if (++count > RANGE_LIMIT) return null;
42
+ const ends = raw.split('-');
43
+ if (ends.length > 2 || !ends.every(integer)) return null;
44
+ const lo = Number(ends[0]), hi = Number(ends[1] ?? ends[0]);
45
+ if (hi < lo) return null;
46
+ ranges.push([lo, hi]);
47
+ }
48
+ out.push([fields[0], ranges]);
49
+ }
50
+ return unionRanges(out);
51
+ }
52
+
53
+ function printRanges(set) {
54
+ return unionRanges(set).map(([client, ranges]) => `${client}:${ranges.map(([lo, hi]) => lo === hi ? String(lo) : `${lo}-${hi}`).join(',')}`).join(';');
55
+ }
56
+
57
+ export function parseBy(text) {
58
+ if (typeof text !== 'string' || text.length > 32768) return null;
59
+ if (!text.startsWith('v2|')) {
60
+ const m = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(text);
61
+ return m && integer(m[2]) ? { client: m[1], n: Number(m[2]) } : null;
62
+ }
63
+ const parts = text.split('|');
64
+ if (parts.length !== 4) return null;
65
+ const frames = parseRanges(parts[1]), ancestors = parseRanges(parts[2]);
66
+ const capture = /^([A-Za-z0-9_-]{1,64}):([1-9]\d{0,15})$/.exec(parts[3]);
67
+ if (!frames || !ancestors || !capture || !integer(capture[2])) return null;
68
+ if (rangeCount(frames) + rangeCount(ancestors) + 1 > RANGE_LIMIT) return null;
69
+ const id = [capture[1], Number(capture[2])];
70
+ if (contains(ancestors, ...id)) return null;
71
+ return { v: 2, frames, ancestors, capture: id };
72
+ }
73
+
74
+ export function formatBy(by) {
75
+ const text = `v2|${printRanges(by.frames)}|${printRanges(by.ancestors)}|${by.capture[0]}:${by.capture[1]}`;
76
+ return parseBy(text) ? text : null;
77
+ }
78
+
79
+ export function receiptHas(receipt, n) {
80
+ return !!receipt && (n <= receipt.n || hasNumber(receipt.saved, n));
81
+ }
82
+
83
+ export function receiptBytes(receipt) {
84
+ return receipt?.saved ? JSON.stringify({ saved: receipt.saved, savedSeq: receipt.savedSeq }).length : 0;
85
+ }
86
+
87
+ export function replaceReceipt(room, key, next) {
88
+ const delta = receiptBytes(next) - receiptBytes(room.lastN.get(key));
89
+ room.receiptBytes = (room.receiptBytes ?? 0) + delta;
90
+ room.lastN.delete(key);
91
+ room.lastN.set(key, next);
92
+ return delta;
93
+ }
94
+
95
+ export function retireReceipt(room, key) {
96
+ const bytes = receiptBytes(room.lastN.get(key));
97
+ room.lastN.delete(key);
98
+ room.receiptBytes = (room.receiptBytes ?? 0) - bytes;
99
+ room.receiptGeneration = (room.receiptGeneration ?? 0) + 1;
100
+ room.retiredReceipts ??= new Map();
101
+ room.retiredReceipts.delete(key);
102
+ room.retiredReceipts.set(key, room.receiptGeneration);
103
+ if (room.retiredReceipts.size > RECEIPT_LIMIT) room.retiredReceipts.delete(room.retiredReceipts.keys().next().value);
104
+ return -bytes;
105
+ }
106
+
107
+ export function saveReceipt(room, key, ranges, seq, limit, retained = null) {
108
+ const last = room.lastN.get(key);
109
+ if (!last && room.lastN.size >= limit) {
110
+ let victim;
111
+ for (const candidate of room.lastN.keys()) {
112
+ if (retained?.has(candidate)) continue;
113
+ victim = candidate;
114
+ break;
115
+ }
116
+ if (victim === undefined) return false;
117
+ retireReceipt(room, victim);
118
+ }
119
+ const next = last ? { ...last } : { n: 0, seq: 0 };
120
+ const kept = unionRanges([['r', next.saved ?? []]], [['r', ranges]])[0]?.[1]
121
+ .filter(([, hi]) => hi > next.n).map(([lo, hi]) => [Math.max(lo, next.n + 1), hi]) ?? [];
122
+ if (kept.length > RANGE_LIMIT) {
123
+ retireReceipt(room, key);
124
+ next.saved = kept.slice(-RANGE_LIMIT);
125
+ next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
126
+ } else {
127
+ if (kept.length) {
128
+ next.saved = kept;
129
+ next.savedSeq = Math.max(next.savedSeq ?? 0, seq);
130
+ } else {
131
+ delete next.saved;
132
+ delete next.savedSeq;
133
+ }
134
+ }
135
+ replaceReceipt(room, key, next);
136
+ return true;
137
+ }
138
+
139
+ export function postReceipt(last, n, seq) {
140
+ const saved = (last?.saved ?? []).filter(([, hi]) => hi > n).map(([lo, hi]) => [Math.max(lo, n + 1), hi]);
141
+ return { n, seq, ...(saved.length && { saved, savedSeq: last.savedSeq }) };
142
+ }
@@ -0,0 +1,79 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { parseJsonRelaxed } from 'hyper-morph/json-parse';
3
+ import { cloneJson } from './live-data-ops.js';
4
+ import { liveTags } from './live-data-tags.js';
5
+ import { recent } from './recent.js';
6
+
7
+ const PAGE = /^page(\.[0-9]+)?$/;
8
+ const PART = /^(.+)\.(\d+)\.(\d+)$/;
9
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ export const isHybridPageValue = (value) => record(value) && record(value.blocks) && (value.deleted == null || record(value.deleted)) && Object.keys(value).every((key) => key === 'blocks' || key === 'deleted');
11
+
12
+ export function isYPageValue(value, { complete = true } = {}) {
13
+ if (!record(value) || value.v !== 1 || !record(value.y) || Object.keys(value).some((key) => !['v', 'g', 'y'].includes(key))) return false;
14
+ const whole = new Set();
15
+ const groups = new Map();
16
+ for (const [key, text] of Object.entries(value.y)) {
17
+ if (typeof text !== 'string') return false;
18
+ const part = PART.exec(key);
19
+ if (!part) {
20
+ whole.add(key);
21
+ continue;
22
+ }
23
+ const index = Number(part[2]);
24
+ const count = Number(part[3]);
25
+ if (!Number.isSafeInteger(index) || !Number.isSafeInteger(count) || count < 1 || index >= count) return false;
26
+ const group = groups.get(part[1]) ?? { count, indices: new Set() };
27
+ if (group.count !== count || group.indices.has(index)) return false;
28
+ group.indices.add(index);
29
+ groups.set(part[1], group);
30
+ }
31
+ for (const [key, group] of groups) {
32
+ if (whole.has(key) || (complete && group.indices.size !== group.count)) return false;
33
+ }
34
+ return true;
35
+ }
36
+
37
+ // Callers get their own copy of the tags, so none can change another's answer.
38
+ export function inspectPage(html) {
39
+ const answer = inspected(html);
40
+ return { ...answer, tags: cloneJson(answer.tags) };
41
+ }
42
+
43
+ const inspected = recent(4, (html) => {
44
+ if (!/merge/i.test(html)) return { kind: 'none', tags: [] };
45
+ const source = liveTags(html, { includeDuplicates: true }).filter((tag) => PAGE.test(tag.name));
46
+ if (!source.length) return { kind: 'none', tags: [] };
47
+ const names = new Set();
48
+ const tags = [];
49
+ for (const tag of source) {
50
+ if (names.has(tag.name)) return { kind: 'invalid', reason: 'duplicate-tag', tags: [] };
51
+ names.add(tag.name);
52
+ if (tag.ephemeral) return { kind: 'invalid', reason: 'ephemeral-tag', tags: [] };
53
+ try {
54
+ tags.push([tag.name, cloneJson(parseJsonRelaxed(tag.text))]);
55
+ } catch {
56
+ return { kind: 'invalid', reason: 'invalid-json', tags: [] };
57
+ }
58
+ }
59
+ if (tags.some(([, value]) => record(value) && (Object.hasOwn(value, 'y') || Object.hasOwn(value, 'v')))) {
60
+ if (tags.length !== 1 || tags[0][0] !== 'page' || !isYPageValue(tags[0][1])) return { kind: 'invalid', reason: 'invalid-yjs', tags };
61
+ return { kind: 'yjs', tags };
62
+ }
63
+ return { kind: tags.every(([, value]) => isHybridPageValue(value)) ? 'hybrid' : 'other', tags };
64
+ });
65
+
66
+ const canon = (value) => (Array.isArray(value) ? `[${value.map(canon).join(',')}]` : value && typeof value === 'object' ? `{${Object.keys(value).sort().map((key) => JSON.stringify(key) + ':' + canon(value[key])).join(',')}}` : JSON.stringify(value ?? null));
67
+
68
+ export function pageBaseline(tags) {
69
+ if (!Array.isArray(tags) || !tags.length) throw new TypeError('Expected named hybrid page tags');
70
+ const names = new Set();
71
+ const source = tags.map((tag) => {
72
+ if (!Array.isArray(tag) || tag.length !== 2 || typeof tag[0] !== 'string' || !PAGE.test(tag[0]) || names.has(tag[0])) throw new TypeError('Expected unique page tag names');
73
+ names.add(tag[0]);
74
+ const value = cloneJson(tag[1]);
75
+ if (!isHybridPageValue(value)) throw new TypeError('Expected a hybrid page value');
76
+ return [tag[0], value];
77
+ });
78
+ return createHash('sha256').update(canon(source), 'utf8').digest('hex');
79
+ }
@@ -0,0 +1,62 @@
1
+ import { ifMatchSatisfied } from './spec-wire.js';
2
+ import { inspectPage, pageBaseline } from './page-admission.js';
3
+
4
+ const PAGE = /^page(\.[0-9]+)?$/;
5
+ const BASE = /^[0-9a-f]{64}$/;
6
+ const EPOCH = /^[A-Za-z0-9-]{1,64}$/;
7
+
8
+ export class PageConversionError extends Error {
9
+ constructor(reason) {
10
+ super(`Page conversion refused: ${reason}`);
11
+ this.name = 'PageConversionError';
12
+ this.reason = reason;
13
+ }
14
+ }
15
+
16
+ const refuse = (reason) => { throw new PageConversionError(reason); };
17
+
18
+ export function parsePageConversion(header) {
19
+ if (header === undefined || header === null) return null;
20
+ if (typeof header !== 'string') refuse('invalid-header');
21
+ const [version, ...fields] = header.split(';').map((part) => part.trim());
22
+ if (version !== 'v1') refuse('invalid-header');
23
+ const params = new Map();
24
+ for (const field of fields) {
25
+ const pair = field.split('=').map((part) => part.trim());
26
+ if (pair.length !== 2 || !['source', 'base', 'epoch'].includes(pair[0]) || !pair[1] || params.has(pair[0])) refuse('invalid-header');
27
+ params.set(pair[0], pair[1]);
28
+ }
29
+ const source = params.get('source');
30
+ const base = params.get('base');
31
+ const epoch = params.get('epoch');
32
+ if (!['file', 'room'].includes(source) || !BASE.test(base ?? '')) refuse('invalid-header');
33
+ if (source === 'file' ? params.has('epoch') : !EPOCH.test(epoch ?? '')) refuse('invalid-header');
34
+ return { source, base, epoch: epoch ?? null };
35
+ }
36
+
37
+ // A null result means no explicit conversion request, not approval of an ordinary save.
38
+ export function checkPageConversion({ header, currentHtml, html, ifMatch, room = null }) {
39
+ const request = parsePageConversion(header);
40
+ if (!request) return null;
41
+ if (typeof ifMatch !== 'string' || ifMatch.trim() === '*' || ifMatch.includes(',') || !ifMatchSatisfied(ifMatch, currentHtml)) refuse('file-changed');
42
+ const before = inspectPage(currentHtml);
43
+ const after = inspectPage(html);
44
+ if (before.kind !== 'hybrid') refuse('invalid-source');
45
+ if (after.kind !== 'yjs') refuse('invalid-capture');
46
+ const stores = new Map([...(room?.stores ?? [])].filter(([name]) => PAGE.test(name)));
47
+ if (request.source === 'file' && stores.size) refuse('room-required');
48
+ if (request.source === 'room') {
49
+ if (!stores.size || request.epoch !== room?.epoch) refuse('room-changed');
50
+ const names = new Set(before.tags.map(([name]) => name));
51
+ if ([...stores].some(([name, store]) => !names.has(name) || store.ephemeral)) refuse('ambiguous-source');
52
+ }
53
+ const tags = before.tags.map(([name, value]) => [name, stores.has(name) ? stores.get(name).value : value]);
54
+ let baseline;
55
+ try {
56
+ baseline = pageBaseline(tags);
57
+ } catch {
58
+ refuse('invalid-source');
59
+ }
60
+ if (baseline !== request.base) refuse('source-changed');
61
+ return { ...request, names: tags.map(([name]) => name), value: after.tags[0][1] };
62
+ }
@@ -0,0 +1,108 @@
1
+ import { load } from 'cheerio';
2
+ import { merge3, mergeJson } from 'hyper-morph';
3
+ import { parseJsonRelaxed } from 'hyper-morph/json-parse';
4
+ import { project, parseModel, renderModel, fragmentCleaner } from '../../page/projection.js';
5
+ import { blocksFromPageValues } from '../../page/ybind/decode.js';
6
+ import { mergePageModels } from '../../page/merge.js';
7
+ import { inert, pageRev } from './page-state.js';
8
+
9
+ // The lone article of html: its element rebuilt in doc, and where its start tag
10
+ // and its contents sit in html.
11
+ function articleOf(html, doc) {
12
+ const $ = load(html, { sourceCodeLocationInfo: true });
13
+ const nodes = $('article[data-page]').toArray().filter((node) => !inert(node));
14
+ if (nodes.length !== 1) return null;
15
+ const loc = nodes[0].sourceCodeLocation;
16
+ if (!loc?.startTag || !loc?.endTag) return null;
17
+ const article = doc.createElement('article');
18
+ for (const [name, value] of Object.entries(nodes[0].attribs)) article.setAttribute(name, value);
19
+ article.innerHTML = html.slice(loc.startTag.endOffset, loc.endTag.startOffset);
20
+ return { article, startTag: loc.startTag, inner: [loc.startTag.endOffset, loc.endTag.startOffset] };
21
+ }
22
+
23
+ // The article's start tag with its data-rev replaced or added, using the
24
+ // attribute's own location so a data-rev inside another value is left alone.
25
+ function stampOpen(html, startTag, rev) {
26
+ const at = startTag.attrs?.['data-rev'];
27
+ if (!at) return `${html.slice(startTag.startOffset, startTag.endOffset - 1)} data-rev="${rev}">`;
28
+ return html.slice(startTag.startOffset, at.startOffset) + `data-rev="${rev}"` + html.slice(at.endOffset, startTag.endOffset);
29
+ }
30
+
31
+ function tagValue(tag, doc) {
32
+ const holder = doc.createElement('div');
33
+ holder.innerHTML = tag;
34
+ const script = holder.querySelector('script');
35
+ return script ? parseJsonRelaxed(script.textContent) : null;
36
+ }
37
+
38
+ // An outside writer's page rebased onto the stored page. base is the version the
39
+ // writer started from (its article carries the incoming data-rev), current the
40
+ // stored file, state the stored page record ({ v, rev, tag }). The result is the
41
+ // incoming document with a merged article stamped with state.rev, so the host
42
+ // composes the record back in and open tabs adopt it as a hand edit. Null when
43
+ // there is nothing to rebase: no record, the writer already had the stored rev,
44
+ // or the inputs do not line up.
45
+ export function rebasePage({ storedHtml, state, incomingHtml, baseHtml, createDocument }) {
46
+ if (!state || state.v !== 1 || !state.rev || typeof state.tag !== 'string') return null;
47
+ const rev = pageRev(incomingHtml);
48
+ if (!rev || rev === state.rev) return null;
49
+ if (pageRev(baseHtml) !== rev || pageRev(storedHtml) !== state.rev) return null;
50
+ const doc = createDocument();
51
+ const value = tagValue(state.tag, doc);
52
+ if (!value) return null;
53
+ const blocks = blocksFromPageValues([value]);
54
+ const known = new Map(project(blocks).map((item) => [item.id, item]));
55
+ const incoming = articleOf(incomingHtml, doc);
56
+ const base = articleOf(baseHtml, doc);
57
+ const stored = articleOf(storedHtml, doc);
58
+ if (!incoming || !base || !stored) return null;
59
+ const merged = mergePageModels(
60
+ parseModel(base.article, { seed: rev, known }),
61
+ parseModel(stored.article, { seed: state.rev, known }),
62
+ parseModel(incoming.article, { seed: rev, known }),
63
+ { doc, merge3, mergeJson },
64
+ );
65
+ const fallbackOf = (id) => (typeof blocks[id]?.artifact?.fallbackHtml === 'string' ? blocks[id].artifact.fallbackHtml : '');
66
+ const inner = `\n${renderModel(merged.items, { fallbackOf, clean: fragmentCleaner(doc) })}\n`;
67
+ const [innerStart, innerEnd] = incoming.inner;
68
+ const open = stampOpen(incomingHtml, incoming.startTag, state.rev);
69
+ const html = incomingHtml.slice(0, incoming.startTag.startOffset) + open + inner + incomingHtml.slice(innerEnd);
70
+ return { html, conflicts: merged.conflicts };
71
+ }
72
+
73
+ // Of the versions carrying the incoming body's rev, newest first, the one it was
74
+ // most likely copied from: the one sharing the most blocks with it, the oldest on
75
+ // a tie. A hand edit that kept the rev is a second such version, and merging a
76
+ // writer that copied the first one onto it would undo that hand edit.
77
+ export function closestBase(candidates, incomingHtml, createDocument) {
78
+ if (candidates.length <= 1) return candidates[0] ?? null;
79
+ const doc = createDocument();
80
+ const rev = pageRev(incomingHtml);
81
+ const blocksOf = (html) => {
82
+ const found = articleOf(html, doc);
83
+ return found ? parseModel(found.article, { seed: rev, known: new Map() }).map((item) => JSON.stringify(item)) : null;
84
+ };
85
+ const incoming = blocksOf(incomingHtml);
86
+ if (!incoming) return candidates[candidates.length - 1];
87
+ const wanted = new Set(incoming);
88
+ let best = candidates[candidates.length - 1];
89
+ let most = -1;
90
+ for (const html of candidates) {
91
+ const blocks = blocksOf(html);
92
+ const shared = blocks ? blocks.filter((block) => wanted.has(block)).length : -1;
93
+ if (shared >= most) { best = html; most = shared; }
94
+ }
95
+ return best;
96
+ }
97
+
98
+ // The page document with its article stamped with rev: a version restored as an
99
+ // edit carries the stored rev, so open tabs merge it onto the page they have.
100
+ export function stampPageRev(html, rev) {
101
+ if (typeof html !== 'string' || !rev) return null;
102
+ const $ = load(html, { sourceCodeLocationInfo: true });
103
+ const nodes = $('article[data-page]').toArray().filter((node) => !inert(node));
104
+ const loc = nodes.length === 1 ? nodes[0].sourceCodeLocation?.startTag : null;
105
+ if (!loc) return null;
106
+ const stamped = stampOpen(html, loc, rev);
107
+ return html.slice(0, loc.startOffset) + stamped + html.slice(loc.endOffset);
108
+ }
@@ -0,0 +1,91 @@
1
+ import { load } from 'cheerio';
2
+ import { inspectPage } from './page-admission.js';
3
+ import { recent } from './recent.js';
4
+
5
+ export const inert = (node) => {
6
+ for (let parent = node.parent; parent; parent = parent.parent) if (parent.name === 'template' || parent.name === 'noscript') return true;
7
+ return false;
8
+ };
9
+
10
+ const span = (node) => {
11
+ const loc = node.sourceCodeLocation;
12
+ return loc ? { startOffset: loc.startOffset, endOffset: loc.endOffset } : null;
13
+ };
14
+
15
+ // Offsets only, never the parsed tree, so the remembered answers stay small.
16
+ const locate = recent(4, (html) => {
17
+ const $ = load(html, { sourceCodeLocationInfo: true });
18
+ const articles = $('article[data-page]').toArray().filter((node) => !inert(node));
19
+ const tags = $('script[merge="page"]').toArray().filter((node) => !inert(node) &&
20
+ ($(node).attr('clay') || '').split(/\s+/).includes('live'));
21
+ return Object.freeze({
22
+ articles: articles.length,
23
+ tags: tags.length,
24
+ rev: articles.length === 1 ? $(articles[0]).attr('data-rev') || null : null,
25
+ article: articles.length === 1 ? span(articles[0]) : null,
26
+ tag: tags.length === 1 ? span(tags[0]) : null,
27
+ });
28
+ });
29
+
30
+ export function pageRev(html) {
31
+ if (typeof html !== 'string') return null;
32
+ return locate(html).rev;
33
+ }
34
+
35
+ const lineStart = (html, at) => {
36
+ for (let i = at - 1; i >= 0; i--) if (html[i] === '\n' || html[i] === '\r') return i + 1;
37
+ return 0;
38
+ };
39
+
40
+ const lineEnd = (html, at) => {
41
+ for (let i = at; i < html.length; i++) {
42
+ if (html[i] === '\n') return i + 1;
43
+ if (html[i] === '\r') return html[i + 1] === '\n' ? i + 2 : i + 1;
44
+ }
45
+ return html.length;
46
+ };
47
+
48
+ const newlineOf = (html, from) => {
49
+ const before = html.slice(Math.max(0, from - 2), from);
50
+ if (before.endsWith('\r\n')) return '\r\n';
51
+ if (before.endsWith('\n')) return '\n';
52
+ if (before.endsWith('\r')) return '\r';
53
+ const first = /\r\n|\r|\n/.exec(html);
54
+ return first ? first[0] : '\n';
55
+ };
56
+
57
+ const spanOf = (html, loc) => {
58
+ let start = loc.startOffset;
59
+ let end = loc.endOffset;
60
+ const from = lineStart(html, start);
61
+ const to = lineEnd(html, end);
62
+ if (!/\S/.test(html.slice(from, start)) && !/\S/.test(html.slice(end, to))) { start = from; end = to; }
63
+ return [start, end];
64
+ };
65
+
66
+ // The stored file and the page tag it held, or the html unchanged with no state
67
+ // when it is not exactly one Yjs page tag beside exactly one revisioned article.
68
+ // A tag with no revisioned article stays in the file: there it is the only copy
69
+ // of the page, and no record could hold it.
70
+ export function splitPage(html) {
71
+ if (typeof html !== 'string' || inspectPage(html).kind !== 'yjs') return { file: html, state: null };
72
+ const { articles, tags, rev, article, tag: loc } = locate(html);
73
+ if (articles !== 1 || tags !== 1 || !rev) return { file: html, state: null };
74
+ if (!article || !loc || (loc.startOffset < article.endOffset && loc.endOffset > article.startOffset)) return { file: html, state: null };
75
+ const [start, end] = spanOf(html, loc);
76
+ return { file: html.slice(0, start) + html.slice(end), state: { v: 1, rev, tag: html.slice(loc.startOffset, loc.endOffset) } };
77
+ }
78
+
79
+ // The document an editor sees: the file with the stored page tag put back
80
+ // before its article, when the record still belongs to that article.
81
+ export function composePage(file, state) {
82
+ if (typeof file !== 'string' || !state || state.v !== 1 || typeof state.tag !== 'string' || !state.rev) return file;
83
+ if (inspectPage(file).kind !== 'none') return file;
84
+ const { articles, rev, article } = locate(file);
85
+ if (articles !== 1 || rev !== state.rev || !article) return file;
86
+ const at = article.startOffset;
87
+ const from = lineStart(file, at);
88
+ const indent = file.slice(from, at);
89
+ const insert = /\S/.test(indent) ? state.tag : `${state.tag}${newlineOf(file, from)}${indent}`;
90
+ return file.slice(0, at) + insert + file.slice(at);
91
+ }
@@ -0,0 +1,63 @@
1
+ import { parseJsonRelaxed } from 'hyper-morph/json-parse';
2
+ import { liveTags } from './live-data-tags.js';
3
+ import { inspectPage } from './page-admission.js';
4
+ import { checkPageConversion, PageConversionError } from './page-conversion.js';
5
+ import { PageFormatError } from './page-write-guard.js';
6
+ import { ifMatchSatisfied } from './spec-wire.js';
7
+
8
+ const PAGE = /^page(\.[0-9]+)?$/;
9
+ const record = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
10
+ const refuse = (reason) => { throw new PageConversionError(reason); };
11
+
12
+ function claimsYPage(html) {
13
+ if (!/merge/i.test(html)) return false;
14
+ return liveTags(html, { includeDuplicates: true }).some((tag) => {
15
+ if (!PAGE.test(tag.name)) return false;
16
+ try {
17
+ const value = parseJsonRelaxed(tag.text);
18
+ return record(value) && value.v === 1 && record(value.y);
19
+ } catch {
20
+ return false;
21
+ }
22
+ });
23
+ }
24
+
25
+ function checkInitialRoom(room) {
26
+ if ([...(room?.stores.keys() ?? [])].some((name) => PAGE.test(name)) ||
27
+ [...(room?.lastN.keys() ?? [])].some((key) => PAGE.test(key.slice(0, key.indexOf('\n')))) ||
28
+ (room?.pageTransition && room.pageTransition.format !== 'none')) refuse('room-page-authority');
29
+ }
30
+
31
+ function checkInitialCondition(ifMatch, currentHtml) {
32
+ if (typeof ifMatch !== 'string' || ifMatch.trim() === '*' || ifMatch.includes(',') ||
33
+ !ifMatchSatisfied(ifMatch, currentHtml)) refuse('file-changed');
34
+ }
35
+
36
+ export function checkPageInitial({ currentHtml, html, ifMatch, room = null }) {
37
+ if (typeof currentHtml !== 'string' || inspectPage(currentHtml).kind !== 'none') refuse('invalid-source');
38
+ if (typeof html !== 'string' || inspectPage(html).kind !== 'yjs') refuse('invalid-capture');
39
+ checkInitialRoom(room);
40
+ checkInitialCondition(ifMatch, currentHtml);
41
+ }
42
+
43
+ export function checkPageWriteAdmission({ currentHtml, html, header = null, ifMatch, room = null }) {
44
+ const before = inspectPage(currentHtml ?? '');
45
+ const after = inspectPage(html ?? '');
46
+ const converted = before.kind === 'yjs' || room?.pageTransition?.format === 'yjs';
47
+ if (converted && after.kind === 'none' && header == null) return 'ordinary';
48
+ if (converted && after.kind !== 'yjs') throw new PageFormatError();
49
+ if (header !== null && header !== undefined) {
50
+ checkPageConversion({ header, currentHtml, html, ifMatch, room });
51
+ return 'conversion';
52
+ }
53
+ if (converted) return 'ordinary';
54
+ if (after.kind !== 'yjs') {
55
+ if (after.kind === 'invalid' && claimsYPage(html ?? '') && !claimsYPage(currentHtml ?? '')) refuse('invalid-capture');
56
+ return 'ordinary';
57
+ }
58
+ if (before.kind === 'hybrid') refuse('implicit-conversion');
59
+ if (before.kind !== 'none' || typeof currentHtml !== 'string') refuse('invalid-source');
60
+ checkInitialRoom(room);
61
+ checkInitialCondition(ifMatch, currentHtml);
62
+ return 'initial';
63
+ }
@@ -0,0 +1,23 @@
1
+ import { inspectPage } from './page-admission.js';
2
+
3
+ export class PageFormatError extends Error {
4
+ constructor() {
5
+ super('A converted page must retain one complete Yjs page value');
6
+ this.name = 'PageFormatError';
7
+ this.code = 'page-format';
8
+ this.statusCode = 409;
9
+ }
10
+ }
11
+
12
+ export function checkPageWriteFormat({ currentHtml, html, room = null }) {
13
+ if (room?.pageTransition?.format !== 'yjs' && inspectPage(currentHtml ?? '').kind !== 'yjs') return;
14
+ const kind = inspectPage(html ?? '').kind;
15
+ if (kind !== 'yjs' && kind !== 'none') throw new PageFormatError();
16
+ }
17
+
18
+ // A Yjs page room whose lineage the written document no longer carries: an
19
+ // outside edit or restore replaced the article, so the room's unsaved page
20
+ // edits belong in a recovered copy and the room must start over.
21
+ export function pageLineageLost(room, html) {
22
+ return room?.pageTransition?.format === 'yjs' && room.stores.has('page') && inspectPage(html ?? '').kind === 'none';
23
+ }