rapier-markdown-kit 1.1.55 → 1.1.57

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.
@@ -0,0 +1,139 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ const _RAPIER_TRANSACTION_ACTOR_LIMIT = 160;
4
+ const _RAPIER_TRANSACTION_OPERATION_LIMIT = 96;
5
+ const _RAPIER_TRANSACTION_REQUEST_LIMIT = 160;
6
+
7
+ /* Validate every row, not only the final string: a half-pair replacement can produce a whole
8
+ character while leaving unencodable fragments in the ledger; two invalid rows can also repair
9
+ each other. Refusal is atomic. Pre-existing damage elsewhere stays untouched and editable. */
10
+ function _rapierTransformSplices(text, splices, inverse = false) {
11
+ try {
12
+ if (!Array.isArray(splices)) return null;
13
+ let value = String(text == null ? '' : text);
14
+ // In Unicode mode a complete pair is one scalar, outside the surrogate range.
15
+ const whole = text => typeof text === 'string' && !/[\uD800-\uDFFF]/u.test(text);
16
+ const splitsPair = at => at > 0 && at < value.length &&
17
+ (value.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
18
+ (value.charCodeAt(at) & 0xFC00) === 0xDC00;
19
+ const rows = inverse ? splices.slice().reverse() : splices;
20
+ for (const row of rows) {
21
+ if (!row || !Number.isSafeInteger(row.pos) || row.pos < 0 || Object.is(row.pos, -0)) return null;
22
+ const pos = row.pos;
23
+ const removed = inverse ? row.inserted : row.removed;
24
+ const inserted = inverse ? row.removed : row.inserted;
25
+ if (!whole(removed) || !whole(inserted) || pos > value.length ||
26
+ pos + removed.length > value.length || splitsPair(pos) || splitsPair(pos + removed.length) ||
27
+ value.slice(pos, pos + removed.length) !== removed) return null;
28
+ value = value.slice(0, pos) + inserted + value.slice(pos + removed.length);
29
+ }
30
+ return value;
31
+ } catch (_) { return null; }
32
+ }
33
+
34
+ function _rapierRecordSplices(entry, ledger) {
35
+ try {
36
+ if (entry && Array.isArray(entry.splices) && entry.splices.length) return entry.splices;
37
+ const transaction = entry && entry.transaction;
38
+ const targetId = String(transaction?.reverts || transaction?.reapplies || '');
39
+ const target = targetId && (ledger || []).find(record => record?.transaction?.id === targetId);
40
+ if (!target || !Array.isArray(target.splices) || !target.splices.length) return null;
41
+ return transaction.reverts ? target.splices.slice().reverse().map(row => ({
42
+ pos: row.pos, removed: row.inserted, inserted: row.removed,
43
+ })) : target.splices;
44
+ } catch (_) { return null; }
45
+ }
46
+
47
+ // A ledger record as the journal and the Notes return validate it below: plain data, nothing
48
+ // undefined anywhere in it. A live record could carry a row whose type or order was not yet known
49
+ // (a block just inserted, before its first render: `type: undefined`), and the validator refused
50
+ // the whole ledger for that one field -- silently at boot, where a refused ledger is cleared, and on
51
+ // the founder's phone as "Rapier could not be reopened: the document history could not be restored"
52
+ // on every return from Notes (24 September). The copy is made once per record; records are frozen,
53
+ // so it never goes stale.
54
+ const _rapierJournalRecord = (() => {
55
+ const copies = new WeakMap();
56
+ return record => {
57
+ let copy = copies.get(record);
58
+ if (!copy) { copy = JSON.parse(JSON.stringify(record)); copies.set(record, copy); }
59
+ return copy;
60
+ };
61
+ })();
62
+
63
+ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, maxDocumentBytes, utf8Length, allowGap = false) {
64
+ // Recovery records are data, not executable objects. Inspect descriptors without invoking
65
+ // accessors, and use an ancestor set (not a global seen set) so shared acyclic data stays valid.
66
+ // Iterative traversal refuses cycles without exhausting the call stack. Proxies/counters that
67
+ // throw are refused by the same gate rather than escaping into document recovery.
68
+ try {
69
+ const active = new Set(), pending = [[record, false]];
70
+ while (pending.length) {
71
+ const [value, leaving] = pending.pop();
72
+ if (leaving) { active.delete(value); continue; }
73
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') continue;
74
+ if (typeof value === 'number') { if (!Number.isFinite(value)) return false; continue; }
75
+ if (!value || typeof value !== 'object' || active.has(value)) return false;
76
+ const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
77
+ if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
78
+ Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) return false;
79
+ const keys = Reflect.ownKeys(value);
80
+ if (array && keys.length !== value.length + 1) return false;
81
+ active.add(value); pending.push([value, true]);
82
+ for (const key of keys) {
83
+ if (array && key === 'length') continue;
84
+ if (typeof key !== 'string' || key === '__proto__' || key === 'constructor' || key === 'prototype' ||
85
+ (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 ||
86
+ Number(key) >= value.length || String(Number(key)) !== key))) return false;
87
+ const field = Object.getOwnPropertyDescriptor(value, key);
88
+ if (!field || !Object.hasOwn(field, 'value')) return false;
89
+ pending.push([field.value, false]);
90
+ }
91
+ }
92
+ if (!record || typeof record !== 'object' ||
93
+ typeof record.beforeHash !== 'string' || !record.beforeHash.length ||
94
+ typeof record.afterHash !== 'string' || !record.afterHash.length || record.beforeHash === record.afterHash) return false;
95
+ const own = Array.isArray(record.splices) && record.splices.length;
96
+ const navigation = !!(record.transaction?.reverts || record.transaction?.reapplies);
97
+ if ((!own && !navigation) || (own && record.splices.length > 64) ||
98
+ (record.transaction?.reverts && record.transaction?.reapplies)) return false;
99
+ const splices = _rapierRecordSplices(record, earlier);
100
+ if (!splices || splices.length > 64 || !splices.every(row =>
101
+ row && Number.isSafeInteger(row.pos) && row.pos >= 0 && !Object.is(row.pos, -0) &&
102
+ typeof row.removed === 'string' && typeof row.inserted === 'string' &&
103
+ !/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted) &&
104
+ utf8Length(row.removed) <= maxDocumentBytes &&
105
+ utf8Length(row.inserted) <= maxDocumentBytes)) return false;
106
+ const transaction = record.transaction;
107
+ const nullableText = (value, limit, empty = true) => value === null ||
108
+ (typeof value === 'string' && value.length <= limit && (empty || value.length > 0));
109
+ if (!transaction || typeof transaction.id !== 'string' || !transaction.id.length ||
110
+ transaction.id.length > 256 || !Array.isArray(earlier) ||
111
+ earlier.some(row => row?.transaction?.id === transaction.id) ||
112
+ typeof transaction.documentAuthority !== 'string' ||
113
+ transaction.documentAuthority !== documentAuthority ||
114
+ (transaction.transport !== 'platform' && transaction.transport !== 'webmcp') ||
115
+ typeof transaction.operation !== 'string' || !transaction.operation.length ||
116
+ transaction.operation.length > _RAPIER_TRANSACTION_OPERATION_LIMIT ||
117
+ !nullableText(transaction.requestId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
118
+ !nullableText(transaction.sourceTransactionId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
119
+ !nullableText(transaction.parent, 256, false) ||
120
+ !nullableText(transaction.reverts, 256, false) ||
121
+ !nullableText(transaction.reapplies, 256, false) ||
122
+ !Number.isSafeInteger(transaction.createdAt) || transaction.createdAt < 0 ||
123
+ !Array.isArray(transaction.affectedBlockIds) || transaction.affectedBlockIds.length > 64 ||
124
+ !transaction.affectedBlockIds.every(id => Number.isSafeInteger(id) && id >= 0) ||
125
+ !Number.isSafeInteger(transaction.revision) || transaction.revision < 1 ||
126
+ !Number.isSafeInteger(transaction.baseRevision) ||
127
+ transaction.baseRevision + 1 !== transaction.revision ||
128
+ (prior && (transaction.revision <= prior.transaction.revision ||
129
+ (transaction.baseRevision === prior.transaction.revision
130
+ ? (transaction.parent !== prior.transaction.id || record.beforeHash !== prior.afterHash)
131
+ : (!allowGap || transaction.baseRevision <= prior.transaction.revision))))) return false;
132
+ return !!transaction.actor && typeof transaction.actor.kind === 'string' &&
133
+ /^(?:human|agent|system)$/.test(transaction.actor.kind) &&
134
+ typeof transaction.actor.id === 'string' && transaction.actor.id.length > 0 &&
135
+ transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT;
136
+ } catch (_) { return false; }
137
+ }
138
+
139
+ export { _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord};
@@ -0,0 +1,101 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {readLedger, exportLedger, rootAfter} from './format.mjs';
3
+ import {_rapierTransformSplices} from './journal-records.mjs';
4
+ import {mergeText} from './text-merge.mjs';
5
+ import {parseWill} from '../../agent/will.mjs';
6
+ import {sha256} from './hash.mjs';
7
+ import {canonicalJSON, fault} from './data.mjs';
8
+ // The same interval transport used by the agent kernel, now shared with the public ledger.
9
+ export function transportInterval(start, end, splices) {
10
+ let a = start, b = end;
11
+ for (const row of splices) {
12
+ if (!Number.isSafeInteger(row.pos) || row.pos < 0) return null;
13
+ if (row.pos + row.removed.length <= a) {
14
+ const delta = row.inserted.length - row.removed.length; a += delta; b += delta;
15
+ } else if (row.pos < b || (a === b && row.pos < a && row.pos + row.removed.length > a)) return null;
16
+ }
17
+ return {start: a, end: b};
18
+ }
19
+ function over(row, other) {
20
+ // Equal-position insertions have no ordering evidence. Review, never a lexical winner.
21
+ if (!row.removed.length && !other.removed.length && row.pos === other.pos) return null;
22
+ const range = transportInterval(row.pos, row.pos + row.removed.length, [other]);
23
+ return range ? {...row, pos: range.start} : null;
24
+ }
25
+ const recordKey = record => sha256(canonicalJSON(record));
26
+ const origin = record => record.transaction.sourceTransactionId?.startsWith('ledger:') ? record.transaction.sourceTransactionId : 'ledger:' + recordKey(record);
27
+ function checkpointList(read) {
28
+ const list = [{root: read.ledger.start.root, text: read.ledger.start.text, index: 0}], records = read.ledger.records;
29
+ let text = read.ledger.start.text;
30
+ for (let i = 0; i < records.length; i++) { text = _rapierTransformSplices(text, records[i].splices); list.push({root: records[i].afterHash, text, index: i + 1}); }
31
+ return list;
32
+ }
33
+ function kept(text) {
34
+ const will = parseWill(text);
35
+ if (will.fault) return {fault: will.fault, text};
36
+ return will.regions.filter(region => region.law === 'keep').map(region => text.slice(region.start, region.end));
37
+ }
38
+ export function merge(first, second) {
39
+ const ours = readLedger(first), theirs = readLedger(second);
40
+ // A reviewed conflict receipt names the complete incoming envelope: it too is idempotent.
41
+ if (ours.ledger.records.some(record => record.transaction.operation === 'document.merge' && record.transaction.sourceTransactionId === 'ledger:' + theirs.ledger.sha256)) {
42
+ const clean = !ours.text.includes('<!-- note-conflict:v1 ');
43
+ return {text: ours.text, ledger: ours.ledger, clean, conflicts: [], review: {required: !clean, base: ours.text, proposed: ours.text}};
44
+ }
45
+ const a = checkpointList(ours), b = checkpointList(theirs);
46
+ let common = null;
47
+ for (let i = b.length - 1; i >= 0 && !common; i--) {
48
+ const found = a.findLast(point => point.root === b[i].root && point.text === b[i].text);
49
+ if (found) common = {ours: found.index, theirs: b[i].index, text: found.text};
50
+ }
51
+ if (!common) throw fault('copies have no proven common start');
52
+ const records = ours.ledger.records.slice(), imported = new Set(records.map(origin));
53
+ let text = ours.text, root = ours.root;
54
+ const append = (splices, actor, sourceId, operation, at) => {
55
+ const next = _rapierTransformSplices(text, splices);
56
+ if (next === null) throw fault('transported record failed replay');
57
+ const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
58
+ for (const row of splices) root = rootAfter(root, row);
59
+ records.push({beforeHash, afterHash: root, splices, transaction: {
60
+ id: ours.ledger.documentAuthority.slice(0, 48) + ':merge:' + revision.toString(36), documentAuthority: ours.ledger.documentAuthority,
61
+ baseRevision: revision - 1, revision, actor, transport: 'platform', operation, requestId: null,
62
+ sourceTransactionId: sourceId, affectedBlockIds: [], parent: records.at(-1)?.transaction.id ?? null,
63
+ reverts: null, reapplies: null, createdAt: at,
64
+ }}); text = next;
65
+ };
66
+ let against = ours.ledger.records.slice(common.ours).flatMap(record => record.splices.map(row => ({...row})));
67
+ const incoming = theirs.ledger.records.slice(common.theirs);
68
+ // Complete repeats are detected by record content, never the revision ID (copies can reuse IDs).
69
+ const unseen = incoming.filter(record => !imported.has(origin(record)));
70
+ const keepDiffers = canonicalJSON(kept(ours.text)) !== canonicalJSON(kept(theirs.text));
71
+ let conflicted = keepDiffers && unseen.length > 0;
72
+ if (!conflicted && unseen.length && unseen.length !== incoming.length) conflicted = true; // partial receipt needs a common text merge
73
+ if (!conflicted) for (const record of unseen) {
74
+ const moved = [];
75
+ for (const splice of record.splices) {
76
+ let row = {...splice}; const nextAgainst = [];
77
+ for (const other of against) {
78
+ const next = over(row, other), otherNext = over(other, row);
79
+ if (!next || !otherNext) { conflicted = true; break; }
80
+ nextAgainst.push(otherNext); row = next;
81
+ }
82
+ if (conflicted) break;
83
+ moved.push(row); against = nextAgainst;
84
+ }
85
+ if (conflicted || _rapierTransformSplices(text, moved) === null) { conflicted = true; break; }
86
+ append(moved, record.transaction.actor, origin(record), record.transaction.operation, record.transaction.createdAt);
87
+ }
88
+ let conflicts = [];
89
+ if (conflicted) {
90
+ // Restart from the original local head: partially transported records never leak into a conflict result.
91
+ records.splice(ours.ledger.records.length); text = ours.text; root = ours.root;
92
+ const merged = mergeText(keepDiffers ? null : common.text, ours.text, theirs.text, {oursId: 'copy-' + ours.ledger.sha256.slice(0, 16), theirsId: 'copy-' + theirs.ledger.sha256.slice(0, 16)});
93
+ conflicts = merged.conflicts;
94
+ if (merged.text !== text) append([{pos: 0, removed: text, inserted: merged.text}], {kind: 'system', id: 'merge'},
95
+ 'ledger:' + theirs.ledger.sha256, 'document.merge', Math.max(0, ...incoming.map(record => record.transaction.createdAt)));
96
+ }
97
+ const ledger = exportLedger({text, records, documentAuthority: ours.ledger.documentAuthority, root,
98
+ revision: records.at(-1)?.transaction.revision ?? ours.revision, complete: ours.complete});
99
+ const clean = !conflicts.length && !text.includes('<!-- note-conflict:v1 ');
100
+ return {text, ledger, clean, conflicts, review: {required: !clean, base: ours.text, proposed: text}};
101
+ }
@@ -0,0 +1,345 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Shared conflict envelopes and exact three-way text merge; Notes and the ledger import this owner.
3
+ function textFault(code, message) { return Object.assign(new Error(message), {code}); }
4
+ export function canonicalNote(text) {
5
+ if (typeof text !== 'string') throw textFault('note_text', 'A note must be text');
6
+ for (let i = 0; i < text.length; i++) {
7
+ const c = text.charCodeAt(i);
8
+ if (c >= 0xd800 && c <= 0xdbff) {
9
+ const next = text.charCodeAt(++i);
10
+ if (!(next >= 0xdc00 && next <= 0xdfff)) throw textFault('note_unicode', 'An unpaired surrogate has no exact UTF-8 bytes');
11
+ } else if (c >= 0xdc00 && c <= 0xdfff) throw textFault('note_unicode', 'An unpaired surrogate has no exact UTF-8 bytes');
12
+ }
13
+ return text;
14
+ }
15
+
16
+ const TEXT_CONFLICT = '<!-- note-conflict:v1 ';
17
+ const textLine = line => line.replace(/(?:\r\n|\r|\n)$/, '');
18
+ const textBlank = line => /^[ \t]*$/.test(textLine(line));
19
+ const textSpace = unit => /^[ \t\r\n]*$/.test(unit);
20
+ const textList = line => /^( {0,3})(?:[-+*]|\d{1,9}[.)])[ \t]+/.exec(line);
21
+ const textFence = line => /^ {0,3}(`{3,}|~{3,})(.*)$/.exec(line);
22
+ const textRule = line => /^ {0,3}(?:(?:\*[ \t]*){3,}|(?:-[ \t]*){3,}|(?:_[ \t]*){3,})$/.test(line);
23
+ const textHeading = line => /^ {0,3}#{1,6}(?:[ \t]|$)/.test(line);
24
+ const textDefinition = line => /^ {0,3}\[(?:\\.|[^\]\\\r\n])+\]:/.test(line);
25
+ const textStart = line => textHeading(line) || textRule(line) || textFence(line) || /^ {0,3}(?:[-+*]|1[.)])[ \t]+/.test(line) || /^ {0,3}(?:>|<!--|<(?:script|style|pre|textarea)(?:\s|>))/i.test(line);
26
+
27
+ // Only the exact public envelope is structural; malformed comments and fenced examples stay text.
28
+ function textEnvelopeAt(text, start = 0) {
29
+ if (!text.startsWith(TEXT_CONFLICT, start)) return null;
30
+ const headerEnd = text.indexOf(' -->\n\n', start), variants = [];
31
+ if (headerEnd < 0) return null;
32
+ let header;
33
+ try { header = JSON.parse(text.slice(start + TEXT_CONFLICT.length, headerEnd)); } catch { return null; }
34
+ if (!header || typeof header.id !== 'string' || !Array.isArray(header.variants) || header.variants.length < 2) return null;
35
+ let at = headerEnd + 6;
36
+ for (const item of header.variants) {
37
+ if (!item || typeof item.device !== 'string' || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(item.device) || !Number.isSafeInteger(item.length) || item.length < 0) return null;
38
+ const label = '**Version from ' + item.device + '**\n\n';
39
+ if (!text.startsWith(label, at)) return null;
40
+ at += label.length;
41
+ const fence = /^(`{3,})markdown\n/.exec(text.slice(at));
42
+ if (!fence) return null;
43
+ at += fence[0].length;
44
+ const value = text.slice(at, at + item.length); at += item.length;
45
+ const tail = (value.endsWith('\n') ? '' : '\n') + fence[1] + '\n\n';
46
+ if (!text.startsWith(tail, at)) return null;
47
+ at += tail.length; variants.push({device: item.device, text: value});
48
+ }
49
+ const tail = '<!-- /note-conflict:v1 -->\n';
50
+ if (!text.startsWith(tail, at)) return null;
51
+ const end = at + tail.length, block = text.slice(start, end);
52
+ if (textEnvelope(header.id, variants) !== block) return null;
53
+ return {kind: 'text', id: header.id, start, end, block, variants};
54
+ }
55
+
56
+ export function inspectTextConflicts(text, {nested = false} = {}) {
57
+ canonicalNote(text);
58
+ const out = []; let at = 0;
59
+ for (const unit of textUnits(text)) {
60
+ const found = textEnvelopeAt(unit);
61
+ if (found && found.end === unit.length) {
62
+ out.push({...found, start: at, end: at + unit.length});
63
+ // Nested offsets belong to their alternative's source; custody callers use
64
+ // the exact block bytes. Ordinary fenced examples remain opaque at every level.
65
+ if (nested) for (const variant of found.variants) out.push(...inspectTextConflicts(variant.text, {nested}));
66
+ }
67
+ at += unit.length;
68
+ }
69
+ return out;
70
+ }
71
+
72
+ export function mapTextConflictVariants(text, mapper) {
73
+ const conflicts = inspectTextConflicts(text); let out = '', at = 0;
74
+ for (const block of conflicts) {
75
+ out += text.slice(at, block.start) + textEnvelope(block.id, block.variants.map(variant => ({
76
+ device: variant.device, text: canonicalNote(mapper(mapTextConflictVariants(variant.text, mapper), variant.device)),
77
+ })));
78
+ at = block.end;
79
+ }
80
+ return out + text.slice(at);
81
+ }
82
+
83
+ // This is a conservative edit boundary scan, never the document's rendering grammar.
84
+ function textUnits(text) {
85
+ const lines = text.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/g) || [], out = [], offsets = [0];
86
+ for (const line of lines) offsets.push(offsets[offsets.length - 1] + line.length);
87
+ for (let i = 0; i < lines.length;) {
88
+ const start = i, first = textLine(lines[i]).replace(/^\ufeff/, ''), list = textList(first), fence = textFence(first);
89
+ const envelope = first.startsWith(TEXT_CONFLICT) ? textEnvelopeAt(text, offsets[i]) : null;
90
+ if (envelope) {
91
+ while (i < lines.length && offsets[i] < envelope.end) i++;
92
+ out.push(lines.slice(start, i).join('')); continue;
93
+ }
94
+ if (textBlank(lines[i])) { while (++i < lines.length && textBlank(lines[i])) {} }
95
+ else if (fence) {
96
+ const mark = fence[1][0], count = fence[1].length;
97
+ i++;
98
+ while (i < lines.length) {
99
+ const close = new RegExp('^ {0,3}' + (mark === '`' ? '`' : '~') + '{' + count + ',}[ \\t]*$').test(textLine(lines[i++]));
100
+ if (close) break;
101
+ }
102
+ } else if (list) {
103
+ const indent = list[1].length;
104
+ i++;
105
+ while (i < lines.length) {
106
+ const line = textLine(lines[i]), next = textList(line);
107
+ if (next && next[1].length <= indent) break;
108
+ if (textBlank(lines[i])) {
109
+ let j = i; while (j < lines.length && textBlank(lines[j])) j++;
110
+ if (j === lines.length || !new RegExp('^(?: {' + (indent + 1) + ',}|\\t)').test(lines[j])) break;
111
+ i = j; continue;
112
+ }
113
+ if (!/^[ \t]/.test(line) && textStart(line)) break;
114
+ i++;
115
+ }
116
+ } else if (/^ {0,3}>/.test(first)) {
117
+ i++;
118
+ while (i < lines.length) {
119
+ if (textBlank(lines[i])) {
120
+ let j = i; while (j < lines.length && textBlank(lines[j])) j++;
121
+ if (j === lines.length || !/^ {0,3}>/.test(lines[j])) break;
122
+ i = j; continue;
123
+ }
124
+ if (!/^ {0,3}>/.test(lines[i]) && textStart(textLine(lines[i]))) break;
125
+ i++;
126
+ }
127
+ } else if (/^ {0,3}<!--/.test(first)) {
128
+ i++;
129
+ if (!first.includes('-->')) while (i < lines.length && !lines[i++].includes('-->')) {}
130
+ } else if (/^ {0,3}<(script|style|pre|textarea)(?:\s|>)/i.test(first)) {
131
+ const tag = /^ {0,3}<([a-z]+)/i.exec(first)[1]; i++;
132
+ if (!new RegExp('</' + tag + '\\s*>', 'i').test(first)) while (i < lines.length && !new RegExp('</' + tag + '\\s*>', 'i').test(lines[i++])) {}
133
+ } else if (textHeading(first) || textRule(first)) i++;
134
+ else {
135
+ i++;
136
+ while (i < lines.length && !textBlank(lines[i])) {
137
+ if (/^ {0,3}(?:=+|-+)[ \t]*$/.test(textLine(lines[i]))) { i++; break; }
138
+ if (textStart(textLine(lines[i]))) break;
139
+ i++;
140
+ }
141
+ if (textDefinition(first) || /^(?: {4}|\t)/.test(first)) {
142
+ while (i < lines.length) {
143
+ let j = i; while (j < lines.length && textBlank(lines[j])) j++;
144
+ if (j === lines.length || !/^(?: {4}|\t)/.test(lines[j])) break;
145
+ i = j + 1; while (i < lines.length && !textBlank(lines[i]) && /^(?: {4}|\t)/.test(lines[i])) i++;
146
+ }
147
+ }
148
+ }
149
+ out.push(lines.slice(start, i).join(''));
150
+ }
151
+ return out;
152
+ }
153
+
154
+ function textEdits(base, side) {
155
+ const n = base.length, m = side.length;
156
+ if ((n + 1) * (m + 1) > 1000000) return null;
157
+ const width = m + 1, score = new Uint32Array((n + 1) * width);
158
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
159
+ const p = i * width + j;
160
+ score[p] = base[i] === side[j] ? score[p + width + 1] + 1 : Math.max(score[p + width], score[p + 1]);
161
+ }
162
+ const walk = () => {
163
+ const pairs = []; let i = 0, j = 0;
164
+ while (i < n && j < m) {
165
+ const value = score[i * width + j]; if (!value) break;
166
+ if (base[i] === side[j]) { pairs.push([i++, j++]); continue; }
167
+ if (score[(i + 1) * width + j] >= score[i * width + j + 1]) i++; else j++;
168
+ }
169
+ return pairs;
170
+ };
171
+ const pairs = walk(), meaningful = pairs.filter(([i]) => !textSpace(base[i]));
172
+ // Every optimal path must agree on every authored anchor, not just two greedy walks.
173
+ const seen = new Uint32Array(score.length); seen.fill(0xffffffff); seen[0] = 0;
174
+ for (let i = 0; i <= n; i++) for (let j = 0; j <= m; j++) {
175
+ const p = i * width + j, at = seen[p]; if (at === 0xffffffff) continue;
176
+ const visit = (next, count) => { if (seen[next] !== 0xffffffff && seen[next] !== count) return false; seen[next] = count; return true; };
177
+ if (i < n && score[p + width] === score[p] && !visit(p + width, at)) return null;
178
+ if (j < m && score[p + 1] === score[p] && !visit(p + 1, at)) return null;
179
+ if (i < n && j < m && base[i] === side[j] && score[p] === score[p + width + 1] + 1) {
180
+ const content = !textSpace(base[i]);
181
+ if (content && (meaningful[at]?.[0] !== i || meaningful[at]?.[1] !== j)) return null;
182
+ if (!visit(p + width + 1, at + (content ? 1 : 0))) return null;
183
+ }
184
+ }
185
+ if (seen[n * width + m] !== meaningful.length) return null;
186
+ const edits = []; let i = 0, j = 0;
187
+ for (const [bi, sj] of [...pairs, [n, m]]) {
188
+ if (i !== bi || j !== sj) edits.push({start: i, end: bi, text: side.slice(j, sj).join('')});
189
+ i = bi + 1; j = sj + 1;
190
+ }
191
+ return edits;
192
+ }
193
+
194
+ function textTick(base, a, b) {
195
+ const box = value => {
196
+ const m = /^( {0,3}(?:[-+*]|\d{1,9}[.)])[ \t]+\[)([ xX])(\])/.exec(value);
197
+ return m && {at: m[1].length, tick: m[2], body: value.slice(0, m[1].length) + ' ' + value.slice(m[1].length + 1)};
198
+ };
199
+ const c = box(base); if (!c) return null;
200
+ // One side ticked, the other added lines: the tick lands on the item found whole among the other side's units. Ambiguity is a conflict.
201
+ const au = textUnits(a), bu = textUnits(b);
202
+ if (au.length !== 1 || bu.length !== 1) {
203
+ const [one, many] = au.length === 1 ? [a, bu] : bu.length === 1 ? [b, au] : [null, null];
204
+ const x = one === null ? null : box(one);
205
+ if (!x || x.body !== c.body || x.tick === c.tick) return null;
206
+ const at = many.map((unit, i) => unit === base ? i : -1).filter(i => i >= 0);
207
+ if (at.length !== 1) return null;
208
+ return many.map((unit, i) => i === at[0] ? one : unit).join('');
209
+ }
210
+ const x = box(a), y = box(b); if (!x || !y) return null;
211
+ const body = x.body === y.body ? x.body : x.body === c.body ? y.body : y.body === c.body ? x.body : null;
212
+ if (body === null) return null;
213
+ const marked = x.tick !== ' ' && y.tick !== ' ' ? [x.tick, y.tick].sort()[0] : x.tick === c.tick ? y.tick : y.tick === c.tick ? x.tick : null;
214
+ const at = box(body).at;
215
+ return marked === null ? null : body.slice(0, at) + marked + body.slice(at + 1);
216
+ }
217
+
218
+ function textDeviceOptions(options) {
219
+ const {oursId, theirsId} = options || {};
220
+ if (![oursId, theirsId].every(id => typeof id === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(id)) || oursId === theirsId) {
221
+ throw textFault('merge_devices', 'A conflict needs two distinct stable device identifiers');
222
+ }
223
+ return [oursId, theirsId];
224
+ }
225
+
226
+ function textEnvelope(id, variants) {
227
+ let longest = 2;
228
+ for (const v of variants) for (const run of v.text.match(/`+/g) || []) longest = Math.max(longest, run.length);
229
+ const fence = '`'.repeat(longest + 1);
230
+ let out = TEXT_CONFLICT + JSON.stringify({id, variants: variants.map(v => ({device: v.device, length: v.text.length}))}) + ' -->\n\n';
231
+ for (const v of variants) {
232
+ out += '**Version from ' + v.device + '**\n\n' + fence + 'markdown\n' + v.text + (v.text.endsWith('\n') ? '' : '\n') + fence + '\n\n';
233
+ }
234
+ return out + '<!-- /note-conflict:v1 -->\n';
235
+ }
236
+
237
+ function textDeclarations(text) {
238
+ text = textUnits(text).filter(unit => !textEnvelopeAt(unit)).join('');
239
+ // A definition can move a destination/title onto the next unindented line, or span its label.
240
+ const definitions = text.match(/^\ufeff? {0,3}\[(?:\\.|[^\]\\])+\]:[^\r\n]*(?:(?:\r\n|\r|\n)(?![ ]*(?:\r\n|\r|\n|$))[^\r\n]*)*/gm) || [];
241
+ const continuations = textUnits(text).filter(unit => /^\ufeff? {0,3}\[\^/.test(unit));
242
+ return [...definitions, ...continuations, ...(text.match(/<!--[\s\S]*?(?:-->|$)/g) || [])].join('\0');
243
+ }
244
+
245
+ export function mergeText(base, ours, theirs, options = {}) {
246
+ canonicalNote(ours); canonicalNote(theirs); if (base !== null) canonicalNote(base);
247
+ const unchanged = value => ({text: value, clean: !value.includes(TEXT_CONFLICT), conflicts: value.includes(TEXT_CONFLICT) ? [{kind: 'existing-conflict', advisory: true}] : []});
248
+ if (ours === theirs) return unchanged(ours);
249
+ if (base === ours) return unchanged(theirs);
250
+ if (base === theirs) return unchanged(ours);
251
+ const conflicts = []; let text = '';
252
+ const conflict = (before, a, b, kind) => {
253
+ // Shared framing remains ordinary text; only the changed region is a choice.
254
+ const ab = inspectTextConflicts(a), bb = inspectTextConflicts(b);
255
+ const au = textUnits(a), bu = textUnits(b); let first = 0, last = 0;
256
+ while (first < Math.min(au.length, bu.length) && au[first] === bu[first]) first++;
257
+ while (last < Math.min(au.length, bu.length) - first && au[au.length - 1 - last] === bu[bu.length - 1 - last]) last++;
258
+ if ((ab.length || bb.length) && (first || last)) {
259
+ const prefix = au.slice(0, first).join(''), suffix = last ? au.slice(-last).join('') : '';
260
+ text += prefix;
261
+ conflict(null, au.slice(first, last ? -last : undefined).join(''), bu.slice(first, last ? -last : undefined).join(''), kind);
262
+ text += suffix; return;
263
+ }
264
+ // A shared unresolved region is an anchor even when its alternatives have grown. Splitting
265
+ // around it keeps surrounding edits in their own choices instead of quoting an old choice.
266
+ const overlaps = (x, y) => x.variants.some(v => y.variants.some(w => v.device === w.device && v.text === w.text));
267
+ const anchor = ab.map(x => ({x, matches: bb.filter(y => overlaps(x, y))})).find(({x, matches}) =>
268
+ matches.length === 1 && ab.filter(y => overlaps(y, matches[0])).length === 1 &&
269
+ (x.start || matches[0].start || x.end < a.length || matches[0].end < b.length));
270
+ if (anchor) {
271
+ const {x, matches: [y]} = anchor;
272
+ for (const [av, bv] of [[a.slice(0, x.start), b.slice(0, y.start)], [x.block, y.block], [a.slice(x.end), b.slice(y.end)]]) {
273
+ if (av === bv) text += av; else conflict(null, av, bv, kind);
274
+ }
275
+ return;
276
+ }
277
+ const ids = textDeviceOptions(options), candidates = [{device: ids[0], text: a}, {device: ids[1], text: b}];
278
+ const variants = [...new Map(candidates.flatMap(v => {
279
+ const block = textEnvelopeAt(v.text);
280
+ return block && block.end === v.text.length ? block.variants : [v];
281
+ }).map(v => [JSON.stringify(v), v])).values()].sort((x, y) => x.device < y.device ? -1 : x.device > y.device ? 1 : x.text < y.text ? -1 : x.text > y.text ? 1 : 0);
282
+ if (text && !/[\r\n]$/.test(text)) text += '\n\n';
283
+ const start = text.length, block = textEnvelope(String(conflicts.length), variants);
284
+ conflicts.push({kind, base: before, variants, start, end: start + block.length, block});
285
+ text += block;
286
+ };
287
+ if (base === null) conflict(null, ours, theirs, 'no-ancestor');
288
+ else if (textDeclarations(ours) !== textDeclarations(base) || textDeclarations(theirs) !== textDeclarations(base)) conflict(base, ours, theirs, 'document-bindings');
289
+ else {
290
+ const units = textUnits(base), a = textEdits(units, textUnits(ours)), b = textEdits(units, textUnits(theirs));
291
+ if (!a || !b) conflict(base, ours, theirs, 'ambiguous-alignment-or-budget');
292
+ else {
293
+ const changes = [...a.map(e => ({...e, side: 0})), ...b.map(e => ({...e, side: 1}))].sort((x, y) => x.start - y.start || x.end - y.end || x.side - y.side);
294
+ let pos = 0;
295
+ for (let i = 0; i < changes.length;) {
296
+ const group = [changes[i++]], start = group[0].start; let end = group[0].end;
297
+ // An insertion can anchor on either side of a blank run; that is still one gap.
298
+ while (i < changes.length && (changes[i].start < end || group.some(e => {
299
+ const next = changes[i];
300
+ // Appending after an unresolved block does not edit any of its alternatives.
301
+ const afterBlock = (edit, insert) => insert.start === insert.end && insert.start === edit.end && edit.start < edit.end && units.slice(edit.start, edit.end).every(unit => textEnvelopeAt(unit));
302
+ if (afterBlock(e, next) || afterBlock(next, e)) return false;
303
+ if (e.start === e.end && next.start <= e.start && e.start <= next.end) return true;
304
+ if (next.start === next.end && e.start <= next.start && next.start <= e.end) return true;
305
+ return next.start >= e.end && units.slice(e.start, e.end).every(textSpace) &&
306
+ units.slice(e.end, next.start).every(textSpace) && units.slice(next.start, next.end).every(textSpace);
307
+ }))) {
308
+ group.push(changes[i]); end = Math.max(end, changes[i++].end);
309
+ }
310
+ text += units.slice(pos, start).join('');
311
+ const before = units.slice(start, end).join('');
312
+ const version = side => {
313
+ let value = '', at = start;
314
+ for (const e of group.filter(e => e.side === side)) { value += units.slice(at, e.start).join('') + e.text; at = e.end; }
315
+ return value + units.slice(at, end).join('');
316
+ };
317
+ const av = version(0), bv = version(1);
318
+ if (av === bv) text += av;
319
+ else if (av === before) text += bv;
320
+ else if (bv === before) text += av;
321
+ else {
322
+ const tick = end === start + 1 ? textTick(before, av, bv) : null;
323
+ if (tick !== null) text += tick; else conflict(before, av, bv, 'text');
324
+ }
325
+ pos = end;
326
+ }
327
+ text += units.slice(pos).join('');
328
+ }
329
+ }
330
+ return {text, clean: conflicts.length === 0 && !text.includes(TEXT_CONFLICT), conflicts};
331
+ }
332
+
333
+ // A marker is advisory data. The caller supplies its trusted descriptor and current revision.
334
+ export function resolveTextConflict(text, conflict, device) {
335
+ canonicalNote(text);
336
+ if (!conflict || !Number.isInteger(conflict.start) || !Number.isInteger(conflict.end) || conflict.start < 0 || conflict.end < conflict.start || text.slice(conflict.start, conflict.end) !== conflict.block) {
337
+ throw textFault('merge_stale', 'The inspected conflict changed');
338
+ }
339
+ const matches = Number.isInteger(device) ? [conflict.variants?.[device]].filter(Boolean) : conflict.variants?.filter(v => v.device === device) || [];
340
+ const version = matches.length === 1 ? matches[0] : null;
341
+ if (!version) throw textFault('merge_choice', 'Choose one inspected version');
342
+ canonicalNote(version.text);
343
+ return text.slice(0, conflict.start) + version.text + text.slice(conflict.end);
344
+ }
345
+
@@ -0,0 +1,9 @@
1
+ // SPDX-License-Identifier: MIT
2
+ import {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId} from './ledger/format.mjs';
3
+ export {exportLedger, replaceLedgerText, readLedger, textRoot, rootAfter, historyEnvelope, agentActorId};
4
+ import {authorship, readAuthorship, ledgerFromAuthorship} from './ledger/authorship.mjs';
5
+ export {authorship, readAuthorship, ledgerFromAuthorship};
6
+ import {merge, transportInterval} from './ledger/merge.mjs';
7
+ export {merge, transportInterval};
8
+ import {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord} from './ledger/journal-records.mjs';
9
+ export {_rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord};
@@ -0,0 +1,7 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // The public renderer door; each factory binds explicit host ports once per document realm.
3
+ export {createRenderer} from './render.mjs';
4
+ export {createMarkdownRenderer} from './render-markdown.mjs';
5
+ export {createRenderStyles} from './render-styles.mjs';
6
+ export {createRenderSanitizer} from './render-sanitize.mjs';
7
+ export {createPrintRenderer} from './render-print.mjs';