rapier-markdown-kit 1.2.33 → 1.2.40

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 (45) hide show
  1. package/README.md +15 -9
  2. package/dist/agent/vendor/pretext/NOTICE +2 -2
  3. package/dist/agent/vendor/pretext/SOURCE.json +62 -78
  4. package/dist/agent/vendor/pretext/analysis.js +24 -6
  5. package/dist/agent/vendor/pretext/gecko-line-breaks.js +1 -1
  6. package/dist/agent/vendor/pretext/generated/engine-break-data.js +1 -1
  7. package/dist/agent/vendor/pretext/generated/webkit-generic-families.js +1 -1
  8. package/dist/agent/vendor/pretext/graphemes.js +1 -1
  9. package/dist/agent/vendor/pretext/han-kerning.js +1 -1
  10. package/dist/agent/vendor/pretext/layout.js +14 -11
  11. package/dist/agent/vendor/pretext/line-break.js +170 -195
  12. package/dist/agent/vendor/pretext/line-breaks.js +1 -7
  13. package/dist/agent/vendor/pretext/line-text.js +2 -2
  14. package/dist/agent/vendor/pretext/measurement.js +454 -113
  15. package/dist/agent/vendor/pretext/prepare.js +164 -244
  16. package/dist/agent/vendor/pretext/rich-inline.js +57 -37
  17. package/dist/agent/will.mjs +73 -0
  18. package/dist/kit/ledger/data.mjs +2 -2
  19. package/dist/kit/ledger/format.mjs +86 -42
  20. package/dist/kit/ledger/hash.mjs +43 -2
  21. package/dist/kit/ledger/history.mjs +6 -0
  22. package/dist/kit/ledger/inverse-source.mjs +6 -2
  23. package/dist/kit/ledger/journal-records.mjs +4 -2
  24. package/dist/kit/ledger/merge.mjs +3 -17
  25. package/dist/kit/ledger/text-merge.mjs +28 -2
  26. package/dist/kit/rapier-ledger.mjs +2 -2
  27. package/dist/kit/render-markdown.mjs +133 -10
  28. package/dist/kit/render-print.mjs +3 -3
  29. package/dist/kit/render-sanitize.mjs +66 -23
  30. package/dist/kit/render.mjs +10 -10
  31. package/dist/kit/wiki.mjs +34 -0
  32. package/dist/kit/will.d.mts +3 -0
  33. package/dist/layout/hyphenation.mjs +6 -4
  34. package/dist/layout/line-plan.mjs +96 -0
  35. package/dist/layout/model.mjs +2 -1
  36. package/dist/layout/paragraph-fit.mjs +4 -4
  37. package/dist/layout/prepared-paragraph.mjs +19 -2
  38. package/dist/layout/typography.mjs +57 -63
  39. package/dist/spec/document-fonts.mjs +1 -0
  40. package/dist/spec/document-settings.mjs +1 -0
  41. package/dist/spec/html-blocks.mjs +10 -7
  42. package/dist/spec/md-assets.mjs +52 -12
  43. package/package.json +1 -1
  44. package/style.css +25 -23
  45. package/dist/agent/vendor/pretext/entry-geometry.js +0 -183
@@ -1,9 +1,10 @@
1
- /*! Pretext Unreleased (bac1774c0) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
1
+ /*! Pretext 1.0.0 + main (0d102a5cd) | MIT; BSD/Unicode/ICU notices in LICENSE | Copyright (c) 2026 Pretext contributors */
2
2
 
