@voithos-labs/aragonite 0.10.2 → 0.10.3

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 (255) hide show
  1. package/README.md +6 -21
  2. package/THIRD-PARTY-NOTICES.md +25 -0
  3. package/dist/a11y-strings.d.ts +18 -0
  4. package/dist/a11y-strings.js +18 -0
  5. package/dist/action-contracts.d.ts +7 -1
  6. package/dist/ambient/ambient-dom.js +5 -1
  7. package/dist/block-component.d.ts +14 -0
  8. package/dist/components/BlockDragHandle.svelte +35 -24
  9. package/dist/components/BlockHost.svelte +16 -9
  10. package/dist/components/Editor.svelte +381 -198
  11. package/dist/components/Editor.svelte.d.ts +1 -1
  12. package/dist/components/SelectionOverlay.svelte +17 -3
  13. package/dist/components/TailInsert.svelte +107 -0
  14. package/dist/components/TailInsert.svelte.d.ts +17 -0
  15. package/dist/components/block-content-selector.d.ts +6 -2
  16. package/dist/components/block-content-selector.js +6 -2
  17. package/dist/components/blocks/ThematicBreakBlock.svelte +19 -6
  18. package/dist/components/blocks/ThematicBreakBlock.svelte.d.ts +1 -0
  19. package/dist/components/blocks/code/CodeBlock.svelte +211 -30
  20. package/dist/components/blocks/code/CodeBlockRail.svelte +686 -0
  21. package/dist/components/blocks/code/CodeBlockRail.svelte.d.ts +26 -0
  22. package/dist/components/blocks/code/code-bootstrap.js +4 -0
  23. package/dist/components/blocks/code/code-context-actions.d.ts +1 -0
  24. package/dist/components/blocks/code/code-context-actions.js +24 -0
  25. package/dist/components/blocks/code/code-fence-exit.d.ts +15 -0
  26. package/dist/components/blocks/code/code-fence-exit.js +26 -0
  27. package/dist/components/blocks/code/code-languages.d.ts +6 -0
  28. package/dist/components/blocks/code/code-languages.js +11 -0
  29. package/dist/components/blocks/code/code-renderer.js +11 -0
  30. package/dist/components/blocks/directive/DirectiveContainerBlock.svelte +1 -1
  31. package/dist/components/blocks/editable-leaf.d.ts +37 -6
  32. package/dist/components/blocks/editable-leaf.js +243 -29
  33. package/dist/components/blocks/editable-surface.d.ts +9 -0
  34. package/dist/components/blocks/editable-surface.js +22 -3
  35. package/dist/components/blocks/list/ListItemBlock.svelte +3 -3
  36. package/dist/components/blocks/list/task-checkbox.d.ts +2 -0
  37. package/dist/components/blocks/list/task-checkbox.js +11 -2
  38. package/dist/components/blocks/surface-wiring.svelte.d.ts +4 -0
  39. package/dist/components/blocks/surface-wiring.svelte.js +8 -1
  40. package/dist/components/blocks/table/TableActionMenu.svelte +210 -86
  41. package/dist/components/blocks/table/TableActionMenu.svelte.d.ts +5 -0
  42. package/dist/components/blocks/table/TableBlock.svelte +205 -186
  43. package/dist/components/blocks/table/TableBlock.svelte.d.ts +1 -0
  44. package/dist/components/blocks/table/TableCellBlock.svelte +87 -22
  45. package/dist/components/blocks/table/TableRowBlock.svelte +4 -18
  46. package/dist/components/blocks/table/TableRowBlock.svelte.d.ts +0 -5
  47. package/dist/components/blocks/table/cell-clipboard.d.ts +10 -0
  48. package/dist/components/blocks/table/cell-clipboard.js +34 -1
  49. package/dist/components/blocks/table/cell-keydown-plan.d.ts +1 -1
  50. package/dist/components/blocks/table/cell-keydown-plan.js +3 -1
  51. package/dist/components/blocks/table/table-cell-paste.js +2 -1
  52. package/dist/components/blocks/table/table-menu-model.d.ts +35 -8
  53. package/dist/components/blocks/table/table-menu-model.js +36 -17
  54. package/dist/components/blocks/text/TextEditableBlock.svelte +58 -11
  55. package/dist/components/blocks/text/delimiter-autopair.d.ts +71 -0
  56. package/dist/components/blocks/text/delimiter-autopair.js +216 -0
  57. package/dist/components/blocks/text/edge-policy-dispatch.d.ts +4 -0
  58. package/dist/components/blocks/text/edge-policy-dispatch.js +56 -2
  59. package/dist/components/blocks/text/live-selection-edit.js +27 -0
  60. package/dist/components/blocks/text/text-keydown.d.ts +7 -1
  61. package/dist/components/blocks/text/text-keydown.js +11 -1
  62. package/dist/components/blocks/text/text-render.d.ts +1 -1
  63. package/dist/components/blocks/text/text-render.js +3 -1
  64. package/dist/components/blocks/text/widget-interaction.d.ts +3 -0
  65. package/dist/components/blocks/text/widget-interaction.js +144 -22
  66. package/dist/components/drag-handle.d.ts +35 -0
  67. package/dist/components/drag-handle.js +126 -0
  68. package/dist/components/editor-root-focus.d.ts +19 -0
  69. package/dist/components/editor-root-focus.js +67 -0
  70. package/dist/components/editor-root-geometry.d.ts +39 -0
  71. package/dist/components/editor-root-geometry.js +91 -0
  72. package/dist/components/editor-root-keydown.d.ts +1 -1
  73. package/dist/components/editor-root-keydown.js +12 -3
  74. package/dist/components/editor-root-listeners.d.ts +10 -6
  75. package/dist/components/editor-root-listeners.js +23 -22
  76. package/dist/components/editor-root-mode-flip.d.ts +36 -0
  77. package/dist/components/editor-root-mode-flip.js +92 -0
  78. package/dist/components/image/ImageOverlayHost.svelte +14 -6
  79. package/dist/components/image/ImageProperties.svelte +512 -70
  80. package/dist/components/image/ImageProperties.svelte.d.ts +6 -1
  81. package/dist/components/image/ImageResizeHandles.svelte +59 -34
  82. package/dist/components/image/image-crop.d.ts +39 -0
  83. package/dist/components/image/image-crop.js +74 -0
  84. package/dist/components/image/image-edit-commit.d.ts +1 -0
  85. package/dist/components/image/image-edit-commit.js +21 -5
  86. package/dist/components/image/image-source-bytes.js +10 -3
  87. package/dist/components/image/image-widget-editing.js +1 -0
  88. package/dist/components/image/widget-dom.js +5 -1
  89. package/dist/components/link-card/link-card-commit.js +1 -1
  90. package/dist/components/lrd-map-gate.js +1 -1
  91. package/dist/components/menu/BlockMenu.svelte +315 -0
  92. package/dist/components/menu/BlockMenu.svelte.d.ts +34 -0
  93. package/dist/components/menu/MenuIcon.svelte +153 -0
  94. package/dist/components/menu/MenuIcon.svelte.d.ts +51 -0
  95. package/dist/components/menu/clipboard-actions.d.ts +12 -0
  96. package/dist/components/menu/clipboard-actions.js +42 -0
  97. package/dist/components/menu/default-context-actions.d.ts +15 -0
  98. package/dist/components/menu/default-context-actions.js +76 -0
  99. package/dist/components/menu/flyout-placement.d.ts +6 -0
  100. package/dist/components/menu/flyout-placement.js +25 -0
  101. package/dist/core/inline/format-toggle.d.ts +12 -4
  102. package/dist/core/inline/format-toggle.js +94 -40
  103. package/dist/core/inline/image-dimensions.d.ts +3 -0
  104. package/dist/core/inline/image-dimensions.js +46 -10
  105. package/dist/core/inline/inline-widgets.d.ts +11 -0
  106. package/dist/core/inline/scan/brackets.js +1 -0
  107. package/dist/core/inline/scan/plugin-syntax.d.ts +8 -0
  108. package/dist/core/inline/scan/plugin-syntax.js +15 -1
  109. package/dist/core/inline-render.d.ts +6 -0
  110. package/dist/core/inline-render.js +32 -0
  111. package/dist/core/nodes.d.ts +14 -0
  112. package/dist/cursor/edge-affinity.js +2 -1
  113. package/dist/cursor/overlay-remeasure.js +8 -0
  114. package/dist/cursor/reveal-source.js +7 -2
  115. package/dist/cursor/widget-offset.d.ts +6 -0
  116. package/dist/cursor/widget-offset.js +61 -4
  117. package/dist/debug/interaction-trace.d.ts +4 -0
  118. package/dist/debug/interaction-trace.js +15 -0
  119. package/dist/decorations/decoration-state.svelte.js +1 -1
  120. package/dist/editor-actions/ancestry-folds.d.ts +2 -2
  121. package/dist/editor-actions/ancestry-folds.js +1 -1
  122. package/dist/editor-actions/block-edit-scope.js +1 -1
  123. package/dist/editor-actions/commit/text-batch.d.ts +3 -2
  124. package/dist/editor-actions/commit/text-batch.js +1 -1
  125. package/dist/editor-actions/commit/undo-controller.js +4 -2
  126. package/dist/editor-actions/container-edit.js +2 -1
  127. package/dist/editor-actions/enter-completion.d.ts +2 -0
  128. package/dist/editor-actions/enter-completion.js +23 -2
  129. package/dist/editor-actions/inline-range-commit.js +1 -1
  130. package/dist/editor-actions/reorder-action.js +19 -10
  131. package/dist/editor-actions/reorder-drag.js +29 -1
  132. package/dist/editor-actions/replacement-focus.d.ts +1 -1
  133. package/dist/editor-actions/replacement-focus.js +1 -1
  134. package/dist/editor-actions/search-replace.js +1 -1
  135. package/dist/editor-actions/table-context.d.ts +4 -1
  136. package/dist/editor-actions/table-context.js +57 -1
  137. package/dist/editor-events.d.ts +3 -0
  138. package/dist/editor-keys.d.ts +33 -0
  139. package/dist/editor-props.d.ts +21 -12
  140. package/dist/index.d.ts +1 -1
  141. package/dist/plugin.d.ts +6 -0
  142. package/dist/plugin.js +11 -0
  143. package/dist/plugins/latex/BlockMath.svelte +264 -29
  144. package/dist/plugins/latex/BlockMath.svelte.d.ts +2 -0
  145. package/dist/plugins/latex/index.d.ts +2 -1
  146. package/dist/plugins/latex/latex-kind.js +36 -3
  147. package/dist/plugins/latex/math-completion.js +4 -1
  148. package/dist/plugins/latex/math-layout.d.ts +15 -0
  149. package/dist/plugins/latex/math-layout.js +13 -0
  150. package/dist/plugins/latex/math-source.d.ts +19 -0
  151. package/dist/plugins/latex/math-source.js +97 -0
  152. package/dist/plugins/latex/register.d.ts +11 -2
  153. package/dist/plugins/latex/register.js +3 -1
  154. package/dist/plugins/latex/renderer.d.ts +3 -3
  155. package/dist/plugins/latex/renderer.js +13 -6
  156. package/dist/reactivity/list-windowing.svelte.d.ts +8 -8
  157. package/dist/reactivity/list-windowing.svelte.js +49 -24
  158. package/dist/schema/block-completions.d.ts +8 -0
  159. package/dist/schema/block-completions.js +11 -0
  160. package/dist/schema/context-actions.d.ts +31 -0
  161. package/dist/schema/context-actions.js +23 -0
  162. package/dist/schema/fenced-code-raw.js +31 -1
  163. package/dist/schema/operations.d.ts +8 -1
  164. package/dist/schema/reserved-chords.js +25 -4
  165. package/dist/schema/table-cell-raw.d.ts +1 -1
  166. package/dist/schema/table-cell-raw.js +1 -1
  167. package/dist/selection/block-hit-test.js +3 -2
  168. package/dist/selection/char-endpoint-snap.js +1 -1
  169. package/dist/selection/clipboard-text.js +6 -1
  170. package/dist/selection/covered-block.d.ts +10 -0
  171. package/dist/selection/covered-block.js +24 -0
  172. package/dist/selection/cross-block/dispatch.d.ts +3 -0
  173. package/dist/selection/cross-block/dispatch.js +13 -1
  174. package/dist/selection/cross-block/format-range.d.ts +1 -1
  175. package/dist/selection/cross-block/format-range.js +4 -15
  176. package/dist/selection/cross-block/format-toggle.js +1 -1
  177. package/dist/selection/cross-block/keydown.js +1 -1
  178. package/dist/selection/cross-block/ops.js +1 -1
  179. package/dist/selection/cross-block/paste.js +32 -30
  180. package/dist/selection/cross-block/type-replace.d.ts +3 -2
  181. package/dist/selection/cross-block/type-replace.js +62 -13
  182. package/dist/selection/dead-space-caret.d.ts +10 -0
  183. package/dist/selection/dead-space-caret.js +47 -1
  184. package/dist/selection/double-click-trim.d.ts +17 -0
  185. package/dist/selection/double-click-trim.js +57 -0
  186. package/dist/selection/drag-pointer.d.ts +7 -2
  187. package/dist/selection/drag-pointer.js +61 -2
  188. package/dist/selection/gap-caret.js +1 -1
  189. package/dist/selection/keyboard-extend.js +1 -1
  190. package/dist/selection/path-lookup.js +1 -1
  191. package/dist/selection/range-delete-ceremony.js +4 -2
  192. package/dist/selection/range-delete-chrome.js +2 -1
  193. package/dist/selection/range-delete-table-coverage.js +2 -1
  194. package/dist/selection/range-delete-table.js +3 -2
  195. package/dist/selection/range-delete.js +30 -2
  196. package/dist/selection/selection-restore.js +1 -1
  197. package/dist/selection/selection-state.svelte.d.ts +6 -0
  198. package/dist/selection/selection-state.svelte.js +36 -1
  199. package/dist/selection/table-endpoint-snap.js +1 -1
  200. package/dist/selection/table-rect-extend.js +1 -1
  201. package/dist/styles/editor-theme.css +57 -28
  202. package/dist/styles/editor.css +217 -18
  203. package/dist/testing/container-conformance.js +2 -2
  204. package/dist/testing/inline-conformance.js +2 -1
  205. package/dist/tree-operations/blockquote.js +1 -1
  206. package/dist/tree-operations/chain-rebuild.d.ts +63 -0
  207. package/dist/tree-operations/chain-rebuild.js +142 -0
  208. package/dist/tree-operations/children.d.ts +1 -1
  209. package/dist/tree-operations/children.js +1 -1
  210. package/dist/tree-operations/cleanup.js +1 -1
  211. package/dist/tree-operations/content-write.d.ts +50 -0
  212. package/dist/tree-operations/content-write.js +263 -0
  213. package/dist/tree-operations/index.d.ts +8 -3
  214. package/dist/tree-operations/index.js +6 -2
  215. package/dist/tree-operations/list/exit-replacement.js +1 -1
  216. package/dist/tree-operations/list/unwrap-merge.js +3 -3
  217. package/dist/tree-operations/node-ops.d.ts +17 -234
  218. package/dist/tree-operations/node-ops.js +47 -1113
  219. package/dist/tree-operations/node-primitives.d.ts +75 -0
  220. package/dist/tree-operations/node-primitives.js +117 -0
  221. package/dist/tree-operations/paste/apply.js +1 -1
  222. package/dist/tree-operations/paste/body-write.d.ts +1 -1
  223. package/dist/tree-operations/paste/body-write.js +2 -2
  224. package/dist/tree-operations/paste/container-match.js +4 -2
  225. package/dist/tree-operations/paste/dispatch.js +2 -1
  226. package/dist/tree-operations/paste/find-enclosing-list.js +1 -1
  227. package/dist/tree-operations/paste/focus-target.d.ts +1 -1
  228. package/dist/tree-operations/paste/list-absorb.js +1 -1
  229. package/dist/tree-operations/paste/list-break-out.js +1 -1
  230. package/dist/tree-operations/paste/parent-scope.js +1 -1
  231. package/dist/tree-operations/paste/paste-replacement.js +1 -1
  232. package/dist/tree-operations/paste/replace-block-at-parent.js +1 -1
  233. package/dist/tree-operations/path-mutate.d.ts +1 -1
  234. package/dist/tree-operations/path-mutate.js +2 -1
  235. package/dist/tree-operations/reorder-unit.js +1 -1
  236. package/dist/tree-operations/reorder.d.ts +5 -2
  237. package/dist/tree-operations/reorder.js +55 -2
  238. package/dist/tree-operations/settle.d.ts +105 -0
  239. package/dist/tree-operations/settle.js +660 -0
  240. package/dist/tree-operations/table-grid-clipboard.d.ts +21 -0
  241. package/dist/tree-operations/table-grid-clipboard.js +90 -0
  242. package/dist/tree-operations/unshare.d.ts +15 -77
  243. package/dist/tree-operations/unshare.js +15 -166
  244. package/docs/guide/consumer-guide.md +126 -96
  245. package/docs/guide/plugin-api.md +31 -3
  246. package/docs/guide/plugin-guide.md +26 -3
  247. package/package.json +4 -2
  248. package/dist/components/blocks/code/CodeLanguageChip.svelte +0 -127
  249. package/dist/components/blocks/code/CodeLanguageChip.svelte.d.ts +0 -14
  250. package/dist/components/blocks/table/TableGrip.svelte +0 -91
  251. package/dist/components/blocks/table/TableGrip.svelte.d.ts +0 -8
  252. package/dist/components/blocks/table/table-drop-target.d.ts +0 -1
  253. package/dist/components/blocks/table/table-drop-target.js +0 -16
  254. package/dist/components/blocks/table/table-reorder-drag.d.ts +0 -78
  255. package/dist/components/blocks/table/table-reorder-drag.js +0 -97
