rapier-html 1.2.7 → 1.2.9

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.
@@ -5,14 +5,10 @@ const _RAPIER_TRANSACTION_ACTOR_LIMIT = 160;
5
5
  const _RAPIER_TRANSACTION_OPERATION_LIMIT = 96;
6
6
  const _RAPIER_TRANSACTION_REQUEST_LIMIT = 160;
7
7
 
8
- /* Validate every row, not only the final string: a half-pair replacement can produce a whole
9
- character while leaving unencodable fragments in the ledger; two invalid rows can also repair
10
- each other. Refusal is atomic. Pre-existing damage elsewhere stays untouched and editable. */
11
8
  function _rapierTransformSplices(text, splices, inverse = false) {
12
9
  try {
13
10
  if (!Array.isArray(splices)) return null;
14
11
  let value = String(text == null ? '' : text);
15
- // In Unicode mode a complete pair is one scalar, outside the surrogate range.
16
12
  const whole = text => typeof text === 'string' && !/[\uD800-\uDFFF]/u.test(text);
17
13
  const splitsPair = at => at > 0 && at < value.length &&
18
14
  (value.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
@@ -81,7 +77,6 @@ function _rapierTransformMetadata(value, effect, inverse = false) {
81
77
  return next;
82
78
  }
83
79
 
84
- // An origin binds transported placement to exact authored rows and their causal basis.
85
80
  function _rapierValidAuthored(origin) {
86
81
  if (!_rapierPlainLedgerData(origin) || !origin || Array.isArray(origin) ||
87
82
  Object.keys(origin).length !== 3 || !Array.isArray(origin.basis) ||
@@ -95,9 +90,6 @@ function _rapierValidAuthored(origin) {
95
90
  !/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted));
96
91
  }
97
92
 
98
- // The lookup is an observation of one call's supplied records. Material rows keep
99
- // their existing borrowed-row API; an inverse owns fresh rows. Navigation only
100
- // follows strictly earlier revisions, including when an unadmitted list is unordered.
101
93
  function _rapierRecordIndex(records) {
102
94
  const byId = new Map(), splices = new Map(), metadata = new Map();
103
95
  for (const record of records) {
@@ -139,8 +131,6 @@ function _rapierRecordSplices(entry, ledger) {
139
131
  } catch (_) { return null; }
140
132
  }
141
133
 
142
- // Source identifies a retained checkpoint even when host revision numbers differ.
143
- // The History Slider compares now → earlier: that answer is the earlier → now walk, inverted.
144
134
  function journalOperations(before, after, journal) {
145
135
  if (typeof before !== 'string' || typeof after !== 'string' || !Array.isArray(journal)) return;
146
136
  if (before === after) return [];
@@ -151,15 +141,34 @@ function journalOperations(before, after, journal) {
151
141
 
152
142
  function journalWalk(before, after, journal) {
153
143
  try {
154
- let text = after;
144
+ const pieces = [after];
155
145
  const lookup = _rapierRecordIndex(journal), groups = [];
146
+ let length = after.length;
147
+ const locate = at => { let index = 0; for (; index < pieces.length - 1 && at >= pieces[index].length; index++) at -= pieces[index].length; return [index, at]; };
148
+ const codeAt = at => { const [index, offset] = locate(at); return pieces[index].charCodeAt(offset); };
149
+ const read = (from, to) => { let out = ''; for (let [index, offset] = locate(from); from < to && index < pieces.length; index++, offset = 0) { const part = pieces[index].slice(offset, offset + to - from); out += part; from += part.length; } return out; };
150
+ const cut = at => { const [index, offset] = locate(at); if (offset > 0 && offset < pieces[index].length) pieces.splice(index, 1, pieces[index].slice(0, offset), pieces[index].slice(offset)); };
151
+ const whole = text => typeof text === 'string' && !/[\uD800-\uDFFF]/u.test(text);
152
+ const splitsPair = at => at > 0 && at < length && (codeAt(at - 1) & 0xFC00) === 0xD800 && (codeAt(at) & 0xFC00) === 0xDC00;
156
153
  for (let index = journal.length - 1; index >= 0; index--) {
157
154
  const rows = lookup.splices(journal[index]);
158
- const prior = _rapierTransformSplices(text, rows, true);
159
- if (prior === null) return;
155
+ if (!Array.isArray(rows)) return;
156
+ for (const row of rows.slice().reverse()) {
157
+ if (!row || !Number.isSafeInteger(row.pos) || row.pos < 0 || Object.is(row.pos, -0)) return;
158
+ const pos = row.pos, removed = row.inserted, inserted = row.removed;
159
+ if (!whole(removed) || !whole(inserted) || pos > length || pos + removed.length > length ||
160
+ splitsPair(pos) || splitsPair(pos + removed.length) || read(pos, pos + removed.length) !== removed) return;
161
+ cut(pos); cut(pos + removed.length);
162
+ let [first] = locate(pos), count = 0;
163
+ if (pos === length) first = pieces.length;
164
+ else if (locate(pos)[1] !== 0) first++;
165
+ for (let left = removed.length; left > 0; count++) left -= pieces[first + count].length;
166
+ pieces.splice(first, count, ...(inserted ? [inserted] : []));
167
+ length += inserted.length - removed.length;
168
+ for (let at = pieces.length - 1; at >= 0; at--) if (!pieces[at] && pieces.length > 1) pieces.splice(at, 1);
169
+ }
160
170
  groups.push(rows);
161
- if (prior === before) return groups.reverse().flat().map(({pos, removed, inserted}) => ({pos, removed, inserted}));
162
- text = prior;
171
+ if (length === before.length && pieces.join('') === before) return groups.reverse().flat().map(({pos, removed, inserted}) => ({pos, removed, inserted}));
163
172
  }
164
173
  } catch (_) { return; }
165
174
  }
@@ -167,14 +176,11 @@ function journalWalk(before, after, journal) {
167
176
  function _rapierRecordMetadata(entry, ledger) {
168
177
  try {
169
178
  if (Object.hasOwn(entry, 'metadata')) return _rapierValidMetadataEffect(entry.metadata) ? entry.metadata : undefined;
170
- // An expanded record owns its complete effect; only compact navigation inherits one.
171
179
  if (Array.isArray(entry.splices)) return null;
172
180
  return _rapierRecordIndex(ledger || []).metadata(entry);
173
181
  } catch (_) { return undefined; }
174
182
  }
175
183
 
176
- // A codec replacement owns only one inserted definition and its reference. Its
177
- // source act remains the picture's author; an arbitrary edit cannot claim upkeep.
178
184
  function _rapierPictureFinish(record, original, earlier, lookup) {
179
185
  const tx = record?.transaction, rows = record?.splices;
180
186
  if (tx?.operation !== 'document.finish-picture' || !tx.sourceTransactionId || tx.sourceTransactionIds ||
@@ -221,9 +227,6 @@ function _rapierPictureFinish(record, original, earlier, lookup) {
221
227
  });
222
228
  }
223
229
 
224
- // Every active inverse keeps its original targets suppressed. Independent inverses
225
- // can share a target; withdrawing one restores it only after the last blocker leaves.
226
- // This includes assignments hidden by later choices. No second history is retained.
227
230
  function _rapierWalkHistoryEffects(records, lookup, visit = null, observe = null) {
228
231
  try {
229
232
  if (!Array.isArray(records)) return {ok: false};
@@ -238,9 +241,6 @@ function _rapierWalkHistoryEffects(records, lookup, visit = null, observe = null
238
241
  if (was !== node.active) for (const ref of node.references) pending.push([ref, node.active ? 1 : -1]);
239
242
  }
240
243
  };
241
- // Most admission needs only the final effect graph. A backwards pass proves it
242
- // in linear work; prefix activity is asked only by native Redo, picture custody,
243
- // or a metadata assignment. The explicit stack also bounds long inverse chains.
244
244
  const activity = new Map();
245
245
  const active = id => {
246
246
  if (visit) return nodes.get(id).active;
@@ -258,8 +258,6 @@ function _rapierWalkHistoryEffects(records, lookup, visit = null, observe = null
258
258
  return activity.get(id);
259
259
  };
260
260
  const lastUndo = new Map(), contexts = new Map(), order = [];
261
- // Native Redo names an original act. Its inverse target is the last Undo
262
- // in the author's proved causal view, not an unrelated later arrival.
263
261
  const contextOf = origin => {
264
262
  if (!origin) return null;
265
263
  if (!_rapierValidAuthored(origin)) throw Error('origin');
@@ -338,8 +336,6 @@ function _rapierHistoryEffects(records, visit = null) {
338
336
  return _rapierWalkHistoryEffects(records, null, visit);
339
337
  }
340
338
 
341
- // Every transition constrains the two initial metadata scalars. This proves each
342
- // inverse's result once, before the caller supplies the exact initial metadata.
343
339
  function _rapierMetadataProof(lookup) {
344
340
  const values = {}, required = {}, assignments = {filename: [], docKind: []};
345
341
  const requireInitial = (key, value) => {
@@ -355,7 +351,6 @@ function _rapierMetadataProof(lookup) {
355
351
  values[key] = pair.after;
356
352
  if (!refs.length) assignments[key].push({id: record.transaction.id, value: pair.after});
357
353
  }
358
- // Source-only rows cannot change a previously proved metadata assignment.
359
354
  if (!effect && !refs.length) return;
360
355
  for (const key of ['filename', 'docKind']) {
361
356
  const expected = assignments[key].findLast(row => active(row.id));
@@ -385,7 +380,6 @@ function _rapierMetadataState(initial, records, effects = _rapierHistoryEffects(
385
380
  } catch (_) { return null; }
386
381
  }
387
382
 
388
- // Prove each recorded transition and each inverse's semantic result, not only the final values.
389
383
  function _rapierReplayMetadata(initial, records) {
390
384
  if (!_rapierValidMetadata(initial)) return null;
391
385
  try {
@@ -405,16 +399,9 @@ function _rapierHasHistoryEffect(record, earlier = []) {
405
399
  return _rapierHistoryEffects([...earlier, {...record, transaction: {...tx, id}}]).ok;
406
400
  }
407
401
 
408
- // A cached observation owns every byte-bearing record. Mutable graphs are compared
409
- // through descriptors on every call; only a proved, recursively frozen graph can
410
- // bypass that walk. Per-object weak entries keep one latest copy, not a revision log.
411
402
  const _rapierPrepareHistoryRecords = (() => {
412
403
  const copies = new WeakMap();
413
- // Repeated queries share the latest proved snapshot. A live but inactive input
414
- // does not need to retain its record/effect indexes; immutable copies remain weak.
415
404
  let prepared = new WeakMap();
416
- // Object.freeze does not freeze Map/Set entries. Keep those collections inside
417
- // the preparation and expose only immutable values through reading closures.
418
405
  const view = collection => Object.freeze({size: collection.size, has: key => collection.has(key),
419
406
  ...(collection instanceof Map ? {get: key => collection.get(key)} : {}),
420
407
  keys: () => collection.keys(), values: () => collection.values(), entries: () => collection.entries(),
@@ -456,8 +443,6 @@ const _rapierPrepareHistoryRecords = (() => {
456
443
  frame.previous = previous?.copy;
457
444
  frame.height = 0;
458
445
  if (previous?.frozen) {
459
- // A frozen shell fixes its descriptors, but every mutable child is still
460
- // observed afresh. Fixed scalar fields need no second Unicode/data proof.
461
446
  frame.frozen = frame.immutable = true;
462
447
  frame.descriptors = previous.descriptors; frame.fields = previous.fields;
463
448
  frame.out = null; frame.leaving = true; active.add(value); pending.push(frame);
@@ -467,8 +452,6 @@ const _rapierPrepareHistoryRecords = (() => {
467
452
  }
468
453
  continue;
469
454
  }
470
- // Check frozen-ness before capturing descriptors. A caller freezing a mutable
471
- // input later cannot retroactively certify an earlier view of its properties.
472
455
  frame.frozen = frame.immutable = Object.isFrozen(value);
473
456
  const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
474
457
  if (!array && !plainPrototype(proto)) throw Error('history_data');
@@ -535,12 +518,6 @@ const _rapierPrepareHistoryRecords = (() => {
535
518
  };
536
519
  })();
537
520
 
538
- // A ledger record as the journal and the Notes return validate it below: plain data, nothing
539
- // undefined anywhere in it. A live record could carry a row whose type or order is not yet known (a
540
- // block just inserted, before its first render: `type: undefined`), and the validator would refuse
541
- // the whole ledger for that one field -- silently at boot, where a refused ledger is cleared, and
542
- // as "Rapier could not be reopened: the document history could not be restored" on a return from
543
- // Notes. The copy is made once per record; records are frozen, so it never goes stale.
544
521
  const _rapierJournalRecord = (() => {
545
522
  const copies = new WeakMap();
546
523
  return record => {
@@ -551,10 +528,6 @@ const _rapierJournalRecord = (() => {
551
528
  })();
552
529
 
553
530
  function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, maxDocumentBytes, utf8Length, allowGap = false) {
554
- // Recovery records are data, not executable objects. Inspect descriptors without invoking
555
- // accessors, and use an ancestor set (not a global seen set) so shared acyclic data stays valid.
556
- // Iterative traversal refuses cycles without exhausting the call stack. Proxies/counters that
557
- // throw are refused by the same gate rather than escaping into document recovery.
558
531
  try {
559
532
  if (!_rapierPlainLedgerData(record) || !Array.isArray(earlier)) return false;
560
533
  const own = Array.isArray(record?.splices), lookup = own ? null : _rapierRecordIndex(earlier);
@@ -566,8 +539,6 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
566
539
  } catch (_) { return false; }
567
540
  }
568
541
 
569
- // Both callers proved plain data themselves: the public single-row validator above,
570
- // or the private owned-snapshot preparation. No caller-supplied trusted flag exists.
571
542
  function _rapierCheckLedgerRecord(record, prior, documentAuthority, maxDocumentBytes, utf8Length, allowGap,
572
543
  {splices, metadata, semantic, duplicate}) {
573
544
  try {
@@ -579,8 +550,6 @@ function _rapierCheckLedgerRecord(record, prior, documentAuthority, maxDocumentB
579
550
  if ((!own && !navigation) || (own && record.splices.length > 64) ||
580
551
  (record.transaction?.reverts && record.transaction?.reapplies)) return false;
581
552
  const metadataChanged = Object.values(metadata || {}).some(pair => pair.before !== pair.after);
582
- // An identical concurrent replacement keeps its authored rows and an empty
583
- // physical splice. The origin proof, not this gate, shows the effect is already present.
584
553
  const coincidentWitness = !splices?.length && record.beforeHash === record.afterHash &&
585
554
  record.authored && _rapierValidAuthored(record.authored) && record.authored.splices.some(row => row.removed.length);
586
555
  if (metadata === undefined || (!splices?.length && !metadataChanged && !semantic && !coincidentWitness) ||
@@ -597,7 +566,6 @@ function _rapierCheckLedgerRecord(record, prior, documentAuthority, maxDocumentB
597
566
  const transaction = record.transaction;
598
567
  const nullableText = (value, limit, empty = true) => value === null ||
599
568
  (typeof value === 'string' && value.length <= limit && (empty || value.length > 0));
600
- // Identity fields are bounded individually; durable history has no record-count limit.
601
569
  const optional = (value, valid) => value === undefined || valid(value);
602
570
  const identity = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
603
571
  if (!transaction || typeof transaction.id !== 'string' || !transaction.id.length ||
@@ -1,5 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Paired source placement, shared by live editing and canonical-history proof.
3
2
  import {_rapierTransformSplices, _rapierValidAuthored, _rapierHistoryEffects} from './journal-records.mjs';
4
3
  import {projectInverseSource} from './inverse-source.mjs';
5
4
  import {authoredBasis, authoredPlacement, authoredPlacements} from './authored-origin.mjs';
@@ -25,8 +24,6 @@ function emit(rows, at, remove, insert = '') {
25
24
  } else rows.push({at, remove, insert});
26
25
  }
27
26
 
28
- // Sweep original-source boundaries. Each counter names a coordinate in one input's
29
- // resulting document; concurrent insertions never enter the other input's removals.
30
27
  export function transformPair(left, right, leftFirst) {
31
28
  admitted(left); admitted(right);
32
29
  const points = [...new Set([0, ...left.flatMap(row => [row.at, row.at + row.remove]),
@@ -62,9 +59,6 @@ function applyBatch(text, rows) {
62
59
  return {text: after, splices: exact};
63
60
  }
64
61
 
65
- // Transport one sequential canonical act and every crossed record together. The
66
- // reciprocal path is checked against exact source, rather than only equal end text.
67
- // Canonical transport refuses deleting any authored unit already removed elsewhere.
68
62
  export function transposeRecord(base, splices, id, records) {
69
63
  if (typeof base !== 'string' || !Array.isArray(splices) || typeof id !== 'string' || !Array.isArray(records))
70
64
  throw new RangeError('canonical_transport_invalid');
@@ -75,10 +69,6 @@ export function transposeRecord(base, splices, id, records) {
75
69
  if (next === null) throw new RangeError('canonical_transport_invalid');
76
70
  current = next;
77
71
  }
78
- // A concurrent act that already removed these same authored units and wrote the
79
- // same replacement is one physical edit. This act stays in history with no second
80
- // deletion. Pure insertions are never collapsed: each one is its own unit. An
81
- // inverse of that act means the replacement is not still in force.
82
72
  const sameRows = (left, right) => left.length === right.length && left.every((row, index) =>
83
73
  row.pos === right[index].pos && row.removed === right[index].removed && row.inserted === right[index].inserted);
84
74
  const matched = crossed.filter(record => sameRows(record.splices, splices));
@@ -122,8 +112,6 @@ export function transposeRecord(base, splices, id, records) {
122
112
  return {splices: moved, sourceIndices, against: crossed, text: current};
123
113
  }
124
114
 
125
- // Hosted and editor callers already own a reciprocal transport log. Convert only
126
- // its admitted exact source to records, then prove canonical-unit preservation.
127
115
  export function transposeSource(base, splices, client, log) {
128
116
  let source = base;
129
117
  const records = log.map(entry => {
@@ -135,8 +123,6 @@ export function transposeSource(base, splices, client, log) {
135
123
 
136
124
  export {authoredBasis, authoredPlacement, authoredPlacements};
137
125
 
138
- // Reconstruct an authored view from the same records, then prove its exact physical
139
- // placement. Equality of resulting text alone cannot prove a repeated-text anchor.
140
126
  function authoredReplay(base, records, append = null) {
141
127
  const origins = new Map(), byId = new Map(), originalOrder = [], cache = new Map(), frontier = new Set();
142
128
  const effects = _rapierHistoryEffects(append ? [...records, append] : records);
@@ -156,8 +142,6 @@ function authoredReplay(base, records, append = null) {
156
142
  if (!result.ok) throw new RangeError('canonical_origin_invalid');
157
143
  return result;
158
144
  };
159
- // Only immutable text digests are reused, within this one replay. Every
160
- // authored-source comparison and physical placement proof still runs.
161
145
  const textHashes = new Map();
162
146
  const hashText = text => {
163
147
  if (typeof text !== 'string') return sha256(text);
@@ -166,7 +150,6 @@ function authoredReplay(base, records, append = null) {
166
150
  return digest;
167
151
  };
168
152
  const same = (a, b) => canonicalJSON(a) === canonicalJSON(b);
169
- // Only owner-created ordered views of already admitted string IDs use this.
170
153
  const sameIds = (a, b) => {
171
154
  if (a.length !== b.length) return false;
172
155
  for (let index = 0; index < a.length; index++) if (a[index] !== b[index]) return false;
@@ -192,8 +175,6 @@ function authoredReplay(base, records, append = null) {
192
175
  })()))) throw new RangeError('canonical_origin_invalid');
193
176
  const refs = effects.references.get(id) || [];
194
177
  if (refs.some(id => !seen.has(id))) throw new RangeError('canonical_origin_invalid');
195
- // An explicit origin can prove the same linear placement as an implicit one.
196
- // Preserve the origin, but do not reconstruct every growing prefix to prove it again.
197
178
  if (linearPrefix === originalOrder.length && same(origin.basis, [...frontier].sort()) &&
198
179
  same(origin.splices, record.splices) && (!Object.hasOwn(record, 'authored') || origin.source === sha256(source)))
199
180
  linearPrefix++;
@@ -212,7 +193,6 @@ function authoredReplay(base, records, append = null) {
212
193
  const key = coverKey(origin);
213
194
  if (key) coverCounts.set(key, (coverCounts.get(key) || 0) + 1);
214
195
  }
215
- // Two inverses can name different acts and still withdraw one physical replacement.
216
196
  const sharesCover = id => (effects.references.get(id) || []).some(ref => {
217
197
  const key = coverKey(origins.get(ref));
218
198
  return !!key && coverCounts.get(key) > 1;
@@ -250,14 +230,9 @@ function authoredReplay(base, records, append = null) {
250
230
  return closure;
251
231
  };
252
232
  const canonicalOrder = ordered(originalOrder);
253
- // Only the three immediate fresh-spread call sites use this entry. Nothing
254
- // retains allocation trust after an array has crossed an observable method.
255
233
  const literalKey = order => {
256
- // An input iterator can supply arbitrary values. Preserve the whole-array
257
- // depth/refusal path before any per-string encoding in that case.
258
234
  for (let index = 0; index < order.length; index++)
259
235
  if (typeof order[index] !== 'string') return canonicalJSON(order);
260
- // Keep each canonical primitive encoder call and its serializer behavior.
261
236
  return '[' + Array.from({length: order.length}, (_, index) => canonicalJSON(order[index])).join(',') + ']';
262
237
  };
263
238
  const materialize = (order, rawSubmitted = false) => materializeKey(order, canonicalJSON(order), rawSubmitted);
@@ -266,14 +241,10 @@ function authoredReplay(base, records, append = null) {
266
241
  if (cache.has(key)) return cache.get(key);
267
242
  let prefixEnds = null;
268
243
  const prefixKey = length => {
269
- // The original submitted array keeps its existing slice/species/data gate.
270
- // Only this module's own order allocations use the validated key string.
271
244
  if (rawSubmitted) return canonicalJSON(order.slice(0, length));
272
245
  if (!prefixEnds) {
273
246
  prefixEnds = [1];
274
247
  let quoted = false, depth = 0;
275
- // Scan canonical JSON only: an escaped code unit cannot end a string,
276
- // and commas inside strings or nested values cannot split this array.
277
248
  for (let at = 1; at < key.length - 1; at++) {
278
249
  const code = key.charCodeAt(at);
279
250
  if (quoted) {
@@ -290,11 +261,7 @@ function authoredReplay(base, records, append = null) {
290
261
  };
291
262
  let placed = [], ids = new Set(), sourceIndices = new Map(), text = base;
292
263
  if (!order.length) { const empty = {text, records: [], sourceIndices}; cache.set(key, empty); return empty; }
293
- // A proved linear prefix already is its authored view. Replay it once;
294
- // reconstructing every growing prefix adds no evidence.
295
264
  let prefix = 0;
296
- // Resume only an exact ordered prefix already proved in this replay. Copy its
297
- // mutable containers before extending it; transported rows retain their placement.
298
265
  for (let length = order.length - 1; length > 0; length--) {
299
266
  const prior = cache.get(prefixKey(length));
300
267
  if (!prior) continue;
@@ -314,8 +281,6 @@ function authoredReplay(base, records, append = null) {
314
281
  throw new RangeError('canonical_origin_invalid');
315
282
  const closure = closureOf(origin.basis);
316
283
  if (closure.size === ids.size && [...closure].every(prior => ids.has(prior))) {
317
- // The current view already is the complete authored context. There are no
318
- // independent rows to cross, even when earlier acts were concurrent.
319
284
  if (hashText(text) !== origin.source) throw new RangeError('canonical_origin_invalid');
320
285
  const next = _rapierTransformSplices(text, origin.splices);
321
286
  if (next === null) throw new RangeError('canonical_origin_invalid');
@@ -326,8 +291,6 @@ function authoredReplay(base, records, append = null) {
326
291
  }
327
292
  const basisOrder = canonicalOrder.filter(prior => closure.has(prior)), basis = materialize(basisOrder);
328
293
  if (hashText(basis.text) !== origin.source) throw new RangeError('canonical_origin_invalid');
329
- // Reconstruct causal context before crossing it. Physical gaps may already
330
- // have collapsed under a deletion; they cannot choose concurrent ordering.
331
294
  const contextOrder = canonicalOrder.filter(prior => ids.has(prior)), context = sameIds(contextOrder, [...ids]) ? {text, records: placed} : materialize(contextOrder);
332
295
  if (context.text !== text) throw new RangeError('canonical_origin_invalid');
333
296
  const alignedOrder = [...basisOrder, ...contextOrder.filter(prior => !closure.has(prior))];
@@ -336,10 +299,6 @@ function authoredReplay(base, records, append = null) {
336
299
  const crossed = aligned.records.slice(basisOrder.length);
337
300
  let moved;
338
301
  if (effects.references.get(id)?.length && (sharedInverses || sharesCover(id))) {
339
- // Each inverse retains its own targets. First prove the exact intention
340
- // in its causal source, then place that choice in the accepted effect
341
- // graph. Another inverse may already suppress the same original bytes,
342
- // including when the two inverses name different authors of one replacement.
343
302
  const authored = projectInverse(basis.text, basis.records, id);
344
303
  if (!same(origin.splices, authored.splices) ||
345
304
  _rapierTransformSplices(basis.text, origin.splices) !== authored.after)
@@ -357,7 +316,6 @@ function authoredReplay(base, records, append = null) {
357
316
  const physicalOrder = append ? originalOrder.slice(0, -1) : originalOrder, physical = materialize(physicalOrder);
358
317
  if (physical.text !== source || physical.records.some((row, index) => !same(row.splices, records[index].splices)))
359
318
  throw new RangeError('canonical_origin_invalid');
360
- // Raw submission is selected here by a private call site, never an input flag.
361
319
  return {source, origins, originalOrder, materialize, materializeLiteral,
362
320
  materializeSubmitted: order => materialize(order, true), ordered};
363
321
  }
@@ -365,14 +323,11 @@ function authoredReplay(base, records, append = null) {
365
323
  export function authoredHistory(base, records, selected = null) {
366
324
  const replay = authoredReplay(base, records);
367
325
  if (!selected) return {text: replay.source, records, origins: replay.origins};
368
- // Targets follow independent work only in this transient Undo projection.
369
326
  const projected = replay.materialize(replay.ordered(replay.originalOrder, selected));
370
327
  if (projected.text !== replay.source) throw new RangeError('canonical_origin_invalid');
371
328
  return {...projected, origins: replay.origins};
372
329
  }
373
330
 
374
- // Semantic annotations use the original splice coordinates, even when an accepted
375
- // physical placement split those rows. The same replay proves the causal basis first.
376
331
  export function authoredSourceReader(base, records) {
377
332
  const replay = authoredReplay(base, records);
378
333
  return actId => {
@@ -390,8 +345,6 @@ export function authoredSourceReader(base, records) {
390
345
  };
391
346
  }
392
347
 
393
- // The original view and the accepted view are both reconstructed from the same
394
- // immutable acts. The reciprocal rows are an exact ACK, not another history.
395
348
  export function transposeAuthored(base, records, incoming, submitted) {
396
349
  if (!incoming.authored || !Array.isArray(submitted)) throw new RangeError('canonical_origin_invalid');
397
350
  const replay = authoredReplay(base, records, incoming), id = incoming.transaction.id;
@@ -0,0 +1,59 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // rapier-html: Markdown to one offline HTML file that carries its exact source, and back.
3
+ export type Part = 'drawing' | 'math' | 'mermaid';
4
+ export type DocKind = 'markdown' | 'text' | 'code';
5
+ export interface WrapOptions {
6
+ kind?: DocKind;
7
+ /** The view the page opens on, with the document behind it. */
8
+ view?: 'draw' | 'notes';
9
+ /** An SVG drawing carried beside the document, opened on Draw. */
10
+ drawing?: string;
11
+ drawingName?: string;
12
+ /** The original text (or a page carrying it); the page opens showing the diff. */
13
+ base?: string;
14
+ baseName?: string;
15
+ /** With base: the name and time shown on the diff. */
16
+ by?: string;
17
+ at?: string;
18
+ /** A one-use return URL and its expiry, both from the door's show {target: "return"}. */
19
+ return?: string;
20
+ return_expires_at?: string;
21
+ /** A rapier-ledger/2 history and its authorship projection, verified against the text. */
22
+ ledger?: unknown;
23
+ authorship?: unknown;
24
+ }
25
+ export interface Unwrapped {
26
+ /** The exact carried source, or null for a page without one. */
27
+ text: string | null;
28
+ name: string | null;
29
+ kind: DocKind | null;
30
+ view: 'draw' | 'notes' | null;
31
+ drawing: string | null;
32
+ drawingName: string | null;
33
+ base: string | null;
34
+ baseName: string | null;
35
+ baseSha256: string | null;
36
+ baseRevision: string | null;
37
+ by: string | null;
38
+ at: string | null;
39
+ return: string | null;
40
+ return_expires_at: string | null;
41
+ ledger: unknown;
42
+ authorship: unknown;
43
+ comparisonBase: unknown;
44
+ html: string;
45
+ }
46
+ /** Markdown to a page in one call: the editor this package carries, cut to what the document displays with. */
47
+ export function writePage(text: string, name?: string, options?: WrapOptions & {include?: Part[]}): Promise<string>;
48
+ /** Puts the document into a Rapier page you supply (for example a carry() of the packaged page). */
49
+ export function wrap(pageHtml: string, text: string, name?: string, options?: WrapOptions): string;
50
+ /** Reads the exact document back out of a page. */
51
+ export function unwrap(pageHtml: string): Unwrapped;
52
+ /** The page with only the named parts inline. */
53
+ export function carry(pageHtml: string, include?: Part[]): string;
54
+ /** The parts a document displays with: math, mermaid. */
55
+ export function portableNeeds(text: string, options?: {base?: string; flowchart?: (source: string) => boolean}): Part[];
56
+ export function encodeCarried(text: string): string;
57
+ export function decodeCarried(text: string): string;
58
+ /** The command line: rapier-html <document.md> [out.html] [options]. Resolves the written path, or null for --help. */
59
+ export function main(argv: string[], pagesDir: string, options?: {log?: (line: string) => void}): Promise<string | null>;
package/dist/page.mjs CHANGED
@@ -4,7 +4,13 @@ import {basename, dirname, join} from 'node:path';
4
4
  import {wrap, carry, portableNeeds} from './wrap.mjs';
5
5
  import {returnAddress, returnExpiresAt} from './return-address.mjs';
6
6
  export {wrap, unwrap, carry, portableNeeds, encodeCarried, decodeCarried} from './wrap.mjs';
7
- // Unknown options, a bare --drawing or --compare and a third name are refused.
7
+
8
+ let carried = null;
9
+ const carriedPage = () => carried ??= readFile(new URL('./rapier.html', import.meta.url), 'utf8');
10
+ export async function writePage(text, name = 'document.md', {include, ...options} = {}) {
11
+ const carries = include ?? [...(options.view === 'draw' || options.drawing != null ? ['drawing'] : []), ...portableNeeds(String(text), {base: options.base})];
12
+ return wrap(carry(await carriedPage(), carries), text, name, options);
13
+ }
8
14
  const USAGE = 'usage: rapier-html <document.md> [out.html] [--include draw,math,mermaid] [--view draw] [--drawing sketch.svg] [--compare original.md [--by <your name>]] [--return <url> --return-expires-at <timestamp>]\n' +
9
15
  ' writes <document>.rapier.html beside the file: Rapier with the document inside it, one file, offline.\n' +
10
16
  ' It never overwrites: an output that already exists, the document itself included, is refused.\n' +
@@ -18,7 +24,6 @@ const USAGE = 'usage: rapier-html <document.md> [out.html] [--include draw,math,
18
24
  ' --return-expires-at carries the same mint\'s expiry; Share offers Save once the return expires\n' +
19
25
  ' -- everything after it is a filename, even one that starts with a dash\n' +
20
26
  ' --help this text';
21
- // Damaged UTF-8 is refused, never carried as U+FFFD; a BOM is carried.
22
27
  const text = async path => { try { return new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(await readFile(path)); } catch (error) { throw error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA' ? new Error(path + ' is not valid UTF-8') : error; } };
23
28
 
24
29
  export async function main(argv, pagesDir, {log = console.log} = {}) {
@@ -73,7 +78,6 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
73
78
  if (view) wrapOptions.view = view;
74
79
  if (address) wrapOptions.return = address;
75
80
  if (expiry) wrapOptions.return_expires_at = expiry;
76
- // Always a new file; 'wx' refuses any existing path, links included.
77
81
  const carries = include || [...(view === 'draw' || drawingPath ? ['drawing'] : []), ...portableNeeds(source, {base: wrapOptions.base})];
78
82
  const html = wrap(carry(page, carries), source, name, wrapOptions);
79
83
  try { await writeFile(out, html, {flag: 'wx'}); }