3
3
  import {
4
4
  alignToSource,
5
5
  analyzeText,
6
6
  isCollapsibleSpaceCode,
7
+ DROPPED,
7
8
  HARD_BREAK,
8
9
  KIND_BITS,
9
10
  OBJECT,
@@ -20,7 +21,6 @@ import {
20
21
 
21
22
 
22
23
  } from './analysis.js'
23
- import { getSegmentEntryWidth, } from './entry-geometry.js'
24
24
  import { buildLineTextFromRange, buildRangeText, getGraphemeEnds, } from './line-text.js'
25
25
  import {
26
26
  getItemTabAdvance,
@@ -166,7 +166,8 @@ const PRESERVED_WHITE_SPACE = 1 << PRESERVED_SPACE | 1 << TAB
166
166
 
167
167
 
168
168
 
169
-
169
+
170
+
170
171
 
171
172
 
172
173
 
@@ -300,8 +301,6 @@ export function prepareRichInline(items ,
300
301
  // Firefox 156 and Safari 27 prepare CJK styled paragraphs again up to 8% faster with the lists
301
302
  // made whole than with each filled by a call and a push a segment (RESEARCH.md, Keeping Work
302
303
  // Bounded, JavaScript Engines, under A paragraph's sparse lists made whole).
303
- let entryGeometry = null
304
- let lineStartProhibitions = null
305
304
  let breakableLineStartExtras = null
306
305
  let lineStartExtras = null
307
306
  let lineEndTrims = null
@@ -531,8 +530,6 @@ export function prepareRichInline(items ,
531
530
  }
532
531
  const units = getSourceUnits(offsets, normalizedStart, textEnd, starts[index] )
533
532
  if (units !== null) (sourceUnits ??= new Array (flags.length).fill(null))[at] = units
534
- if (sub.entryGeometry !== null && sub.entryGeometry[s] !== null) (entryGeometry ??= new Array (flags.length).fill(null))[at] = sub.entryGeometry[s]
535
- if (sub.lineStartProhibitions !== null && sub.lineStartProhibitions[s] !== null) (lineStartProhibitions ??= new Array (flags.length).fill(null))[at] = sub.lineStartProhibitions[s]
536
533
  if (sub.breakableLineStartExtras !== null && sub.breakableLineStartExtras[s] !== null) (breakableLineStartExtras ??= new Array (flags.length).fill(null))[at] = sub.breakableLineStartExtras[s]
537
534
  const startExtra = (sub.lineStartExtras === null ? 0 : sub.lineStartExtras[s] ) + (i > first ? extraWidth : 0)
538
535
  if (startExtra !== 0) (lineStartExtras ??= zeroList(flags.length))[at] = startExtra
@@ -621,7 +618,7 @@ export function prepareRichInline(items ,
621
618
  itemEndHalts,
622
619
  hyphenRooms: discretionaryHyphenContexts !== null && profile.unfitHyphenRetreat === 'reduced-width' ? getHyphenRooms(hyphenWidths, itemSegments, segmentItems) : null,
623
620
  insideExtras, fillExtras, openingEdges,
624
- emptyObjectSpaces: null, emptyObjectReturns: null,
621
+ emptyObjectSpaces: null, emptyObjectReturns: null, hangEnds: null,
625
622
  }
626
623
  // The properties measureAnalysis() gives a text's handle, in its order, then the paragraph's own.
627
624
  const data = {
@@ -630,11 +627,9 @@ export function prepareRichInline(items ,
630
627
  simpleLineWalkFastPath: simple,
631
628
  simpleLineCountFastPath: false,
632
629
  breakableFitAdvances,
633
- entryGeometry,
634
630
  letterSpacing: spacingsDiffer || sharedSpacing === null ? 0 : sharedSpacing,
635
631
  discretionaryHyphenWidth: firstTextItem < 0 ? 0 : hyphenWidths[firstTextItem] ,
636
632
  discretionaryHyphenContexts,
637
- lineStartProhibitions,
638
633
  breakableLineStartExtras,
639
634
  lineStartExtras,
640
635
  lineEndTrims,
@@ -645,6 +640,15 @@ export function prepareRichInline(items ,
645
640
  }
646
641
  data.segments = segments
647
642
  if (hasEmptyObject && profile.emptyAtomicAlwaysFits) setEmptyObjectFacts(segmentData, data, items, itemSegments, segmentItems, source, starts)
643
+ // Each item is a text frame: a zero-width space after preserved spaces of its own item ends
644
+ // the run of them that hangs (ParagraphSegmentData, hangEnds).
645
+ if (preserve && profile.hangsSpacesPerTextFrame && source.includes('\u200B')) {
646
+ for (let i = 1; i < segmentCount; i++) {
647
+ if ((segmentFlags[i] & (KIND_BITS | DROPPED)) === ZERO_WIDTH_BREAK && (segmentFlags[i - 1] & KIND_BITS) === PRESERVED_SPACE && segmentItems[i] === segmentItems[i - 1]) {
648
+ ;(segmentData.hangEnds ??= new Uint8Array(segmentCount))[i] = 1
649
+ }
650
+ }
651
+ }
648
652
 
649
653
  let onlyItem = -1
650
654
  for (let index = 0; index < items.length && !paddedOrObject; index++) {
@@ -894,19 +898,13 @@ function getOpeningFit(flags , from , to , extraWidth
894
898
  // (`startsLine`), the first of them adds what a line that starts with it does
895
899
  // (breakableLineStartExtras), as the walker's line does.
896
900
  function getPartWidth(data , i , from , to , startsLine ) {
897
- const { breakableFitAdvances, breakableLineStartExtras, entryGeometry, letterSpacing, items } = data
901
+ const { breakableFitAdvances, breakableLineStartExtras, letterSpacing, items } = data
898
902
  const advances = breakableFitAdvances[i]
899
903
  if (to < 0) to = advances.length
900
- const entry = from > 0 && entryGeometry !== null ? entryGeometry[i] : null
901
- const entryWidth = entry === null ? null : getSegmentEntryWidth(entry, from, to)
902
904
  let w = 0
903
- if (entryWidth !== null) {
904
- w = entryWidth
905
- } else {
906
- for (let g = from; g < to; g++) w += advances[g] + letterSpacing
907
- const startExtras = startsLine && breakableLineStartExtras !== null ? breakableLineStartExtras[i] : null
908
- if (startExtras !== null) w += startExtras[from]
909
- }
905
+ for (let g = from; g < to; g++) w += advances[g] + letterSpacing
906
+ const startExtras = startsLine && breakableLineStartExtras !== null ? breakableLineStartExtras[i] : null
907
+ if (startExtras !== null) w += startExtras[from]
910
908
  if (items !== undefined && (from > 0 ? items.insideExtras !== null : items.fillExtras !== null)) w += from > 0 ? items.insideExtras [i] : items.fillExtras [i]
911
909
  return w
912
910
  }
@@ -928,7 +926,7 @@ function createOnlyItemLine(
928
926
  endGraphemeIndex ,
929
927
  ) {
930
928
  const { onlyItem } = flow
931
- const ended = endSegmentIndex >= flow.data.segmentFlags.length
929
+ const ended = endSegmentIndex >= flow.data.widths.length
932
930
  return {
933
931
  fragments: [{
934
932
  itemIndex: onlyItem,
@@ -974,7 +972,7 @@ function createLine(
974
972
  const segmentItems = flow.segmentItems
975
973
  const gapSegments = flow.gapSegments
976
974
  const itemCount = itemSegments.length - 1
977
- const endItemIndex = endSegmentIndex >= segmentItems.length ? itemCount : segmentItems[endSegmentIndex]
975
+ const endItemIndex = endSegmentIndex >= widths.length ? itemCount : segmentItems[endSegmentIndex]
978
976
  const end = { itemIndex: endItemIndex, segmentIndex: endSegmentIndex - itemSegments[endItemIndex] , graphemeIndex: endGraphemeIndex }
979
977
  const tabsInAppUnits = getEngineProfile().tabsInAppUnits
980
978
  const itemEndHalts = items.itemEndHalts
@@ -1108,7 +1106,7 @@ function createLine(
1108
1106
  // The paragraph's segment a cursor names, or the segment count for one past the last item.
1109
1107
  function getSegmentIndex(flow , cursor ) {
1110
1108
  const { itemSegments } = flow
1111
- return cursor.itemIndex < itemSegments.length ? itemSegments[cursor.itemIndex] + cursor.segmentIndex : flow.data.segmentFlags.length
1109
+ return cursor.itemIndex < itemSegments.length ? itemSegments[cursor.itemIndex] + cursor.segmentIndex : flow.data.widths.length
1112
1110
  }
1113
1111
 
1114
1112
  export function layoutNextRichInlineLineRange(
@@ -1142,9 +1140,26 @@ function getSourceUnits(offsets , from , to , itemStart
1142
1140
  return units
1143
1141
  }
1144
1142
 
1143
+ // Per frozen handle of a paragraph that is one text's handle, the three lists of where its
1144
+ // segments are in the item's text (InternalPreparedRichInline), found when a line of it is
1145
+ // first materialized. A handle keeps them itself; a frozen one can't take them, so they are
1146
+ // kept beside it, as its segments' grapheme ends are (getGraphemeEnds): no line function writes
1147
+ // to a frozen handle or to anything it holds, so one the app froze, or a copy of one in a store
1148
+ // that freezes its state, materializes as any other. Kept beside every handle, the lookup took
1149
+ // a pass that walks and materializes 3,000 of the chat demo's one-item paragraphs, each asked
1150
+ // once, 35-47% longer with the handles out of the processor's caches and 7-25% with them in,
1151
+ // and 40 of them over and over 1-9% (JavaScriptCore of bun 1.4.2, V8 15.4 and node 23.10's,
1152
+ // SpiderMonkey 156, one build a process on the harness's stand-in Canvas, 2026-10-12). Found
1153
+ // while preparing, the lists took such paragraphs 6-13% longer to prepare and count once in
1154
+ // JavaScriptCore and 12-20% in V8, text seen before, and a third more heap a handle, for lines
1155
+ // a long list mostly never paints; found again at every line, walking and materializing took
1156
+ // 5-9 times as long, and over 100 times in a paragraph of 6,000 units.
1157
+
1158
+ const textSources = new WeakMap ()
1159
+
1145
1160
  // Per segment of a paragraph that is one text's handle, where its text starts and ends in the
1146
1161
  // item's.
1147
- function findSegmentSources(flow ) {
1162
+ function findSegmentSources(flow ) {
1148
1163
  const { segments } = flow.data
1149
1164
  const offsets = alignToSource(flow.text, segments.join(''), getEngineProfile().lineBreakScan)
1150
1165
  const sourceStarts = []
@@ -1156,15 +1171,21 @@ function findSegmentSources(flow ) {
1156
1171
  sourceEnds.push(flow.text.charCodeAt(end - 1) === 0x0D && flow.text.charCodeAt(end) === 0x0A && segments[i] === '\n' ? end + 1 : end)
1157
1172
  sourceUnits.push(getSourceUnits(offsets, at, at + segments[i] .length, 0))
1158
1173
  }
1159
- flow.sourceStarts = sourceStarts
1160
- flow.sourceEnds = sourceEnds
1161
- flow.sourceUnits = sourceUnits
1174
+ const sources = { sourceStarts, sourceEnds, sourceUnits }
1175
+ if (Object.isFrozen(flow)) {
1176
+ textSources.set(flow, sources)
1177
+ } else {
1178
+ flow.sourceStarts = sourceStarts
1179
+ flow.sourceEnds = sourceEnds
1180
+ flow.sourceUnits = sourceUnits
1181
+ }
1182
+ return sources
1162
1183
  }
1163
1184
 
1164
1185
  // Where unit `unit` of a segment's text is in its item's text.
1165
- function getSourceOffset(flow , segmentIndex , unit ) {
1166
- const units = flow.sourceUnits === null ? null : flow.sourceUnits[segmentIndex]
1167
- return units === null ? flow.sourceStarts [segmentIndex] + unit : units[unit]
1186
+ function getSourceOffset(sourceStarts , sourceUnits , segmentIndex , unit ) {
1187
+ const units = sourceUnits === null ? null : sourceUnits[segmentIndex]
1188
+ return units === null ? sourceStarts[segmentIndex] + unit : units[unit]
1168
1189
  }
1169
1190
 
1170
1191
  // Bridge from cheap range walking to full fragment text. Lets callers do
@@ -1176,9 +1197,8 @@ export function materializeRichInlineLineRange(
1176
1197
  ) {
1177
1198
  const flow = getInternalPreparedRichInline(prepared)
1178
1199
  const { data, itemSegments } = flow
1179
- if (flow.sourceStarts === null) findSegmentSources(flow)
1180
- const sourceStarts = flow.sourceStarts
1181
- const sourceEnds = flow.sourceEnds
1200
+ let { sourceStarts, sourceEnds, sourceUnits } = flow
1201
+ if (sourceStarts === null) ({ sourceStarts, sourceEnds, sourceUnits } = textSources.get(flow) ?? findSegmentSources(flow))
1182
1202
  const lineEndSegmentIndex = getSegmentIndex(flow, line.end)
1183
1203
  const fragments = []
1184
1204
 
@@ -1194,11 +1214,11 @@ export function materializeRichInlineLineRange(
1194
1214
  const endGraphemeIndex = endSegmentIndex < itemEnd ? fragment.end.graphemeIndex : 0
1195
1215
  // Only the fragment a line ends with shows the hyphen of a soft hyphen it ends at.
1196
1216
  const endsLine = i === line.fragments.length - 1 && endSegmentIndex === lineEndSegmentIndex && endGraphemeIndex === line.end.graphemeIndex
1197
- let sourceStart = startSegmentIndex < itemEnd ? sourceStarts[startSegmentIndex] : startSegmentIndex > first ? sourceEnds[startSegmentIndex - 1] : 0
1198
- if (startGraphemeIndex > 0 && startSegmentIndex < itemEnd) sourceStart = getSourceOffset(flow, startSegmentIndex, getGraphemeEnds(data, startSegmentIndex)[startGraphemeIndex - 1] )
1217
+ let sourceStart = startSegmentIndex < itemEnd ? sourceStarts[startSegmentIndex] : startSegmentIndex > first ? sourceEnds [startSegmentIndex - 1] : 0
1218
+ if (startGraphemeIndex > 0 && startSegmentIndex < itemEnd) sourceStart = getSourceOffset(sourceStarts, sourceUnits, startSegmentIndex, getGraphemeEnds(data, startSegmentIndex)[startGraphemeIndex - 1] )
1199
1219
  let sourceEnd = sourceStart
1200
- if (endGraphemeIndex > 0) sourceEnd = getSourceOffset(flow, endSegmentIndex, getGraphemeEnds(data, endSegmentIndex)[endGraphemeIndex - 1] - 1) + 1
1201
- else if (endSegmentIndex > startSegmentIndex) sourceEnd = sourceEnds[endSegmentIndex - 1]
1220
+ if (endGraphemeIndex > 0) sourceEnd = getSourceOffset(sourceStarts, sourceUnits, endSegmentIndex, getGraphemeEnds(data, endSegmentIndex)[endGraphemeIndex - 1] - 1) + 1
1221
+ else if (endSegmentIndex > startSegmentIndex) sourceEnd = sourceEnds [endSegmentIndex - 1]
1202
1222
  fragments.push({
1203
1223
  itemIndex: fragment.itemIndex,
1204
1224
  text: endsLine ? buildLineTextFromRange(data, startSegmentIndex, startGraphemeIndex, endSegmentIndex, endGraphemeIndex)
@@ -162,5 +162,78 @@ function _rapierWillStripOneTerminator(text) {
162
162
  text.charCodeAt(end - 1), end >= 2 ? text.charCodeAt(end - 2) : -1)) : text;
163
163
  }
164
164
 
165
+ export function willOffsetBefore(rows, offset) {
166
+ for (let index = rows.length - 1; index >= 0 && offset >= 0; index--) {
167
+ const {pos, removed, inserted} = rows[index];
168
+ if (offset >= pos) offset = offset >= pos + inserted.length ? offset - inserted.length + removed.length : -1;
169
+ }
170
+ return offset;
171
+ }
172
+
173
+ function _rapierWillRestores(restores, region, splices, text) {
174
+ if (!Array.isArray(restores) || region.law !== 'append') return null;
175
+ const reaching = splices.filter(row => row.pos <= region.end && row.pos + row.removed.length >= region.start &&
176
+ (row.removed || row.inserted)).toSorted((a, b) => a.pos - b.pos);
177
+ if (!reaching.length || reaching.some((row, index) => row.pos < region.start || row.pos + row.removed.length > region.end ||
178
+ (index && row.pos !== reaching[index - 1].pos + reaching[index - 1].removed.length) || !restores.includes(row)) ||
179
+ !/^(?:\r\n|\r|\n)?$/.test(text.slice(reaching.at(-1).pos + reaching.at(-1).removed.length - region.start))) return null;
180
+ return reaching;
181
+ }
182
+
183
+ export function willTransition(beforeText, afterText, splices, markerSplices = splices, {restores = null, restored = null} = {}) {
184
+ const before = _rapierWillParse(beforeText), after = _rapierWillParse(afterText);
185
+ if (!before.present && !after.present) return null;
186
+ const bounds = region => ({start: region.start, end: region.end});
187
+ const faults = will => will.faults.slice(0, 4).map(({mode, line}) => ({mode, line}));
188
+ if (before.faults.length) return {law: 'keep', rule: 'before_faulted', faults: faults(before), start: 0, end: beforeText.length};
189
+ for (const region of before.regions) {
190
+ const markers = [[region.openerStart, region.openerEnd], [region.closerStart, region.closerEnd]];
191
+ for (let index = 0; index < splices.length; index++) {
192
+ const row = splices[index], end = row.pos + row.removed.length;
193
+ for (const marker of markers) {
194
+ if (row.removed.length ? row.pos < marker[1] && marker[0] < end : row.pos > marker[0] && row.pos < marker[1])
195
+ return {law: region.law, rule: 'marker_span_touched', editIndex: index, region: region.index, ...bounds(region)};
196
+ if (end <= marker[0]) { const shift = row.inserted.length - row.removed.length; marker[0] += shift; marker[1] += shift; }
197
+ }
198
+ }
199
+ }
200
+ if (after.faults.length) return {law: 'keep', rule: 'result_faulted', faults: faults(after), start: 0, end: beforeText.length};
201
+ if (before.markers.length !== after.markers.length || before.markers.some((marker, index) => {
202
+ const next = after.markers[index];
203
+ return marker.kind !== next.kind || beforeText.slice(marker.start, marker.end) !== afterText.slice(next.start, next.end) ||
204
+ willOffsetBefore(markerSplices, next.start) !== marker.start || willOffsetBefore(markerSplices, next.end - 1) !== marker.end - 1;
205
+ })) return {law: 'keep', rule: 'marker_sequence_mismatch', start: 0, end: beforeText.length};
206
+ for (const region of before.regions) {
207
+ const was = beforeText.slice(region.start, region.end), next = after.regions[region.index], now = afterText.slice(next.start, next.end);
208
+ if (region.law === 'keep' && was !== now)
209
+ return {law: region.law, rule: 'law_violated', region: region.index, ...bounds(region)};
210
+ if (region.law !== 'append' || _rapierWillStripOneTerminator(now).startsWith(_rapierWillStripOneTerminator(was))) continue;
211
+ const reaching = _rapierWillRestores(restores, region, splices, was);
212
+ if (!reaching) return {law: region.law, rule: 'law_violated', region: region.index, ...bounds(region)};
213
+ restored?.set(region.index, reaching);
214
+ }
215
+ return null;
216
+ }
217
+
218
+ export function evaluateWill(beforeText, splices) {
219
+ const invalid = rule => ({outcome: 'invalid', reason: 'precondition', rule});
220
+ if (typeof beforeText !== 'string' || !beforeText.isWellFormed()) return invalid('invalid_unicode');
221
+ if (!Array.isArray(splices)) return invalid('malformed_splice');
222
+ const boundary = (text, at) => !(at > 0 && at < text.length &&
223
+ (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00);
224
+ let afterText = beforeText;
225
+ for (const row of splices) {
226
+ if (!row || !Number.isSafeInteger(row.pos) || row.pos < 0 || Object.is(row.pos, -0) ||
227
+ typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
228
+ !row.removed.isWellFormed() || !row.inserted.isWellFormed()) return invalid('malformed_splice');
229
+ const end = row.pos + row.removed.length;
230
+ if (end > afterText.length || !boundary(afterText, row.pos) || !boundary(afterText, end)) return invalid('out_of_range_splice');
231
+ if (afterText.slice(row.pos, end) !== row.removed) return invalid('source_mismatch');
232
+ afterText = afterText.slice(0, row.pos) + row.inserted + afterText.slice(end);
233
+ }
234
+ const refusal = splices.length && willTransition(beforeText, afterText, splices);
235
+ return refusal ? {outcome: 'refused', reason: 'document_law', ...refusal} : {outcome: 'applied'};
236
+ }
237
+
165
238
 
166
239
  export { hasWillMarkers, _rapierWillParse as parseWill, _rapierWillMarkerOf as willMarkerOf, _rapierWillRegionsIn as willRegionsIn, _rapierWillTouchesMarker as willTouchesMarker, _rapierWillGovern as willGovern, _rapierWillIntentOf as willIntentOf, _rapierWillStripOneTerminator as stripOneTerminator, _rapierWillTerminatorWidth as terminatorWidth };
@@ -17,7 +17,7 @@ export function fields(value, depth, active, sorted = true) {
17
17
  }
18
18
  export function scalar(value) {
19
19
  if (value === null || typeof value === 'boolean') return true;
20
- if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
20
+ if (typeof value === 'string') { if (!value.isWellFormed()) throw fault('unpaired surrogate'); return true; }
21
21
  return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
22
22
  }
23
23
  export function plainData(value) {
@@ -39,7 +39,7 @@ function* quoted(text, whole = true) {
39
39
  let end = Math.min(text.length, at + 16384);
40
40
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
41
41
  const part = text.slice(at, end);
42
- if (whole && /[\uD800-\uDFFF]/u.test(part)) throw fault('unpaired surrogate');
42
+ if (whole && !part.isWellFormed()) throw fault('unpaired surrogate');
43
43
  yield JSON.stringify(part).slice(1, -1); at = end;
44
44
  }
45
45
  yield '"';
@@ -3,10 +3,12 @@ import {_rapierPrepareHistoryRecords, _rapierValidMetadata, _rapierTransformMeta
3
3
  import {pictureHistorySplices} from './history.mjs';
4
4
  import {HistorySource} from './source.mjs';
5
5
  import {authoredHistory} from './transport.mjs';
6
- import {textRoot, rootAfter, sha256, sha256Parts, createSHA256} from './hash.mjs';
6
+ import {textRoot, rootAfter, sha256, sha256PartsAsync, createSHA256} from './hash.mjs';
7
7
  import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
8
+ import {diff} from '../../packages/rapier-diff/diff.mjs';
9
+ import {minimalHistorySplice} from './inverse-source.mjs';
10
+ import {evaluateWill} from '../../agent/will.mjs';
8
11
  export {textRoot, rootAfter};
9
- const digest = value => sha256Parts(canonicalParts(value));
10
12
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
11
13
  function* checkedRecords(ledger, prepared) {
12
14
  if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
@@ -26,42 +28,61 @@ function* checkedRecords(ledger, prepared) {
26
28
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
27
29
  }
28
30
  const proofs = new WeakMap();
29
- function readLedgerState(input, expectedText, expectedMetadata) {
30
- const ledger = plainData(input);
31
+ function copiedLedger(input, proof) {
32
+ if (!proof || !input || typeof input !== 'object') return plainData(input);
33
+ const fields = Object.getOwnPropertyDescriptors(input), start = fields.start;
34
+ if (!start || !Object.hasOwn(start, 'value') || !start.value || typeof start.value !== 'object') return plainData(input);
35
+ const parts = Object.getOwnPropertyDescriptors(start.value), text = parts.text;
36
+ if (!text || !Object.hasOwn(text, 'value') || text.value !== proof.startText) return plainData(input);
37
+ const shell = Object.create(Object.getPrototypeOf(input), {...fields, start: {...start,
38
+ value: Object.create(Object.getPrototypeOf(start.value), {...parts, text: {...text, value: ''}})}});
39
+ const ledger = plainData(shell);
40
+ ledger.start.text = proof.startText;
41
+ return ledger;
42
+ }
43
+ function ledgerAdmission(input) {
44
+ const held = input && typeof input === 'object' ? proofs.get(input) : undefined;
45
+ const ledger = copiedLedger(input, held);
31
46
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
32
47
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
33
48
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
34
49
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
35
50
  const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
36
- let proof = typeof input === 'object' ? proofs.get(input) : undefined;
37
- if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
38
- proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
39
- if (typeof input === 'object') proofs.set(input, proof);
40
- }
51
+ const proof = held?.shell === shell && held.startText === ledger.start.text ? held : null;
52
+ return {ledger, shell, proof};
53
+ }
54
+ function ledgerResult(ledger, proof, expectedText, expectedMetadata) {
41
55
  proofs.set(ledger, proof);
42
56
  const {text, metadata, root, revision, prepared, source} = proof;
43
57
  if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
44
58
  throw fault('metadata does not describe this document');
45
59
  if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
46
- return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
60
+ return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source, proof};
47
61
  }
48
- function proveLedger(ledger) {
62
+ function readLedgerState(input, expectedText, expectedMetadata) {
63
+ const {ledger, shell, proof: held} = ledgerAdmission(input);
64
+ const proof = held || {shell, startText: ledger.start.text, ...finish(proveLedger(ledger))};
65
+ if (typeof input === 'object') proofs.set(input, proof);
66
+ return ledgerResult(ledger, proof, expectedText, expectedMetadata);
67
+ }
68
+ function* proveLedger(ledger, native = false) {
49
69
  const {sha256: claimed, ...payload} = ledger;
50
- if (claimed !== digest(payload)) throw fault('envelope checksum differs');
51
- let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
70
+ if (claimed !== (yield* checksum(canonicalParts(payload), native))) throw fault('envelope checksum differs');
71
+ let text = ledger.start.text, root = ledger.start.root, revision = ledger.start.revision;
72
+ const startFacts = yield* sourceFacts(text, native);
52
73
  if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
53
- if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
74
+ if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== startFacts.contentRoot) || ledger.start.sha256 !== startFacts.sha256 ||
54
75
  (ledger.complete && revision !== 0)) throw fault('invalid start');
55
76
  const prepared = _rapierPrepareHistoryRecords(ledger.records);
56
77
  if (!prepared.ok) throw fault('record chain differs');
57
- let source = new HistorySource(text), bytes = utf8Length(text);
78
+ let source = new HistorySource(text), bytes = startFacts.utf8Bytes;
58
79
  for (const record of checkedRecords(ledger, prepared)) {
59
80
  const next = source.apply(record.splices);
60
81
  if (next === null) throw fault('splice does not replay');
61
82
  source = next;
62
83
  for (const row of record.splices) bytes += utf8Length(row.inserted) - utf8Length(row.removed);
63
84
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
64
- root = record.afterHash; revision = record.transaction.revision;
85
+ root = record.afterHash; revision = record.transaction.revision; yield;
65
86
  }
66
87
  text = source.slice();
67
88
  if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
@@ -69,7 +90,8 @@ function proveLedger(ledger) {
69
90
  }
70
91
  const metadata = prepared.replayMetadata(ledger.start.metadata);
71
92
  if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
72
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
93
+ const headFacts = ledger.records.every(row => !row.splices.length) ? startFacts : yield* sourceFacts(text, native);
94
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== headFacts.sha256) throw fault('history does not describe this document');
73
95
  return {text, metadata, root, revision, prepared, source};
74
96
  }
75
97
  export function ledgerWithStart(input, text) {
@@ -83,14 +105,25 @@ export function ledgerWithStart(input, text) {
83
105
  export function readLedger(input, expectedText, expectedMetadata) {
84
106
  return readLedgerState(input, expectedText, expectedMetadata).value;
85
107
  }
86
- function* checksum(parts) {
108
+ export async function readLedgerAsync(input, expectedText, expectedMetadata, options = {}) {
109
+ options.signal?.throwIfAborted();
110
+ const {ledger, shell, proof: held} = ledgerAdmission(input);
111
+ const metadata = expectedMetadata === undefined ? undefined : plainData(expectedMetadata);
112
+ const proof = held || {shell, startText: ledger.start.text,
113
+ ...await finishAsync(proveLedger(ledger, !!globalThis.crypto?.subtle), options)};
114
+ options.signal?.throwIfAborted();
115
+ if (typeof input === 'object') proofs.set(input, proof);
116
+ return ledgerResult(ledger, proof, expectedText, metadata).value;
117
+ }
118
+ function* checksum(parts, native = false) {
119
+ if (native) return yield {parts};
87
120
  const hash = createSHA256();
88
121
  for (const part of parts) { hash.update(part); yield; }
89
122
  return hash.digest();
90
123
  }
91
- function* sourceFacts(text) {
124
+ function* sourceFacts(text, native = false) {
92
125
  if (typeof text !== 'string') throw fault('document is not whole-character text');
93
- const hash = createSHA256();
126
+ const hash = native ? null : createSHA256();
94
127
  let fnv = 0x811c9dc5, a = 1, b = 0, bytes = 0;
95
128
  for (let at = 0; at < text.length;) {
96
129
  const start = at, end = Math.min(text.length, at + 65536);
@@ -112,18 +145,19 @@ function* sourceFacts(text) {
112
145
  a %= 65521; b %= 65521;
113
146
  }
114
147
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
115
- hash.update(text.slice(start, end)); yield;
148
+ hash?.update(text.slice(start, end)); yield;
116
149
  }
117
- return {utf8Bytes: bytes, sha256: hash.digest(), contentRoot: text.length + ':' +
150
+ const digest = hash ? hash.digest() : yield {parts: [text], byteLength: bytes};
151
+ return {utf8Bytes: bytes, sha256: digest, contentRoot: text.length + ':' +
118
152
  (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
119
153
  }
120
- function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
154
+ function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}, native = false) {
121
155
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
122
156
  metadata = {...metadata};
123
157
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
124
158
  const prepared = _rapierPrepareHistoryRecords(records, earlier);
125
159
  if (!prepared.ok) throw fault('navigation target was not retained');
126
- const headFacts = yield* sourceFacts(text);
160
+ const headFacts = yield* sourceFacts(text, native);
127
161
  let bytes = headFacts.utf8Bytes;
128
162
  const expanded = [];
129
163
  for (const {record, splices: rows, metadata: effect} of prepared.rows) {
@@ -153,7 +187,7 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
153
187
  const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
154
188
  if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
155
189
  if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
156
- const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText);
190
+ const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText, native);
157
191
  if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
158
192
  const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
159
193
  const payload = {format: 'rapier-ledger/2', documentAuthority,
@@ -165,7 +199,10 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
165
199
  }
166
200
  const replayedMetadata = prepared.replayMetadata(startMetadata);
167
201
  if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
168
- const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload))};
202
+ const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload), native)};
203
+ proofs.set(ledger, {shell: canonicalJSON({...ledger, start: {...ledger.start, text: ''}}), startText,
204
+ text, metadata: {...metadata}, root, revision, prepared: _rapierPrepareHistoryRecords(ledger.records),
205
+ source: new HistorySource(text)});
169
206
  return {ledger, contentRoot, utf8Bytes: headBytes};
170
207
  }
171
208
  function finish(producer) {
@@ -174,13 +211,14 @@ function finish(producer) {
174
211
  return step.value;
175
212
  }
176
213
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
177
- export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
178
- const producer = produceLedger(input);
179
- let step;
214
+ async function finishAsync(producer, {yield: yieldWork, signal} = {}) {
215
+ let step, digest;
180
216
  do {
181
217
  signal?.throwIfAborted();
182
- step = producer.next();
183
- if (!step.done && yieldWork) {
218
+ step = producer.next(digest); digest = undefined;
219
+ if (!step.done && step.value) digest = await sha256PartsAsync(step.value.parts,
220
+ {yield: yieldWork, signal, byteLength: step.value.byteLength});
221
+ else if (!step.done && yieldWork) {
184
222
  const pending = yieldWork();
185
223
  if (pending) await pending;
186
224
  }
@@ -188,8 +226,10 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
188
226
  signal?.throwIfAborted();
189
227
  return step.value;
190
228
  }
191
- export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
192
- const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
229
+ export function prepareLedgerAsync(input, options = {}) {
230
+ return finishAsync(produceLedger(input, !!globalThis.crypto?.subtle), options);
231
+ }
232
+ function historyNavigation(ledger, text, metadata, prepared, source) {
193
233
  const {byId, effects: {pictures}} = prepared;
194
234
  let branch = [], cursor = 0;
195
235
  for (const {transaction: tx} of ledger.records) {
@@ -232,8 +272,13 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
232
272
  if (active === null || !activeMetadata || (i + 1 === cursor && (active.length !== text.length || !active.matches(0, text) || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
233
273
  }
234
274
  }
275
+ return {branch, cursor};
276
+ }
277
+ export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
278
+ const {value: {ledger, text, metadata}, prepared, source, proof} = readLedgerState(input, expectedText, expectedMetadata);
279
+ const {branch, cursor} = proof.navigation ??= historyNavigation(ledger, text, metadata, prepared, source);
235
280
  return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
236
- ledger: ledger.records, branch, cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
281
+ ledger: ledger.records, branch: branch.slice(), cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
237
282
  sourceRootId: ledger.head.root, earliestMetadata: {...ledger.start.metadata}, documentMetadata: {...metadata}, segmentIdentity, historyComplete: ledger.complete,
238
283
  trimReason: ledger.complete ? '' : 'carried_start', trimmedBytes: 0};
239
284
  }
@@ -249,15 +294,14 @@ export function agentActorId(door, agent) {
249
294
  export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
250
295
  const current = readLedger(input); documentText(text);
251
296
  if (current.text === text) return current.ledger;
252
- let start = 0, a = current.text.length, b = text.length;
253
- while (start < a && start < b && current.text[start] === text[start]) start++;
254
- if (start && /[\uDC00-\uDFFF]/.test(current.text[start] || text[start] || '')) start--;
255
- while (a > start && b > start && current.text[a - 1] === text[b - 1]) { a--; b--; }
256
- if (/[\uDC00-\uDFFF]/.test(current.text[a] || text[b] || '')) { a++; b++; }
257
- const splices = [{pos: start, removed: current.text.slice(start, a), inserted: text.slice(start, b)}];
258
- const revision = current.revision + 1, root = rootAfter(current.root, splices[0]);
297
+ const splices = diff(current.text, text, {_unit: 'word'}).edits.map(edit =>
298
+ minimalHistorySplice(current.text.slice(...edit.before), text.slice(...edit.after), edit.before[0])).reverse();
299
+ const revision = current.revision + 1, root = splices.reduce(rootAfter, current.root);
300
+ if (actor.kind === 'agent' && current.metadata.docKind === 'markdown' && evaluateWill(current.text, splices).outcome !== 'applied')
301
+ throw fault('will_violation');
302
+ const identity = sha256(canonicalJSON({before: current.ledger.sha256, text, actor, operation, at: at ?? null}));
259
303
  const record = {beforeHash: current.root, afterHash: root, splices, transaction: {
260
- id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + revision.toString(36),
304
+ id: current.ledger.documentAuthority.slice(0, 48) + ':export:' + identity,
261
305
  documentAuthority: current.ledger.documentAuthority, baseRevision: current.revision, revision,
262
306
  actor, transport: 'platform', operation, requestId: null, sourceTransactionId: null,
263
307
  affectedBlockIds: [], parent: current.ledger.records.at(-1)?.transaction.id ?? null,
@@ -111,9 +111,50 @@ export function sha256Parts(parts) {
111
111
  for (const part of parts) hash.update(part);
112
112
  return hash.digest();
113
113
  }
114
- const LONG_TEXT = 65536, DIGESTS = new Map();
114
+ export async function sha256PartsAsync(parts, {yield: yieldWork, signal, byteLength} = {}) {
115
+ const encoder = new TextEncoder(), chunks = [];
116
+ let bytes = byteLength === undefined ? null : new Uint8Array(byteLength);
117
+ let high = '', length = 0;
118
+ const write = text => {
119
+ if (bytes) {
120
+ const result = encoder.encodeInto(text, bytes.subarray(length));
121
+ if (result.read !== text.length) throw new RangeError('SHA-256 byte length differs');
122
+ length += result.written;
123
+ } else {
124
+ const chunk = encoder.encode(text); chunks.push(chunk); length += chunk.length;
125
+ }
126
+ };
127
+ for (const value of parts) {
128
+ let text = high + String(value === undefined ? '' : value); high = '';
129
+ if (text.length && (text.charCodeAt(text.length - 1) & 0xfc00) === 0xd800) {
130
+ high = text.slice(-1); text = text.slice(0, -1);
131
+ }
132
+ for (let at = 0; at < text.length;) {
133
+ signal?.throwIfAborted();
134
+ let end = Math.min(text.length, at + 32768);
135
+ if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 &&
136
+ (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
137
+ write(text.slice(at, end)); at = end;
138
+ const pending = yieldWork?.(); if (pending) await pending;
139
+ }
140
+ }
141
+ if (high) write(high);
142
+ if (bytes && length !== bytes.length) throw new RangeError('SHA-256 byte length differs');
143
+ bytes ||= new Uint8Array(length);
144
+ let at = 0;
145
+ for (const chunk of chunks) {
146
+ signal?.throwIfAborted(); bytes.set(chunk, at); at += chunk.length;
147
+ const pending = yieldWork?.(); if (pending) await pending;
148
+ }
149
+ chunks.length = 0;
150
+ signal?.throwIfAborted();
151
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', bytes);
152
+ signal?.throwIfAborted();
153
+ return Array.from(new Uint8Array(digest), value => value.toString(16).padStart(2, '0')).join('');
154
+ }
155
+ const LONG_TEXT = 65536, DOCUMENT_TEXT = 1048576, DIGESTS = new Map();
115
156
  export function sha256(text) {
116
- if (typeof text !== 'string' || text.length < LONG_TEXT) return createSHA256().update(text).digest();
157
+ if (typeof text !== 'string' || text.length < LONG_TEXT || text.length >= DOCUMENT_TEXT) return createSHA256().update(text).digest();
117
158
  let digest = DIGESTS.get(text);
118
159
  if (digest === undefined) {
119
160
  digest = createSHA256().update(text).digest();