@@ -1,122 +1,44 @@
1
1
  /**
2
- * Kind-agnostic CST node mutations: path resolution, split, merge, delete, update. Children-array
3
- * contract: an op mutating a container's top-level children takes the array as a parameter and
4
- * mutates that, never `node.children` — the caller owns and republishes it, so a direct splice is
5
- * overwritten. A descendant found by walking the live tree is the exception: mutate it in place on
6
- * a caller-unshared spine (`unshare.ts`), a STRUCTURAL one via `commitMultiScope`.
2
+ * The split and merge primitives, the two ops that re-tile a body's blocks around a caret, plus
3
+ * the join cleanup every destructive join crosses (live-mode.md § 4.5). They mutate and report;
4
+ * the settle around them is the ceremony's (`settle.ts`).
7
5
  */
8
6
  import { DEV } from 'esm-env';
7
+ import { headingLevel } from '../core/nodes';
9
8
  import { isBlankParagraph, isBlankSource, parse } from '../core/parser';
10
- import { escalatedFenceLength, matchFenceOpen } from '../core/parsers/fence-syntax';
11
- import { isBlockOpenerRegistered } from '../schema/block-openers';
12
9
  import { getLiveJoinSeamCleaner, getLiveSplitRebalancer } from '../schema/inline-construct-policy';
13
10
  import { displayLength, snapToScalarBoundary, terminateLine, trailingLineEnding, trimTrailingLineEnding } from '../core/lines';
14
11
  import { devWarn } from '../dev-warn';
15
12
  import { assignChildIdsDeep } from '../block-id';
16
- import { perfEnabled, recordContainerKindReparse } from '../perf/instruments';
17
13
  import { findMergeTarget } from '../schema/merge-rules';
18
14
  import { rebuildAncestryRaw } from '../schema/container-raw';
19
15
  import { getBlockKindDescriptor, tryGetBlockKindDescriptor } from '../schema/block-kind-descriptor';
20
- import { reservedChromeKindOf } from '../schema/reserved-chrome';
21
- import { ensureUnsharedChild, ensureUnsharedPath } from './unshare';
22
- import { resyncChildIds, spliceChildren } from './children';
23
- import { spliceMany } from './splice-many';
24
- import { applyStructuralChangeToIdsRefs, replacePreservingFirst } from './structural-change';
16
+ import { ensureUnsharedPath } from './unshare';
17
+ import { replacePreservingFirst } from './structural-change';
25
18
  import { assertInvariant } from '../assert';
26
- import { dropChildSpans } from '../schema/child-spans';
27
19
  import { checkSingleNodeSink } from '../invariants/single-node-sink';
28
20
  import { checkSplitLanding } from '../invariants/split-landing';
29
- import { checkStructuralDescriptor } from '../invariants/structural-descriptor';
30
- const ownerKindOf = (parent) => 'ownerKind' in parent ? parent.ownerKind : undefined;
21
+ import { NEXT_PROSE_LINE, ensureEditableContainers, forBody, normalizeOwnRaw } from './node-primitives';
22
+ import { absorbSeamReading, deleteNode } from './settle';
23
+ import { adoptReparsedFields, probeLineOpensAsProse } from './content-write';
31
24
  /**
32
- * Text made legal as a child's raw inside a container of kind `ownerKind`. Exported for
33
- * sinks that build their own bytes rather than handing text to {@link updateNodeContent}.
34
- */
35
- export function normalizeBodyWrite(ownerKind, raw) {
36
- const owner = ownerKind === undefined ? undefined : tryGetBlockKindDescriptor(ownerKind);
37
- return owner?.bodyWrite?.normalize(raw) ?? raw;
38
- }
39
- const forBody = (parent, raw) => normalizeBodyWrite(ownerKindOf(parent), raw);
40
- /**
41
- * `raw` made legal as `node`'s OWN bytes — {@link normalizeBodyWrite}'s node-side twin, for the
42
- * kind's own grammar rather than its container's. Sinks that REPLACE the node with a reparse of
43
- * the result call this door: the reparse re-derives metadata from the bytes, so structure the
44
- * rule restores from the old metadata (a fence closer a truncation consumed) must land first.
45
- */
46
- export function normalizeOwnRaw(node, raw) {
47
- return tryGetBlockKindDescriptor(node.kind)?.normalizeRawWrite?.(raw, node) ?? raw;
48
- }
49
- /**
50
- * Write `raw` as `node`'s OWN bytes through its kind's rule — {@link normalizeOwnRaw}'s in-place
51
- * sink. Every sink writing a leaf's bytes without the kind's surface in front of it owes one of
52
- * the two (pinned by `lint/leaf-raw-write-rule`).
53
- */
54
- export function writeOwnRaw(node, raw, grammar) {
55
- const descriptor = tryGetBlockKindDescriptor(node.kind);
56
- const legal = descriptor?.normalizeRawWrite?.(raw, node) ?? raw;
57
- node.raw = legal;
58
- // A context-dependent kind's raw does not reparse to itself, so its metadata was never
59
- // parse-derived and a fragment parse would only mis-read it.
60
- if (descriptor?.contextDependentKind)
61
- return;
62
- // In place means no reparse replaces the node, so parse-owned metadata re-derives here —
63
- // for the rule's own rewrite AND bytes the caller's edit already changed.
64
- const reparsed = parse(legal, { grammar, scope: 'fragment' }).children;
65
- if (reparsed.length === 1 && reparsed[0].kind === node.kind)
66
- node.metadata = reparsed[0].metadata;
67
- }
68
- // ── Node minting ──
69
- /**
70
- * The paragraph mint. Every argument is required: a paragraph's raw ENDS in a line ending,
71
- * so a mint site must answer which document it lands in (G4.20) rather than strand a lone
72
- * LF in a CRLF file. Returns a fresh node every call — a shared instance would alias across
73
- * tree positions and corrupt the snapshot/unshare model (G1.9).
74
- */
75
- export function paragraphNode(leadingTrivia, text, lineEnding) {
76
- return { kind: 'paragraph', leadingTrivia, raw: text + lineEnding };
77
- }
78
- /** The empty-paragraph placeholder keeping an emptied document or container caret-addressable. */
79
- export function emptyParagraph(leadingTrivia, lineEnding) {
80
- return paragraphNode(leadingTrivia, '', lineEnding);
81
- }
82
- export function nodeAt(doc, path) {
83
- let cur = doc;
84
- for (const idx of path) {
85
- if (!cur.children || idx < 0 || idx >= cur.children.length)
86
- return null;
87
- cur = cur.children[idx];
88
- }
89
- return cur;
90
- }
91
- export function blockNodeAt(doc, path) {
92
- const node = nodeAt(doc, path);
93
- return node !== null && isBlockNode(node) ? node : null;
94
- }
95
- export function isBlockNode(node) {
96
- return 'raw' in node;
97
- }
98
- /**
99
- * The landing a site is about to consume, held to the primitive's answer (G1.34). The
100
- * top-level path seats the caret at it and the list path splices at it, so both cross this
101
- * on their way to using it; a re-derived `blockIndex + 1` warns instead of shipping.
25
+ * The landing a site is about to consume, held to the primitive's answer (G1.34): a re-derived
26
+ * `blockIndex + 1` warns instead of shipping.
102
27
  */
103
28
  export function assertSplitLanding(split, landing) {
104
29
  assertInvariant('split-landing', () => checkSplitLanding(split.secondHalfIndex, landing));
105
30
  }
106
31
  /**
107
32
  * What a one-slot sink is about to put in its slot, held to one node (G1.35). Asked at the WRITE
108
- * with the nodes being written, so the guard answers for sink N+1 — one that skips the refusal its
109
- * siblings make, or splices a plural replacement into a slot that holds one.
33
+ * with the nodes being written, so the guard answers for sink N+1.
110
34
  */
111
35
  export function assertSingleNodeSink(sink, installed) {
112
36
  assertInvariant('single-node-sink', () => checkSingleNodeSink(sink, installed.length));
113
37
  }
114
38
  /**
115
39
  * Split the node at `blockIndex` at raw `offset` (display-relative). The first half inherits the
116
- * original ID and the whole structural suffix (a setext underline), which a plain cut would strand
117
- * below as junk. The second half opens with a blank separator wherever one does structural work
118
- * ({@link separatorSplitsOffNextLine}) — without it GFM lazy continuation folds the halves back
119
- * into one block on reload.
40
+ * original ID and the whole structural suffix (a setext underline); the second half opens with a
41
+ * blank separator wherever one does structural work ({@link separatorSplitsOffNextLine}).
120
42
  */
121
43
  export function splitNode(parent, blockIndex, offset, sharing, presentationMode, linkRef) {
122
44
  const noop = { change: { op: 'noop' }, secondHalfIndex: blockIndex + 1 };
@@ -130,17 +52,17 @@ export function splitNode(parent, blockIndex, offset, sharing, presentationMode,
130
52
  return noop;
131
53
  const rawText = node.raw;
132
54
  const lineEnding = trailingLineEnding(rawText);
133
- const cut = cutPastLineEnding(descriptor, node, offset);
55
+ const cut = headingHeadCut(descriptor, node, cutPastLineEnding(descriptor, node, offset));
134
56
  const suffixSplit = structuralSuffixSplit(descriptor, node, cut);
135
- // Both halves: each can collide alone — a `</details>` stranded on the second half, or
136
- // a first half promoted to a bare terminator once its trailing text is cut away.
57
+ // Both halves: each can collide alone (a `</details>` stranded on the second half, or
58
+ // a first half promoted to a bare terminator once its trailing text is cut away).
137
59
  let firstRaw = forBody(parent, suffixSplit ? suffixSplit.firstRaw : rawText.slice(0, cut));
138
60
  let secondRaw = forBody(parent, suffixSplit ? suffixSplit.secondRaw : rawText.slice(cut));
139
61
  firstRaw = terminateLine(firstRaw, rawText);
140
62
  secondRaw = terminateLine(secondRaw, rawText);
141
63
  // Live alone rebalances: there the delimiters around the cut are unpainted, so a byte-literal
142
- // half would surface runs the reader never saw. The rebalancer verifies its own bytes and
143
- // declines when they do not parse back, leaving the literal cut every other mode gets.
64
+ // half would surface runs the reader never saw. The rebalancer declines when its bytes do not
65
+ // parse back, leaving the literal cut every other mode gets.
144
66
  if (presentationMode === 'live') {
145
67
  const rebalanced = getLiveSplitRebalancer()?.(node, offset, firstRaw, secondRaw, linkRef);
146
68
  if (rebalanced) {
@@ -158,7 +80,7 @@ export function splitNode(parent, blockIndex, offset, sharing, presentationMode,
158
80
  // separator; `separator` answers no when the bytes already end in a blank line.
159
81
  const second = reparseAsNodes(secondRaw, first.suffix + separator);
160
82
  if (DEV && first.nodes.length > 1) {
161
- // Legal — the landing index rides the result — but rare enough to keep visible.
83
+ // Legal, since the landing index rides the result, but rare enough to keep visible.
162
84
  devWarn('tree-ops', `splitNode: the first half parsed to ${first.nodes.length} blocks`);
163
85
  }
164
86
  const nodes = [...first.nodes, ...second.nodes];
@@ -166,9 +88,8 @@ export function splitNode(parent, blockIndex, offset, sharing, presentationMode,
166
88
  nodes[nodes.length - 1].raw += second.suffix;
167
89
  const splitTail = blockIndex === parent.children.length - 1;
168
90
  parent.children.splice(blockIndex, 1, ...nodes);
169
- // Floor at the seam itself: a wider window would reach back into the spliced set and
170
- // break the one-window accounting below. At the tail there is no successor to re-read, and
171
- // the document's folded line is the settle funnel's question, not this sink's.
91
+ // Floor at the seam itself: a wider window would reach back into the spliced set and break
92
+ // the one-window accounting. At the tail the document's folded line is the settle funnel's.
172
93
  const seamLeft = blockIndex + nodes.length - 1;
173
94
  const eaten = splitTail ? 0 : absorbSeamReading(parent, seamLeft, seamLeft, sharing).eaten;
174
95
  return {
@@ -177,10 +98,9 @@ export function splitNode(parent, blockIndex, offset, sharing, presentationMode,
177
98
  };
178
99
  }
179
100
  /**
180
- * The cut a split makes, given the caret's `offset`: an ending the offset lands ON terminates
181
- * the FIRST half rather than opening the second, which would mint a blank line nobody typed.
182
- * A CRLF is one boundary, so a cut between its bytes moves past both. Clamped to a content
183
- * range's end, past which the offset stops being a content position at all.
101
+ * The cut a split makes: an ending the offset lands ON terminates the FIRST half rather than
102
+ * opening the second, which would mint a blank line nobody typed. A CRLF is one boundary, and
103
+ * the cut clamps to a content range's end.
184
104
  */
185
105
  function cutPastLineEnding(descriptor, node, offset) {
186
106
  const raw = node.raw;
@@ -194,20 +114,18 @@ function cutPastLineEnding(descriptor, node, offset) {
194
114
  return contentEnd === undefined ? at + ending.length : Math.min(at + ending.length, contentEnd);
195
115
  }
196
116
  /**
197
- * The stand-in for whatever the user types into the second half: the maximally-continuable
198
- * line, so the predicate answers for the worst case rather than one construct. Openers are
199
- * arbitrary code, so no line is unclaimable by construction — {@link probeLineOpensAsProse}
200
- * is the runtime check.
117
+ * Enter at the head of an ATX heading's text moves the whole heading down under a new empty
118
+ * line: the marker belongs with its text, and an empty heading is nothing anyone asked for.
201
119
  */
202
- export const NEXT_PROSE_LINE = 'x';
203
- /** Whether the ambient grammar still leaves {@link NEXT_PROSE_LINE} an ordinary paragraph. */
204
- export function probeLineOpensAsProse(grammar) {
205
- return lineOpensAs(NEXT_PROSE_LINE, grammar) === 'paragraph';
120
+ function headingHeadCut(descriptor, node, cut) {
121
+ const content = headingLevel(node) === null ? undefined : descriptor.getContentRange?.(node);
122
+ if (!content || content.start === 0 || content.end === content.start)
123
+ return cut;
124
+ return cut <= content.start ? 0 : cut;
206
125
  }
207
126
  /**
208
- * Leading trivia for a freshly minted BLANK block. A blank line is a block only past its
209
- * run's first line, so it separates from a non-blank predecessor — unless a run is already
210
- * open below, where the successor's own separator opens it.
127
+ * Leading trivia for a freshly minted BLANK block: a blank line is a block only past its run's
128
+ * first line, so it separates from a non-blank predecessor unless a run is already open below.
211
129
  */
212
130
  function blankBlockTrivia(predecessorIsBlank, successor, lineEnding) {
213
131
  if (predecessorIsBlank)
@@ -216,8 +134,8 @@ function blankBlockTrivia(predecessorIsBlank, successor, lineEnding) {
216
134
  return runOpenBelow ? '' : lineEnding;
217
135
  }
218
136
  /**
219
- * The second half's leading trivia. A blank half follows {@link blankBlockTrivia}; a prose
220
- * half takes a separator exactly when lazy continuation would fold the halves back together.
137
+ * The second half's leading trivia: a blank half follows {@link blankBlockTrivia}; a prose half
138
+ * takes a separator exactly when lazy continuation would fold the halves back together.
221
139
  */
222
140
  function splitSeparator(firstRaw, secondRaw, lineEnding, successor) {
223
141
  if (isBlankSource(secondRaw)) {
@@ -234,9 +152,8 @@ function blankHalfBecomesBlock(firstRaw, secondRaw, lineEnding) {
234
152
  }
235
153
  /**
236
154
  * Would a blank line between `raw` and the line after it split off a second block? Asked of the
237
- * bytes, never a kind list: a construct whose body swallows both forms alike answers no on its
238
- * own, so the separator never lands inside a body. Blank blocks are discounted on both sides —
239
- * the separator materializes as one, and counting it would answer yes for every raw.
155
+ * bytes, never a kind list, so the separator never lands inside a body that swallows both forms.
156
+ * Blank blocks are discounted on both sides, or the separator would answer yes for every raw.
240
157
  */
241
158
  function separatorSplitsOffNextLine(raw, secondRaw, lineEnding) {
242
159
  if (DEV && !probeLineOpensAsProse()) {
@@ -252,9 +169,9 @@ function contentBlockCount(source) {
252
169
  .length;
253
170
  }
254
171
  /**
255
- * A split that keeps a kind's structural suffix — raw beyond its content range, the setext
256
- * underline — on the first half. Null when the kind has no suffix, or the offset is at block
257
- * start or inside the suffix itself; both keep the plain raw cut.
172
+ * A split that keeps a kind's structural suffix (raw beyond its content range, the setext
173
+ * underline) on the first half. Null when the kind has no suffix, or the offset is at block
174
+ * start or inside the suffix itself.
258
175
  */
259
176
  function structuralSuffixSplit(descriptor, node, offset) {
260
177
  const getRange = descriptor.getContentRange;
@@ -277,8 +194,7 @@ function structuralSuffixSplit(descriptor, node, offset) {
277
194
  }
278
195
  // ── Merge ──
279
196
  /**
280
- * `join.mergedRaw` with the delimiter runs the join orphaned at its seam dropped — live only,
281
- * where those runs are unpainted and a literal concatenation surfaces bytes the reader never saw
197
+ * `join.mergedRaw` with the delimiter runs the join orphaned at its seam dropped, live only
282
198
  * (live-mode.md § 4.5). The one registered cleaner verifies its own bytes and otherwise declines,
283
199
  * leaving the literal join every other mode gets. Every destructive join crosses this door.
284
200
  */
@@ -291,7 +207,7 @@ export function cleanJoinedRaw(join, presentationMode) {
291
207
  /**
292
208
  * The bytes a single-block edit leaves when it deletes `range` out of `display`. A delete-then-
293
209
  * insert is a join like any other, so it crosses the same cleanup, and the returned offset is
294
- * where the two sides now meet (a cleanup that drops a run on the first side moves it).
210
+ * where the two sides now meet.
295
211
  */
296
212
  export function cutRangeFromDisplay(node, display, range, presentationMode, linkRef) {
297
213
  // Both ends off any scalar interior before the slice: a half-pair here is unrecoverable
@@ -322,8 +238,7 @@ function joinRaw(prev, curr, presentationMode, linkRef) {
322
238
  }
323
239
  /**
324
240
  * Merge `curr` into the deepest prose leaf of `prev`, writing into that leaf rather than
325
- * reparsing concatenated raw — preserves prev's component identity, IME state, and the
326
- * leaves' inline caches. Pass `sharing` to unshare everything the merge writes. Null when
241
+ * reparsing concatenated raw, which preserves prev's component identity and IME state. Null when
327
242
  * no mergeable leaf exists, so the caller can fall back to move-focus.
328
243
  */
329
244
  export function mergeIntoPrevDeepLeaf(parent, blockIndex, sharing, presentationMode, linkRef, grammar) {
@@ -365,8 +280,7 @@ function holderChildrenAt(children, path) {
365
280
  }
366
281
  /**
367
282
  * The deep-leaf merge's verdict: the absorbed bytes cross the kind's own rule and a fragment
368
- * reparse. Null when they read as several blocks — the leaf is one slot, and writing them
369
- * whole leaves a tree its own reload disagrees with (G1.35).
283
+ * reparse. Null when they read as several blocks, since the leaf is one slot (G1.35).
370
284
  */
371
285
  function mergedLeafFor(target, raw, grammar) {
372
286
  const written = normalizeOwnRaw(target, raw);
@@ -378,19 +292,6 @@ function mergedLeafFor(target, raw, grammar) {
378
292
  const blocks = parse(written, { grammar, scope: 'fragment' }).children;
379
293
  return blocks.length > 1 ? null : { written, blocks };
380
294
  }
381
- /**
382
- * Refresh a node in place from its own reparse, keeping its object identity. The id resync is
383
- * unconditional: the reparse can change the child count while the caller reports `noop`, and the
384
- * fresh children carry no `childIds` of their own.
385
- */
386
- function adoptReparsedFields(target, parsed) {
387
- target.metadata = parsed?.metadata;
388
- target.children = parsed?.children;
389
- resyncChildIds(target);
390
- assignChildIdsDeep(target);
391
- target.innerPrefix = parsed?.innerPrefix;
392
- target.innerSuffix = parsed?.innerSuffix;
393
- }
394
295
  /** {@link mergedLeafFor}'s write, over the unshared spine the verdict was taken ahead of. */
395
296
  function installMergedLeaf(holderChildren, slot, merged, sharing) {
396
297
  const target = holderChildren[slot];
@@ -432,920 +333,10 @@ export function mergeWithNext(parent, blockIndex, presentationMode, linkRef) {
432
333
  parent.children.splice(blockIndex, 2, ...installed);
433
334
  return { change: replacePreservingFirst(blockIndex, 2, 1), joinOffset: seam };
434
335
  }
435
- // ── Separators ──
436
- /**
437
- * Settle the separator at `index`: nothing above it needs one at the body head or below a blank
438
- * block, where the parser would read the extra line as one more empty paragraph. Every splice
439
- * that changes what precedes a block settles through this family (syntax-tree.md § Blank lines).
440
- * `sharing` owns the write (G1.9).
441
- */
442
- export function clearRedundantSeparator(parent, index, sharing) {
443
- retireChildSpans(parent);
444
- const node = parent.children?.[index];
445
- if (!node || node.leadingTrivia === '')
446
- return;
447
- const bodyStart = bodyStartIndex(parent);
448
- if (index < bodyStart)
449
- return;
450
- const predecessor = index > bodyStart ? parent.children[index - 1] : undefined;
451
- if (predecessor !== undefined && !isBlankParagraph(predecessor))
452
- return;
453
- const owned = sharing ? ensureUnsharedChild(parent, index, sharing) : node;
454
- const freed = owned.leadingTrivia;
455
- owned.leadingTrivia = '';
456
- absorbWrapPrefix(parent, bodyStart, index, freed);
457
- }
458
- /**
459
- * A blank block IS a blank line, so it and its follower share ONE separator: two of them reload
460
- * as a second empty paragraph (G2.13). The follower's is the one that stands, so a later fill of
461
- * this slot (a paste over a cut range) still finds the follower separated. The run-level twin
462
- * {@link settleSeparatorOnBlank} keeps the first already-standing line instead, the same bytes
463
- * the other way round.
464
- */
465
- export function dropDoubledSeparator(parent, index, sharing) {
466
- retireChildSpans(parent);
467
- const node = parent.children?.[index];
468
- if (!node || node.leadingTrivia === '' || !isBlankParagraph(node))
469
- return;
470
- if ((parent.children?.[index + 1]?.leadingTrivia ?? '') === '')
471
- return;
472
- const owned = sharing ? ensureUnsharedChild(parent, index, sharing) : node;
473
- owned.leadingTrivia = '';
474
- }
475
- /**
476
- * The separator a block takes back when it stops being blank: its own blank line was what stood
477
- * between it and a non-blank predecessor. Call at the fill — {@link clearRedundantSeparator}
478
- * frees a separator in exactly the cases this declines, and a block that was never blank keeps
479
- * whatever its bytes earned (a paragraph under a heading needs none).
480
- */
481
- export function restoreSeparatorOnFill(parent, index, sharing) {
482
- retireChildSpans(parent);
483
- const node = parent.children?.[index];
484
- if (!node || node.leadingTrivia !== '' || isBlankParagraph(node))
485
- return;
486
- mintSeparator(parent, index, sharing);
487
- }
488
- /**
489
- * The separator the block BELOW a consumed blank line takes back — the same mint minus the
490
- * blank-self guard, since a blank follower needs the line as much as a prose one. It takes one
491
- * only where its own follower holds none: two would reload as a second empty paragraph
492
- * ({@link dropDoubledSeparator}'s rule, declined here rather than undone after).
493
- */
494
- function restoreSeparatorAfterBlank(parent, index, sharing) {
495
- retireChildSpans(parent);
496
- const children = parent.children;
497
- const node = children?.[index];
498
- if (!children || !node || node.leadingTrivia !== '')
499
- return;
500
- if (isBlankParagraph(node) && (children[index + 1]?.leadingTrivia ?? '') !== '')
501
- return;
502
- mintSeparator(parent, index, sharing);
503
- }
504
- /**
505
- * The settle a block turning INTO a blank line owes: it joins the blank run around it, and a run
506
- * carries exactly ONE separating line — across every block in it AND its follower, since a blank
507
- * block is the follower's line too. The line already standing is the one kept, wherever in the
508
- * run it sits; a mint lands at the run's head, the only slot one may take.
509
- */
510
- export function settleSeparatorOnBlank(parent, index, sharing) {
511
- retireChildSpans(parent);
512
- const children = parent.children;
513
- const node = children?.[index];
514
- if (!children || !node || !isBlankParagraph(node))
515
- return;
516
- const bodyStart = bodyStartIndex(parent);
517
- let start = index;
518
- while (start > bodyStart && isBlankParagraph(children[start - 1]))
519
- start--;
520
- let end = index;
521
- while (end + 1 < children.length && isBlankParagraph(children[end + 1]))
522
- end++;
523
- const standing = [];
524
- for (let i = start; i <= Math.min(end + 1, children.length - 1); i++) {
525
- if (children[i].leadingTrivia !== '')
526
- standing.push(i);
527
- }
528
- // A chrome line bounding the run beside PROSE eats one line as the wrap's peel
529
- // (`innerPrefix`/`innerSuffix`), on top of the run's own count; an all-blank body owes none.
530
- const wrap = bodyWrapOf(parent);
531
- const slots = wrapSlotsOf(parent);
532
- const bodyEnd = children.length - 1;
533
- // A run of two or more that IS the whole body sits against both chrome lines, and the reload
534
- // peels one line into each slot before it materializes a block — so it owes BOTH, where an
535
- // arm bounded by prose on one side grants at most one.
536
- const twoPeelBody = !!slots &&
537
- wrap?.afterOpenerLine === true &&
538
- wrap.beforeCloserLine === true &&
539
- start === bodyStart &&
540
- end === bodyEnd &&
541
- start < end;
542
- const tailBelowProse = start > bodyStart && end === bodyEnd;
543
- if (slots && wrap?.beforeCloserLine && (tailBelowProse || twoPeelBody) && !slots.innerSuffix) {
544
- slots.innerSuffix = trailingLineEnding(children[end].raw);
545
- }
546
- // The reverse: a deletion can leave a lone blank as the WHOLE body, where the closer
547
- // peel no longer engages beside the opener's — the run gives the extra line back.
548
- const loneBlankBody = start === bodyStart && end === bodyEnd && start === end;
549
- if (slots && loneBlankBody && slots.innerSuffix && (slots.innerPrefix || standing.length > 0)) {
550
- slots.innerSuffix = '';
551
- }
552
- const headUnderWrap = !!slots && wrap?.afterOpenerLine === true && start === bodyStart && end < bodyEnd;
553
- // A line already standing IS the opener's peel on reload, so taking one as well would add a
554
- // line — but a two-peel body's count comes out of the slots, not out of the run.
555
- const takesOpenerPeel = twoPeelBody || (headUnderWrap && standing.length === 0);
556
- if (slots && takesOpenerPeel && !slots.innerPrefix) {
557
- slots.innerPrefix = trailingLineEnding(children[start].raw);
558
- }
559
- // Under the wrap the run keeps exactly the one peel line — in `innerPrefix` or still
560
- // standing; elsewhere a run with no LINE above it (the document head, a plain container's
561
- // body head) separates from nothing and materializes in full.
562
- let wanted;
563
- if (twoPeelBody)
564
- wanted = 0;
565
- else if (headUnderWrap)
566
- wanted = slots?.innerPrefix ? 0 : 1;
567
- else
568
- wanted = start > 0 || wrap?.afterOpenerLine ? 1 : 0;
569
- if (standing.length < wanted) {
570
- mintSeparator(parent, start, sharing);
571
- }
572
- else {
573
- for (const at of standing.slice(wanted)) {
574
- const owned = sharing ? ensureUnsharedChild(parent, at, sharing) : children[at];
575
- owned.leadingTrivia = '';
576
- }
577
- }
578
- materializeTailSuffix(parent, sharing);
579
- }
580
- /**
581
- * The give-back twin of {@link settleSeparatorOnBlank}'s closer peel: a blank run reaching the
582
- * body tail borrows a line into `innerSuffix` so the reload keeps the block, and a tail that
583
- * stops being blank owes it back or the wrap emits a line nobody typed. A trailing blank the
584
- * author wrote against the closer reaches the same shape and is spent here too — one cosmetic
585
- * line, against a stray one after every tail split.
586
- */
587
- function releaseWrapPeel(parent, index) {
588
- retireChildSpans(parent);
589
- const children = parent.children;
590
- const slots = wrapSlotsOf(parent);
591
- if (!children || children.length === 0 || !slots?.innerSuffix)
592
- return;
593
- if (!bodyWrapOf(parent)?.beforeCloserLine)
594
- return;
595
- if (index < children.length - 1)
596
- return;
597
- if (isBlankParagraph(children[children.length - 1]))
598
- return;
599
- slots.innerSuffix = '';
600
- }
601
- /**
602
- * The parse folds the document's one trailing blank line into `suffix` only while the tail
603
- * block is non-blank (`parseBlocks`' separator-spent rule); once the tail turns blank the
604
- * reload reads that line as its own empty paragraph, so the settle materializes it.
605
- * Document-level slot only — a container's `innerSuffix` twin stays with the wrap arms.
606
- * Returns the blocks appended.
607
- */
608
- function materializeTailSuffix(parent, sharing) {
609
- retireChildSpans(parent);
610
- const children = parent.children;
611
- const suffix = parent.suffix;
612
- if (!children || !suffix)
613
- return 0;
614
- // An emptied parent has no tail for the line to fold against, so it is the document's whole
615
- // content and the reload reads it as the one block there is.
616
- if (children.length > 0 && !isBlankParagraph(children[children.length - 1]))
617
- return 0;
618
- const minted = { kind: 'paragraph', leadingTrivia: '', raw: suffix };
619
- if (sharing)
620
- sharing.stamp(minted);
621
- children.push(minted);
622
- parent.suffix = '';
623
- return 1;
624
- }
625
- // ── The splice settle funnel ──
626
- /**
627
- * The settle every splice owes its neighbourhood: the vacated separating line hands down, a
628
- * now-redundant one frees, the two debts a consumed blank line leaves are paid (syntax-tree.md
629
- * § Blank lines), the folded tail line materializes, and the window's joins are asked. `removed`
630
- * is the pre-splice span, the only place was-blank survives a splice. `tracked` rides the folds
631
- * for a door landing a caret in the spliced bytes. Returns `change` widened by everything the
632
- * settle itself did.
633
- */
634
- function settleSplicedWindow(parent, at, removed, added, change, sharing, tracked) {
635
- if (!parent.children)
636
- return change;
637
- // Ahead of the arms: `settleSeparatorOnBlank` materializes the tail line itself, so a count
638
- // read after it would leave that growth outside the window the sink reports.
639
- const beforeMint = parent.children.length;
640
- handDownVacatedSeparator(parent, at, removed[0]?.leadingTrivia ?? '', sharing);
641
- clearRedundantSeparator(parent, at, sharing);
642
- if (removed.some(isBlankParagraph)) {
643
- // Both ends: the line was the slot's own AND the one below it stood on.
644
- restoreSeparatorAfterBlank(parent, at, sharing);
645
- if (added > 0)
646
- restoreSeparatorAfterBlank(parent, at + added, sharing);
647
- releaseWrapPeel(parent, at + Math.max(added - 1, 0));
648
- }
649
- else {
650
- settleSeparatorOnBlank(parent, at + Math.max(added - 1, 0), sharing);
651
- }
652
- // Unconditional, and the funnel's only home for it: every arm above probes a slot inside the
653
- // window, and a delete window at the tail has none — the question is about the parent's LAST
654
- // block, which no window position answers.
655
- materializeTailSuffix(parent, sharing);
656
- const widened = widenForTailMint(change, beforeMint, parent.children.length);
657
- // The seam question is part of SETTLING, not a rule each door carries: paste, replace, delete
658
- // and fill all splice through here, so door N+1 inherits it. A byte-shaped write
659
- // reporting `noop` splices no window and is asked nothing.
660
- return absorbWindowSeams(parent, at, added, at, widened, sharing, tracked,
661
- // A one-slot window names the one block whose bytes changed, which is what lets the seam
662
- // above decline on its first line; a plural one names no single block.
663
- added === 1 ? at : undefined).change;
664
- }
665
- /** Whoever takes the slot inherits its line, having none of its own — {@link deleteNode}'s rule. */
666
- function handDownVacatedSeparator(parent, at, vacated, sharing) {
667
- retireChildSpans(parent);
668
- const heir = parent.children?.[at];
669
- if (vacated === '' || !heir || heir.leadingTrivia !== '')
670
- return;
671
- const owned = sharing ? ensureUnsharedChild(parent, at, sharing) : heir;
672
- owned.leadingTrivia = vacated;
673
- }
674
- /**
675
- * The commit ceremony's settle door: derive the spliced window from the change and settle it
676
- * against `before`, the pre-mutate children the ceremony still holds. Nodes surviving inside the
677
- * window are not removals, so a coarse descriptor over an in-place write settles as one.
678
- * `tracked` is the committing door's caret, carried through the folds. Returns the change widened
679
- * by a tail line the settle materialized.
680
- */
681
- export function settleSeparator(parent, before, change, sharing, tracked) {
682
- const window = splicedWindow(change);
683
- const children = parent.children;
684
- if (!window || !children)
685
- return change;
686
- // At the funnel's door, ahead of every arm: a window the mutate mis-derived reads `before`
687
- // out of bounds and hands the arms a negative span, which each would clamp into silence.
688
- assertInvariant('structural-descriptor', () => checkStructuralDescriptor(change, before.length));
689
- const survivors = new Set(children.slice(window.at, window.at + window.added));
690
- const removed = before
691
- .slice(window.at, window.at + window.removed)
692
- .filter((node) => !survivors.has(node));
693
- return settleSplicedWindow(parent, window.at, removed, window.added, change, sharing, tracked);
694
- }
695
- function splicedWindow(change) {
696
- switch (change.op) {
697
- case 'noop':
698
- return null;
699
- case 'insert':
700
- return { at: change.at, removed: 0, added: change.count };
701
- case 'delete':
702
- return { at: change.at, removed: change.count, added: 0 };
703
- case 'replace':
704
- return { at: change.at, removed: change.count, added: change.newCount };
705
- }
706
- }
707
- /**
708
- * The out-of-commit-scope twin of {@link settleSeparator}, for a container discovered by walking
709
- * the live tree: it splices through the `childIds` door and reads the pre-splice span itself.
710
- */
711
- export function spliceChildrenSettled(parent, at, removeCount, replacement, sharing) {
712
- const children = parent.children;
713
- if (!children || at < 0 || at > children.length)
714
- return;
715
- const removed = children.slice(at, at + removeCount);
716
- spliceChildren(parent, at, removeCount, replacement);
717
- // `noop` in, so what comes back describes the SETTLE alone: `spliceChildren` already carried
718
- // the door's own splice into `childIds`, and out of commit scope nothing else publishes.
719
- const settled = settleSplicedWindow(parent, at, removed, replacement.length, { op: 'noop' }, sharing);
720
- const ids = parent.childIds;
721
- if (ids)
722
- applyStructuralChangeToIdsRefs(settled, ids, new Array(ids.length));
723
- }
724
- /**
725
- * A splice can leave neighbours whose adjacent bytes re-read as fewer blocks on reload — a list
726
- * standing above indented code absorbs it, and no separator can hold indentation apart.
727
- * Absorb while the window's own bytes parse to fewer blocks, which is the reload's reading;
728
- * byte-identical by construction. A blank run is transparent to a container's continuation, so the
729
- * window anchors at the nearest non-blank block above the seam, never below `floor`, and cascades.
730
- */
731
- function absorbSeamReading(parent, seamLeft, floor, sharing, tracked, headProbe, onBeforeSplice) {
732
- const children = parent.children;
733
- if (seamLeft < 0)
734
- return { at: 0, span: 0, eaten: 0, spliced: false };
735
- let left = seamLeft;
736
- while (left > floor && isBlankParagraph(children[left]))
737
- left--;
738
- const at = left;
739
- let span = seamLeft - at + 1;
740
- let eaten = 0;
741
- let spliced = false;
742
- // Only the first pass: a fold re-tiles the window, so the probe's absolute index is stale.
743
- let probe = headProbe;
744
- for (;;) {
745
- // The candidate edge crosses a blank run too: the absorbed content sits on the
746
- // run's far side (a list continues into indented code across any number of blanks).
747
- let right = at + span;
748
- while (right < children.length && isBlankParagraph(children[right]))
749
- right++;
750
- const window = children.slice(at, Math.min(right + 1, children.length));
751
- if (window.length <= span || window.length < 2)
752
- break;
753
- // A context-dependent kind has no standalone reading, so its seam is not askable.
754
- if (window.some((node) => tryGetBlockKindDescriptor(node.kind)?.contextDependentKind))
755
- break;
756
- if (probe !== undefined && declinesOnHeadLine(window, probe - at))
757
- break;
758
- probe = undefined;
759
- const reparsed = parse(joinedWindowBytes(window, window.length), { scope: 'fragment' });
760
- const blocks = reparsed.children;
761
- if (blocks.length === 0 || blocks.length >= window.length)
762
- break;
763
- // A fold may PROMOTE the head past what its bytes carry alone (a paragraph under the
764
- // setext underline below it), so what must survive is the head's own reading, not its kind.
765
- if (blocks[0].kind !== window[0].kind && !readsAsItselfAlone(window[0]))
766
- break;
767
- onBeforeSplice?.();
768
- absorbFragmentPeel(parent, at + window.length, reparsed.suffix, blocks, sharing);
769
- blocks[0].leadingTrivia = window[0].leadingTrivia;
770
- for (const block of blocks) {
771
- ensureEditableContainers(block);
772
- if (sharing)
773
- sharing.stamp(block);
774
- assignChildIdsDeep(block);
775
- }
776
- if (tracked)
777
- retrackThroughFold(tracked, at, window, blocks);
778
- spliceMany(children, at, window.length, blocks);
779
- eaten += window.length - blocks.length;
780
- span = blocks.length;
781
- spliced = true;
782
- }
783
- return { at, span, eaten, spliced };
784
- }
785
- /**
786
- * Whether a block's own bytes read back as that block. A structured container's children fail
787
- * this by construction — one list item's bytes read as a LIST — which is how a scope whose
788
- * children a document parse does NOT reproduce declines the seam question. Asked only where the
789
- * fold changed the head's kind, so the kind-preserving folds pay nothing.
790
- */
791
- function readsAsItselfAlone(node) {
792
- const alone = parse(node.raw, { scope: 'fragment' }).children;
793
- return alone.length === 1 && alone[0].kind === node.kind;
794
- }
795
- /**
796
- * Decline-only pre-parse for a window whose LAST member is the block that changed: join the
797
- * others with only that block's first line. Block parsing is a left-to-right line scan, so the
798
- * state entering that line is the same in both strings — if it opens a block here it opens one
799
- * in the full join and no fold is possible. A pass falls through to the real parse, so no fold
800
- * verdict is ever taken from truncated bytes. Costs the window minus the changed block, which is
801
- * what keeps a keystroke inside a giant container off its own bytes.
802
- */
803
- function declinesOnHeadLine(window, member) {
804
- if (member <= 0 || member !== window.length - 1)
805
- return false;
806
- const raw = window[member].raw;
807
- const nl = raw.indexOf('\n');
808
- const joined = joinedWindowBytes(window, member) +
809
- window[member].leadingTrivia +
810
- (nl < 0 ? raw : raw.slice(0, nl + 1));
811
- return parse(joined, { scope: 'fragment' }).children.length >= window.length;
812
- }
813
- /** How a fold reads a window: the head's raw, then each of the next `count - 1` members'
814
- * leading trivia and raw. */
815
- function joinedWindowBytes(window, count) {
816
- let joined = window[0].raw;
817
- for (let i = 1; i < count; i++)
818
- joined += window[i].leadingTrivia + window[i].raw;
819
- return joined;
820
- }
821
- /**
822
- * Where a byte position inside the folded window lands: the fold's own reparse re-tiles the
823
- * joined bytes, and a position past the window only shifts by what the fold ate.
824
- */
825
- function retrackThroughFold(tracked, at, window, blocks) {
826
- const member = tracked.index - at;
827
- if (member < 0)
828
- return;
829
- if (member >= window.length) {
830
- tracked.index -= window.length - blocks.length;
831
- return;
832
- }
833
- // The offset walk mirrors {@link joinedWindowBytes}.
834
- let joined = tracked.offset;
835
- for (let i = 0; i < member; i++) {
836
- joined += (i === 0 ? 0 : window[i].leadingTrivia.length) + window[i].raw.length;
837
- }
838
- if (member > 0)
839
- joined += window[member].leadingTrivia.length;
840
- const landed = focusTargetInReplacement(blocks, joined);
841
- tracked.index = at + landed.index;
842
- tracked.offset = landed.offset;
843
- }
844
- /**
845
- * The seam question at every join the splice at `at` disturbed — its window's two edges and the
846
- * joins inside it — since a move can invalidate a join that was already correct. Each
847
- * fold cascades downward, so the next seam is asked past what that one ate. `tracked` rides the
848
- * folds for a caller placing a caret in bytes an absorb can move. `headProbe` names the one block
849
- * whose bytes changed, letting each ask decline on its first line alone; dropped once anything
850
- * folds, since the index it names has moved by then.
851
- */
852
- export function absorbWindowSeams(parent, at, added, landing, change, sharing, tracked, headProbe, onBeforeSplice) {
853
- let settled = null;
854
- let moved = landing;
855
- let seamLeft = at - 1;
856
- let last = at + added - 1;
857
- while (seamLeft <= last) {
858
- const seam = absorbSeamReading(parent, seamLeft, 0, sharing, tracked, settled ? undefined : headProbe, onBeforeSplice);
859
- if (!seam.spliced) {
860
- seamLeft++;
861
- continue;
862
- }
863
- settled = settled ? unionAbsorptions(settled, seam) : seam;
864
- moved = indexAfterAbsorb(moved, seam);
865
- last = indexAfterAbsorb(last, seam);
866
- seamLeft = seam.at + seam.span;
867
- }
868
- if (!settled)
869
- return { change, landing: moved };
870
- return { change: foldAbsorbIntoChange(change, settled), landing: moved };
871
- }
872
- /** Two folds as ONE window, which is what a change descriptor reports. The walk is left to
873
- * right, so `later` never opens above `earlier`'s post-splice span. */
874
- function unionAbsorptions(earlier, later) {
875
- return {
876
- at: earlier.at,
877
- span: later.at + later.span - earlier.at,
878
- eaten: earlier.eaten + later.eaten,
879
- spliced: true
880
- };
881
- }
882
- /** Where `index` sits once `seam` folded: a slot inside the absorbed span collapses into it. */
883
- function indexAfterAbsorb(index, seam) {
884
- if (index < seam.at)
885
- return index;
886
- const absorbedTo = seam.at + seam.span + seam.eaten;
887
- return index >= absorbedTo ? index - seam.eaten : Math.min(index, seam.at + seam.span - 1);
888
- }
889
- /**
890
- * Where the fragment parse's peeled trailing blank run goes. At the parent's tail it stays in the
891
- * last block's raw, the single-slot sink's rule; mid-document it joins the follower's run, where
892
- * one line separates and every later one is a block of its own (syntax-tree.md § Blank lines).
893
- */
894
- function absorbFragmentPeel(parent, followerIndex, peel, blocks, sharing) {
895
- const follower = parent.children[followerIndex];
896
- if (!follower) {
897
- blocks[blocks.length - 1].raw += peel;
898
- return;
899
- }
900
- if (peel === '')
901
- return;
902
- const lines = blankLinesOf(peel + follower.leadingTrivia);
903
- const owned = sharing ? ensureUnsharedChild(parent, followerIndex, sharing) : follower;
904
- owned.leadingTrivia = lines.length > 1 ? '' : lines[0];
905
- for (let i = 1; i < lines.length; i++) {
906
- blocks.push({ kind: 'paragraph', leadingTrivia: i === 1 ? lines[0] : '', raw: lines[i] });
907
- }
908
- }
909
- /** A blank run split back into the lines it is made of, each keeping its own ending. */
910
- const blankLinesOf = (run) => run.match(/[^\n]*\n|[^\n]+$/g) ?? [];
911
- /** The materialized tail reported inside the sink's one contiguous window. */
912
- export function widenForTailMint(change, before, after) {
913
- const grown = after - before;
914
- if (grown === 0)
915
- return change;
916
- if (change.op === 'noop')
917
- return { op: 'insert', at: before, count: grown };
918
- if (change.op === 'insert' && change.at + change.count === before) {
919
- return { ...change, count: change.count + grown };
920
- }
921
- if (change.op === 'replace' && change.at + change.newCount === before) {
922
- return { ...change, newCount: change.newCount + grown };
923
- }
924
- // A delete that took the tail vacated the slot the mint lands in, so the two are one window.
925
- if (change.op === 'delete' && change.at === before) {
926
- return { op: 'replace', at: change.at, count: change.count, newCount: grown };
927
- }
928
- // The mint landed past a window that does not reach the tail, so no single contiguous
929
- // span describes both; the parallel arrays would drift either way this widened it.
930
- devWarn('tree-ops', 'a tail suffix materialized outside the reported window');
931
- return change;
932
- }
933
- /** A blank line off the node's own bytes (G4.20), where one does structural work at all. */
934
- function mintSeparator(parent, index, sharing) {
935
- const children = parent.children;
936
- if (!children || index <= bodyStartIndex(parent))
937
- return;
938
- if (isBlankParagraph(children[index - 1]))
939
- return;
940
- const owned = sharing
941
- ? ensureUnsharedChild(parent, index, sharing)
942
- : children[index];
943
- owned.leadingTrivia = trailingLineEnding(owned.raw);
944
- }
945
- /** Reserved chrome is not a body block, so the body window opens past it. */
946
- function bodyStartIndex(parent) {
947
- return bodyStartFor(ownerKindNameOf(parent));
948
- }
949
- /** The container's declared body wrap, whichever shape names the owner. */
950
- function bodyWrapOf(parent) {
951
- const kind = ownerKindNameOf(parent);
952
- if (kind === undefined)
953
- return undefined;
954
- return tryGetBlockKindDescriptor(kind)?.bodyWrap;
955
- }
956
- /** The node carrying the wrap's peel slots: the sink's answer, or the parent when it IS the node. */
957
- function wrapSlotsOf(parent) {
958
- return ownerNodeOf(parent);
959
- }
960
- /** The container node these children belong to, where the caller answered for one. */
961
- function ownerNodeOf(parent) {
962
- return parent.owner ?? ('raw' in parent ? parent : undefined);
963
- }
964
- /**
965
- * Every settle door rewrites bytes the owner's child spans describe (a sibling's separating line,
966
- * a wrap slot) while leaving the children's shape alone, so the spans retire at the doors and the
967
- * next rebuild re-derives (`schema/child-spans.ts`). The retire census in
968
- * `lint/separator-write-doors.test.ts` names every door and reds the one that forgets.
969
- */
970
- function retireChildSpans(parent) {
971
- const owner = ownerNodeOf(parent);
972
- if (owner)
973
- dropChildSpans(owner);
974
- }
975
- /** The kind whose body these children are: the sink's answer, or the owner node's own. */
976
- function ownerKindNameOf(parent) {
977
- return 'ownerKind' in parent ? parent.ownerKind : parent.kind;
978
- }
979
- function bodyStartFor(kind) {
980
- if (kind === undefined)
981
- return 0;
982
- return tryGetBlockKindDescriptor(kind)?.reservedChrome ? 1 : 0;
983
- }
984
- /**
985
- * A chrome-wrapped container's parse peels the blank line against its opener into `innerPrefix`
986
- * (`core/parser.parseContainerBody`), so a separator freed above the body head is that line
987
- * rather than dead bytes: hand it over, or the peel eats the head block instead.
988
- */
989
- function absorbWrapPrefix(parent, bodyStart, index, freed) {
990
- const slots = wrapSlotsOf(parent);
991
- if (!slots || (slots.innerPrefix ?? '') !== '')
992
- return;
993
- if (!bodyWrapOf(parent)?.afterOpenerLine)
994
- return;
995
- const head = parent.children?.[bodyStart];
996
- if (!head || head.leadingTrivia !== '')
997
- return;
998
- if (index !== bodyStart && !isBlankParagraph(head))
999
- return;
1000
- slots.innerPrefix = trailingLineEnding(freed);
1001
- }
1002
- // ── Delete ──
1003
- /**
1004
- * Remove the node at `blockIndex`, leaving the next sibling separated from its new predecessor
1005
- * and no more. Takes {@link BodyParentArg} because the settle can hand a freed line to the
1006
- * owner's wrap slots. Pass `sharing` to unshare the nodes written — the successor's trivia is
1007
- * the op's only in-place write.
1008
- */
1009
- export function deleteNode(parent, blockIndex, sharing) {
1010
- if (blockIndex < 0 || blockIndex >= parent.children.length)
1011
- return { op: 'noop' };
1012
- const deleted = parent.children[blockIndex];
1013
- if (blockIndex + 1 < parent.children.length) {
1014
- const successor = sharing
1015
- ? ensureUnsharedChild(parent, blockIndex + 1, sharing)
1016
- : parent.children[blockIndex + 1];
1017
- // The successor inherits the deleted separator only when it has none of its own:
1018
- // concatenating both leaves behind a blank line the delete should have taken.
1019
- successor.leadingTrivia = successor.leadingTrivia || deleted.leadingTrivia;
1020
- }
1021
- parent.children.splice(blockIndex, 1);
1022
- clearRedundantSeparator(parent, blockIndex, sharing);
1023
- // BOTH the survivor's edges: the delete puts it beside a new follower, and a merge door that
1024
- // rewrote its bytes can equally have stopped it interrupting the block above.
1025
- const survivor = Math.max(blockIndex - 1, 0);
1026
- return absorbWindowSeams(parent, survivor, blockIndex - survivor, blockIndex, { op: 'delete', at: blockIndex, count: 1 }, sharing).change;
1027
- }
1028
- /**
1029
- * Update raw and re-parse. The sole re-parse transfer funnel: a kind change mints the reparsed
1030
- * block into the slot rather than reassigning `kind` in place, and multi-block text mints every
1031
- * parsed block. Only a same-kind single-block edit writes fields in place, so routine typing keeps
1032
- * the node's object identity; `replacePreservingFirst` carries the id/ref across a mint. `sharing`
1033
- * owns the separator settle's writes, which land on the run's OTHER blocks.
1034
- */
1035
- export function updateNodeContent(parent, blockIndex, text, grammar, sharing) {
1036
- const wasBlank = isBlankParagraph(parent.children[blockIndex]);
1037
- const change = writeParsedContent(parent, blockIndex, text, grammar);
1038
- const lastWritten = lastMintedIndex(change, blockIndex);
1039
- // One blank line served BOTH sides: it separated this block from the one above and stood in
1040
- // as the separator of the block beneath it. Ending it owes each their own.
1041
- if (wasBlank && !isBlankParagraph(parent.children[blockIndex])) {
1042
- restoreSeparatorOnFill(parent, blockIndex, sharing);
1043
- restoreSeparatorAfterBlank(parent, followerIndexAfter(change, blockIndex), sharing);
1044
- releaseWrapPeel(parent, lastWritten);
1045
- return settleWriteSeams(parent, blockIndex, lastWritten, change, sharing);
1046
- }
1047
- // The reverse transition: the block IS the separating line now, so the run it joins gives
1048
- // back the second one. The last block minted is the one that meets the follower.
1049
- if (!wasBlank && isBlankParagraph(parent.children[lastWritten])) {
1050
- const settled = parent.children.length;
1051
- settleSeparatorOnBlank(parent, lastWritten, sharing);
1052
- const widened = widenForTailMint(change, settled, parent.children.length);
1053
- return settleWriteSeams(parent, blockIndex, lastWritten, widened, sharing);
1054
- }
1055
- // Both blank transitions settle above whatever their change op says; this guard is for
1056
- // same-kind typing INSIDE content, which must never pay a neighbour reparse.
1057
- if (change.op === 'noop')
1058
- return { change, textStart: 0 };
1059
- return settleWriteSeams(parent, blockIndex, lastWritten, change, sharing);
1060
- }
1061
- /**
1062
- * Ask every join the write disturbed, both edges of its window included, and report where the
1063
- * written text ended up: an absorb ABOVE leaves the predecessor standing, so its bytes and the
1064
- * join newline now sit in front of the text the caret was typed into.
1065
- */
1066
- function settleWriteSeams(parent, blockIndex, lastWritten, change, sharing) {
1067
- const tracked = { index: blockIndex, offset: 0 };
1068
- const settled = absorbWindowSeams(parent, blockIndex, lastWritten - blockIndex + 1, blockIndex, change, sharing, tracked);
1069
- return {
1070
- change: settled.change,
1071
- textStart: textOffsetInWindow(parent.children, settled.change, tracked)
1072
- };
1073
- }
1074
- /**
1075
- * The tracked position as an offset in the settled window's committed text — the space every
1076
- * caret door measures in ({@link focusTargetInReplacement}), where the head block's own
1077
- * leading trivia is outside the window.
1078
- */
1079
- function textOffsetInWindow(children, change, tracked) {
1080
- const at = change.op === 'noop' ? tracked.index : change.at;
1081
- let pos = 0;
1082
- for (let i = at; i < tracked.index; i++) {
1083
- pos += (i === at ? 0 : children[i].leadingTrivia.length) + children[i].raw.length;
1084
- }
1085
- const ownTrivia = tracked.index > at ? children[tracked.index].leadingTrivia.length : 0;
1086
- return pos + ownTrivia + tracked.offset;
1087
- }
1088
- /**
1089
- * The absorbed window folded into the write's own, as the ONE contiguous window the sink
1090
- * reports: `count` counts pre-write slots, so the union's span converts back across whatever
1091
- * the write itself added or removed.
1092
- */
1093
- function foldAbsorbIntoChange(change, seam) {
1094
- const absorbedTo = seam.at + seam.span + seam.eaten;
1095
- if (change.op === 'noop') {
1096
- return {
1097
- op: 'replace',
1098
- at: seam.at,
1099
- count: absorbedTo - seam.at,
1100
- newCount: seam.span,
1101
- idMap: { 0: 0 }
1102
- };
1103
- }
1104
- const written = change.op === 'insert' ? change.count : change.op === 'delete' ? 0 : change.newCount;
1105
- const removed = change.op === 'insert' ? 0 : change.count;
1106
- const lo = Math.min(seam.at, change.at);
1107
- const hi = Math.max(absorbedTo, change.at + written);
1108
- const count = hi - lo - written + removed;
1109
- const newCount = hi - lo - seam.eaten;
1110
- return {
1111
- op: 'replace',
1112
- at: lo,
1113
- count,
1114
- newCount,
1115
- idMap: composeFoldIdMap(change, seam, { lo, count, newCount, written, removed })
1116
- };
1117
- }
1118
- /**
1119
- * Identity through the fold: a slot the absorb did not re-mint still holds the block the change
1120
- * put there, so its id composes through both steps instead of resetting. Slot 0 keeps the head
1121
- * mapping wherever the walk has none — a fold EXTENDS its head, kind promotion included, which
1122
- * is the identity `replacePreservingFirst` reports.
1123
- */
1124
- function composeFoldIdMap(change, seam, window) {
1125
- const idMap = {};
1126
- for (let slot = 0; slot < window.newCount; slot++) {
1127
- const index = window.lo + slot;
1128
- if (index >= seam.at && index < seam.at + seam.span)
1129
- continue;
1130
- // Back through the fold: a slot past the absorbed span sat `eaten` further down before it.
1131
- const spliced = index < seam.at ? index : index + seam.eaten;
1132
- const old = preChangeIndex(change, spliced, window);
1133
- if (old === null)
1134
- continue;
1135
- const oldSlot = old - window.lo;
1136
- if (oldSlot >= 0 && oldSlot < window.count)
1137
- idMap[slot] = oldSlot;
1138
- }
1139
- if (idMap[0] === undefined)
1140
- idMap[0] = 0;
1141
- return idMap;
1142
- }
1143
- /** Where `spliced` (a post-change index) stood before the change, or null for a slot it minted. */
1144
- function preChangeIndex(change, spliced, window) {
1145
- if (change.op === 'noop')
1146
- return spliced;
1147
- if (spliced < change.at)
1148
- return spliced;
1149
- if (spliced >= change.at + window.written)
1150
- return spliced - window.written + window.removed;
1151
- const inherited = change.op === 'replace' ? change.idMap?.[spliced - change.at] : undefined;
1152
- return inherited === undefined ? null : change.at + inherited;
1153
- }
1154
- /** Where the filled block's follower ended up: a multi-block reparse pushes it down. */
1155
- function followerIndexAfter(change, blockIndex) {
1156
- return change.op === 'replace' ? change.at + change.newCount : blockIndex + 1;
1157
- }
1158
- /** The last block the write left in the slot: a multi-block reparse mints past the first. */
1159
- function lastMintedIndex(change, blockIndex) {
1160
- return change.op === 'replace' ? change.at + change.newCount - 1 : blockIndex;
1161
- }
1162
- /**
1163
- * The written bytes parsed, with the construct they leave OPEN closed off first. An unterminated
1164
- * construct reads every block below it as its body at the next parse, and the seam settle
1165
- * converges the live tree to exactly that reading. Declines where nothing is at stake: a
1166
- * kind-stable single block never reaches the settle, and a construct with no follower eats
1167
- * nothing.
1168
- */
1169
- function closeWrittenConstruct(parent, blockIndex, text, oldKind, grammar) {
1170
- // An absent grammar defaults to the global one. Fragment scope: this is one block's
1171
- // bytes, whatever its position, so a position-scoped kind must not mint here.
1172
- const parsed = parse(text, { grammar, scope: 'fragment' });
1173
- if (blockIndex + 1 >= parent.children.length)
1174
- return { text, parsed };
1175
- if (parsed.children.length === 1 && parsed.children[0].kind === oldKind)
1176
- return { text, parsed };
1177
- const terminator = openConstructTerminator(text, parsed.children, grammar);
1178
- if (!terminator)
1179
- return { text, parsed };
1180
- const closed = text + terminator;
1181
- return { text: closed, parsed: parse(closed, { grammar, scope: 'fragment' }) };
1182
- }
1183
- /**
1184
- * The terminator written bytes owe when their last construct absorbs to EOF, asked of the grammar
1185
- * rather than a kind list: swallowing a blank line AND a prose line is what separates an absorber
1186
- * from a paragraph continuing onto the next line. Null when the bytes terminate themselves, or
1187
- * when no fence opener explains the absorb — the one family whose closer its opener determines.
1188
- */
1189
- function openConstructTerminator(text, blocks, grammar) {
1190
- // The terminator is a line of its own, so bytes whose last line is still open have none to
1191
- // append to — G4.20's unterminated tail slice, which absorbs nothing while it stands alone.
1192
- if (!text.endsWith('\n') || blocks.length === 0)
1193
- return null;
1194
- const ending = trailingLineEnding(text);
1195
- const probe = parse(text + ending + NEXT_PROSE_LINE + ending, { grammar, scope: 'fragment' });
1196
- if (probe.children.length !== blocks.length)
1197
- return null;
1198
- const raw = blocks[blocks.length - 1].raw;
1199
- const nl = raw.indexOf('\n');
1200
- const opener = matchFenceOpen(nl < 0 ? raw : raw.slice(0, nl));
1201
- if (!opener)
1202
- return null;
1203
- const body = nl < 0 ? '' : raw.slice(nl + 1);
1204
- const run = escalatedFenceLength(body, opener.marker, opener.length);
1205
- return opener.indent + opener.marker.repeat(run) + ending;
1206
- }
1207
- function writeParsedContent(parent, blockIndex, text, grammar) {
1208
- const node = parent.children[blockIndex];
1209
- const oldKind = node.kind;
1210
- const oldDescriptor = getBlockKindDescriptor(oldKind);
1211
- // Ahead of every reparse below, so a write lands on the kind its committed bytes
1212
- // describe, not the kind the pre-escape text would parse to (`bodyWrite`).
1213
- const bodyText = forBody(parent, text);
1214
- // A context-dependent kind (tableCell, plugin chrome) has no standalone recognizer, so
1215
- // reparsing would downgrade it: keep the kind and write raw through the kind's own
1216
- // legality pass, since a delimiter arriving bare would restructure the container.
1217
- if (oldDescriptor.contextDependentKind) {
1218
- writeOwnRaw(node, bodyText, grammar);
1219
- return { op: 'noop' };
1220
- }
1221
- const { text: newText, parsed: reparsed } = closeWrittenConstruct(parent, blockIndex, bodyText, oldKind, grammar);
1222
- const parsed = reparsed.children;
1223
- const first = parsed[0];
1224
- // A container whose empty body will be backfilled: a marker-consuming container (a
1225
- // GitHub alert) needs its raw rebuilt from that body, or G1.1 stale-raw fires.
1226
- const firstBackfilled = !!first && isEmptyEditableContainer(first);
1227
- if (first)
1228
- ensureEditableContainers(first);
1229
- // Text-leading blanks fold into the first block's raw (the single-block shape); the
1230
- // rest keep their own trivia.
1231
- if (parsed.length > 1) {
1232
- const rest = parsed.slice(1);
1233
- for (const sibling of rest)
1234
- ensureEditableContainers(sibling);
1235
- first.raw = first.leadingTrivia + first.raw;
1236
- first.leadingTrivia = node.leadingTrivia;
1237
- if (firstBackfilled)
1238
- reconcileBackfilledRaw(first);
1239
- // The peeled line has no follower inside the splice, so it stays in raw.
1240
- rest[rest.length - 1].raw += reparsed.suffix;
1241
- spliceMany(parent.children, blockIndex, 1, parsed);
1242
- return replacePreservingFirst(blockIndex, 1, parsed.length);
1243
- }
1244
- const newKind = first?.kind ?? 'paragraph';
1245
- // In-place refresh keeps the node's object identity: component, IME state, and inline
1246
- // cache are all keyed on it.
1247
- if (newKind === oldKind) {
1248
- node.raw = newText;
1249
- adoptReparsedFields(node, first);
1250
- if (firstBackfilled)
1251
- reconcileBackfilledRaw(node);
1252
- return { op: 'noop' };
1253
- }
1254
- const replacement = first ?? { kind: 'paragraph', leadingTrivia: '', raw: newText };
1255
- replacement.raw = newText;
1256
- replacement.leadingTrivia = node.leadingTrivia;
1257
- if (firstBackfilled)
1258
- reconcileBackfilledRaw(replacement);
1259
- parent.children.splice(blockIndex, 1, replacement);
1260
- return replacePreservingFirst(blockIndex, 1, 1);
1261
- }
1262
- // ── Container kind re-derivation ──
1263
- /**
1264
- * What the grammar opens `line` as, read in isolation — the opener-significance test, asked of
1265
- * the opener registry and never a kind list, so a kind registered later is covered the day it
1266
- * registers.
1267
- */
1268
- export function lineOpensAs(line, grammar) {
1269
- return parse(`${line}\n`, { grammar, scope: 'fragment' }).children[0]?.kind ?? 'paragraph';
1270
- }
1271
- /**
1272
- * Re-derive the container at `index` from its own (already rebuilt) raw, replacing it in
1273
- * the slot when that raw now opens as a different kind. The container twin of
1274
- * `updateNodeContent`'s kind-change arm (editor.md § 8), which no leaf reparse can see.
1275
- * Eligibility is the opener registry, not a kind list — registering an opener is exactly
1276
- * the claim that `parse(raw)` reproduces the kind, so re-deriving one without would destroy it.
1277
- */
1278
- export function reclassifyContainer(parent, index, grammar) {
1279
- const node = parent.children[index];
1280
- if (!node)
1281
- return null;
1282
- const descriptor = tryGetBlockKindDescriptor(node.kind);
1283
- if (!descriptor?.isContainer || !isBlockOpenerRegistered(node.kind))
1284
- return null;
1285
- if (perfEnabled())
1286
- recordContainerKindReparse();
1287
- const parsed = parse(node.raw, { grammar, scope: 'fragment' }).children;
1288
- // A container's raw is one block by construction; a multi-block reparse means bytes
1289
- // this seam has no slot for — leave it to the gesture that owns the mutation.
1290
- if (parsed.length !== 1 || parsed[0].kind === node.kind)
1291
- return null;
1292
- const replacement = parsed[0];
1293
- const backfilled = isEmptyEditableContainer(replacement);
1294
- ensureEditableContainers(replacement);
1295
- // The slot's trivia is authoritative, so restore the bytes before overwriting it or
1296
- // anything the parse split off the front vanishes with it.
1297
- replacement.raw = node.raw;
1298
- replacement.leadingTrivia = node.leadingTrivia;
1299
- if (backfilled)
1300
- reconcileBackfilledRaw(replacement);
1301
- // A freshly-parsed node carries no childIds and this swap publishes under the slot's
1302
- // reused component instance, so undefined keys would reach the nested keyed `{#each}`.
1303
- assignChildIdsDeep(replacement);
1304
- parent.children[index] = replacement;
1305
- // Write-then-re-read (tree-operations/unshare.ts header).
1306
- return parent.children[index];
1307
- }
1308
- /**
1309
- * Map a post-edit caret offset (in the committed text) to the parsed block it falls in,
1310
- * as a local display offset. The first block's body starts at 0; an offset inside
1311
- * inter-block trivia lands at the next block's start; past-the-end clamps to the last.
1312
- */
1313
- export function focusTargetInReplacement(nodes, offset) {
1314
- let pos = 0;
1315
- for (let i = 0; i < nodes.length; i++) {
1316
- const bodyStart = i === 0 ? 0 : pos + nodes[i].leadingTrivia.length;
1317
- const bodyEnd = bodyStart + trimTrailingLineEnding(nodes[i].raw).length;
1318
- if (offset <= bodyEnd) {
1319
- return { index: i, offset: Math.max(0, offset - bodyStart) };
1320
- }
1321
- pos = bodyStart + nodes[i].raw.length;
1322
- }
1323
- const last = nodes.length - 1;
1324
- return { index: last, offset: trimTrailingLineEnding(nodes[last].raw).length };
1325
- }
1326
- /**
1327
- * Where a caret at `offset` in the written text lands once {@link updateNodeContent}'s folds
1328
- * settled: an absorb ABOVE leaves the predecessor holding the bytes, so the slot the gesture
1329
- * named is gone and the offset carries what that predecessor put in front of it. Every caret
1330
- * door onto the content funnel asks this, so the settle is answered in one place.
1331
- */
1332
- export function settledCaretTarget(settled, at, offset, children) {
1333
- const { change, textStart } = settled;
1334
- if (change.op !== 'replace')
1335
- return { index: at, offset };
1336
- const shifted = offset + textStart;
1337
- if (change.newCount <= 1)
1338
- return { index: change.at, offset: shifted };
1339
- const blocks = children.slice(change.at, change.at + change.newCount);
1340
- const target = focusTargetInReplacement(blocks, shifted);
1341
- return { index: change.at + target.index, offset: target.offset };
1342
- }
1343
336
  // ── Reparse helper (private) ──
1344
337
  /**
1345
338
  * Reparse a half's bytes as the blocks they hold, plus the trailing blank line the fragment
1346
- * parse peels into `doc.suffix` — every sink answers for it, or the bytes are lost. Plural
1347
- * because a half stands in a position its bytes never occupied, where a construct boundary
1348
- * can newly materialize.
339
+ * parse peels into `doc.suffix`: every sink answers for it, or the bytes are lost.
1349
340
  */
1350
341
  function reparseAsNodes(raw, leadingTrivia) {
1351
342
  const doc = parse(raw, { scope: 'fragment' });
@@ -1359,8 +350,7 @@ function reparseAsNodes(raw, leadingTrivia) {
1359
350
  }
1360
351
  /**
1361
352
  * The merge sinks' single-block twin, and their decline: a join whose bytes read as several
1362
- * blocks has no home in one slot, so it is refused rather than truncated (G1.35). Null is the
1363
- * refusal; the door returns noop and its caller falls back to move-focus.
353
+ * blocks has no home in one slot, so null refuses it rather than truncating (G1.35).
1364
354
  */
1365
355
  function reparseAsNode(raw, leadingTrivia) {
1366
356
  const { nodes, suffix } = reparseAsNodes(raw, leadingTrivia);
@@ -1370,59 +360,3 @@ function reparseAsNode(raw, leadingTrivia) {
1370
360
  nodes[0].raw += suffix;
1371
361
  return nodes[0];
1372
362
  }
1373
- // ── Replacement normalization ──
1374
- /** First node inherits the original block's leadingTrivia; subsequent nodes keep theirs. */
1375
- export function normalizeReplacementTrivia(original, replacement) {
1376
- const originalTrivia = original.leadingTrivia ?? '';
1377
- return replacement.map((node, i) => {
1378
- const copy = { ...node };
1379
- copy.leadingTrivia = i === 0 ? originalTrivia : (copy.leadingTrivia ?? '');
1380
- return copy;
1381
- });
1382
- }
1383
- // ── Editable container backfill ──
1384
- /**
1385
- * A container `ensureEditableContainers` will backfill. Read BEFORE the backfill runs —
1386
- * afterwards it holds the synthesized paragraph and no longer qualifies.
1387
- */
1388
- function isEmptyEditableContainer(node) {
1389
- const d = getBlockKindDescriptor(node.kind);
1390
- return d.isContainer && d.blockFocus !== 'whole-block' && (node.children?.length ?? 0) === 0;
1391
- }
1392
- /**
1393
- * Sync a just-backfilled container's `raw` to its synthesized body. Needed only for a
1394
- * marker-consuming container whose typed raw lacks a blank body line (`> [!TYPE]`);
1395
- * blockquote/listItem already strip to the blank, so their rebuild is a no-op.
1396
- */
1397
- function reconcileBackfilledRaw(node) {
1398
- getBlockKindDescriptor(node.kind).rebuildRaw?.(node);
1399
- }
1400
- /** Ensure every container has at least one child block, so the cursor always has a target. */
1401
- export function ensureEditableContainers(node) {
1402
- // A whole-block-focus kind is childless by design — the block itself is the caret
1403
- // target, and a backfilled paragraph its raw can't account for trips opaque-stale-raw.
1404
- if (getBlockKindDescriptor(node.kind).blockFocus === 'whole-block')
1405
- return;
1406
- if (node.children !== undefined) {
1407
- if (node.children.length === 0) {
1408
- // discovered-descendant mutation, see file header
1409
- const chromeKind = reservedChromeKindOf(node.kind);
1410
- // Backfilled lines take the container's own ending (G4.20) — they are pure
1411
- // line ending, so a literal LF strands one inside a CRLF container.
1412
- const lineEnding = trailingLineEnding(node.raw);
1413
- // A chrome-declaring container must re-mint its child-0 leaf too, or the
1414
- // backfilled paragraph would occupy the reserved slot and violate G1.14.
1415
- if (chromeKind !== undefined) {
1416
- // Runtime chrome kind, so the mint takes the generic cast.
1417
- node.children.push({ kind: chromeKind, leadingTrivia: '', raw: lineEnding });
1418
- }
1419
- node.children.push(emptyParagraph('', lineEnding));
1420
- // The synthesized paragraph's ending already represents the blank `parseBlocks`
1421
- // routed into innerPrefix; keeping both double-counts the line on rebuild.
1422
- node.innerPrefix = '';
1423
- }
1424
- for (const child of node.children) {
1425
- ensureEditableContainers(child);
1426
- }
1427
- }
1428
- }