@portabletext/markdown 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,10 +1,36 @@
1
+ import { cleanupEfficiency, makeDiff } from "@sanity/diff-match-patch";
1
2
  import { compileSchema, defineSchema, isSpan, isTextBlock, isTypedObject } from "@portabletext/schema";
2
- import { buildMarksTree, isPortableTextBlock, isPortableTextListItemBlock, isPortableTextToolkitSpan, isPortableTextToolkitTextNode, spanToPlainText } from "@portabletext/toolkit";
3
- import { alert } from "@mdit/plugin-alert";
3
+ import { buildMarksTree, isPortableTextBlock, isPortableTextListItemBlock, isPortableTextSpan, isPortableTextToolkitSpan, isPortableTextToolkitTextNode, spanToPlainText } from "@portabletext/toolkit";
4
+ import LinkifyIt from "linkify-it";
4
5
  import markdownit from "markdown-it";
6
+ import { alert } from "@mdit/plugin-alert";
5
7
  function defaultKeyGenerator() {
6
8
  return randomKey(12);
7
9
  }
10
+ /**
11
+ * Wraps a caller-supplied key generator so one conversion can never
12
+ * mint the same key twice. Keys attach content to its identity at
13
+ * creation time (a span's `marks` entry points at the mark definition
14
+ * minted with the same key), so a generator that repeats a key does
15
+ * not just violate sibling uniqueness, it makes ownership ambiguous in
16
+ * a way no later pass can repair: two definitions sharing a key leave
17
+ * every referencing span attributable to either. Bounded retries, then
18
+ * deterministic suffixing, mirroring how sibling-uniqueness repair
19
+ * treats a generator that keeps returning claimed keys.
20
+ */
21
+ function uniqueKeyGenerator(generator) {
22
+ let mintedKeys = /* @__PURE__ */ new Set();
23
+ return () => {
24
+ let candidate = generator();
25
+ for (let attempt = 0; attempt < 3 && mintedKeys.has(candidate); attempt++) candidate = generator();
26
+ if (mintedKeys.has(candidate)) {
27
+ let base = candidate, suffix = 2;
28
+ for (; mintedKeys.has(`${base}-${suffix}`);) suffix++;
29
+ candidate = `${base}-${suffix}`;
30
+ }
31
+ return mintedKeys.add(candidate), candidate;
32
+ };
33
+ }
8
34
  const getByteHexTable = (() => {
9
35
  let table;
10
36
  return () => {
@@ -91,27 +117,431 @@ function buildListIndexMap(blocks) {
91
117
  listDepthMap
92
118
  };
93
119
  }
94
- const createRenderNode = (renderers, listIndexMap, listDepthMap) => {
95
- function renderNode(options) {
96
- let { node, index, isInline } = options;
97
- return isPortableTextListItemBlock(node) ? renderListItem(node, index) : isPortableTextToolkitSpan(node) ? renderSpan(node) : isPortableTextBlock(node) ? renderBlock(node, index, isInline) : isPortableTextToolkitTextNode(node) ? renderText(node) : renderCustomBlock(node, index, isInline);
120
+ /**
121
+ * The CommonMark ASCII punctuation set. Only these characters can be
122
+ * backslash-escaped into a literal without changing the parsed text.
123
+ */
124
+ const ASCII_PUNCTUATION = /[!-/:-@[-`{-~]/, ENTITY_REFERENCE = /&(?:[a-zA-Z][a-zA-Z0-9]*|#[0-9]+|#[xX][0-9a-fA-F]+);/g, BACKSLASH_BEFORE_PUNCTUATION = RegExp(`\\\\(?=${ASCII_PUNCTUATION.source})`, "g"), TILDE_RUN = /~{2,}/g, HTML_LIKE_ANGLE_BRACKET = /<(?=[a-zA-Z/!?])/g, BRACKET_BEFORE_LINK_OPEN = /\](?=[([])/g, UNICODE_PUNCTUATION_OR_SYMBOL = /^(?:\p{P}|\p{S})$/u, linkify = new LinkifyIt();
125
+ /**
126
+ * Plans the escaped replacement for every plain-text leaf a block's children
127
+ * will produce, in the exact left-to-right order `renderText` visits them
128
+ * (mirroring `buildMarksTree`'s own `text.split('\n')` leaf splitting).
129
+ *
130
+ * Escaping runs ahead of rendering, over the flat span sequence: some
131
+ * hazards only exist across a leaf boundary (an ordered-list marker, a
132
+ * ref-def label, an emphasis run) because an annotation or decorator mark
133
+ * that introduces no markup of its own splices its children in seamlessly.
134
+ * The plan works line by line (a block's children joined into text, split
135
+ * at hard breaks) rather than leaf by leaf: each line's leaves are joined
136
+ * into one string first, opaque children (inline objects, or leaves a
137
+ * custom renderer will replace) masked with a sentinel that can't match any
138
+ * hazard, and every hazard - inline and line-start alike - is detected once
139
+ * against that real, complete line, with true left/right context on both
140
+ * sides. Detected hazards become position-tracked edits against the line's
141
+ * raw text, which are then split back into each contributing leaf's own
142
+ * escaped text; only that composition step is leaf-scoped.
143
+ *
144
+ * A joined line's text that markdown-it's own linkify pass (bundled as
145
+ * `linkify-it`) would claim as a bare URL or email is masked from most
146
+ * edits: the linkify carve-out promises that substring round-trips
147
+ * byte-identical, gaining only a link mark, so escaping inside it would
148
+ * corrupt text linkify is about to claim as a link's visible text. An
149
+ * entity-reference or backtick escape is never masked (see `computeLinkifyMask`
150
+ * for why), and a claim spliced across a decorator boundary is never masked
151
+ * in the first place.
152
+ *
153
+ * `isHeading` is set for ATX headings: only the first joined line sits
154
+ * inside the `# ` prefix an ATX heading can never be reparsed as a block
155
+ * construct within, so line-leading hazards are skipped there; a hard
156
+ * break's later lines are ordinary markdown lines and get the full
157
+ * line-start battery. That first line carries a line-*end* hazard of its
158
+ * own instead: a trailing `#`-run reads back as the heading's own optional
159
+ * closing sequence.
160
+ *
161
+ * `isListItem` is set when the block renders as list-item content: a
162
+ * `[ ] `/`[x] `/`[X] ` at the very start of the first joined line reads
163
+ * back as a GFM task-list checkbox, regardless of the list's own item type.
164
+ *
165
+ * `hardBreakOutputHasNewline` says whether the renderer's actual hard-break
166
+ * output contains a newline. A custom `hardBreak` can render to something
167
+ * with no newline of its own (eg `() => '<br />'`), in which case the
168
+ * leaves on either side of it land on the same rendered line, not two: a
169
+ * hard break like that can't be planned as a line boundary, so it's walled
170
+ * off as an opaque segment instead, the same protection an inline object's
171
+ * unknown rendered text already gets.
172
+ */
173
+ function planLeafEscaping(children, markDefs, options) {
174
+ let linkMarkKeys = new Set(markDefs.filter((def) => def._type === "link").map((def) => def._key)), markDefKeys = new Set(markDefs.map((def) => def._key)), pieces = [];
175
+ for (let child of children) if (isPortableTextSpan(child)) {
176
+ let isLinkLabel = (child.marks ?? []).some((mark) => linkMarkKeys.has(mark)), markSignature = (child.marks ?? []).filter((mark) => !markDefKeys.has(mark)).sort().join(",");
177
+ child.text.split("\n").forEach((line, index) => {
178
+ index > 0 && pieces.push(options.hardBreakOutputHasNewline ? { kind: "hardBreak" } : { kind: "opaque" }), pieces.push({
179
+ kind: "text",
180
+ raw: line,
181
+ isLinkLabel,
182
+ markSignature
183
+ });
184
+ });
185
+ } else pieces.push({ kind: "opaque" });
186
+ let pieceOutputs = pieces.map(() => ""), lineIndex = 0, lineText = "", lineChars = [], lineIsLinkLabelChar = [], lineMarkSignature = [], flushLine = () => {
187
+ processLine({
188
+ text: lineText,
189
+ chars: lineChars,
190
+ isLinkLabelChar: lineIsLinkLabelChar,
191
+ markSignature: lineMarkSignature,
192
+ lineIndex,
193
+ isHeading: options.isHeading,
194
+ isListItem: options.isListItem,
195
+ pieceOutputs
196
+ }), lineIndex++, lineText = "", lineChars = [], lineIsLinkLabelChar = [], lineMarkSignature = [];
197
+ };
198
+ for (let pieceIndex = 0; pieceIndex < pieces.length; pieceIndex++) {
199
+ let piece = pieces[pieceIndex];
200
+ if (!piece || piece.kind === "hardBreak") {
201
+ flushLine();
202
+ continue;
203
+ }
204
+ if (piece.kind === "opaque") {
205
+ lineText += "\0", lineChars.push(null), lineIsLinkLabelChar.push(!1), lineMarkSignature.push("");
206
+ continue;
207
+ }
208
+ let prepared = piece.isLinkLabel ? escapeLinkLabelBrackets(piece.raw) : piece.raw;
209
+ for (let offset = 0; offset < prepared.length; offset++) lineText += prepared[offset], lineChars.push({
210
+ pieceIndex,
211
+ offset
212
+ }), lineIsLinkLabelChar.push(piece.isLinkLabel), lineMarkSignature.push(piece.markSignature);
98
213
  }
99
- function renderListItem(node, index) {
100
- let renderer = renderers.listItem, itemHandler = (typeof renderer == "function" ? renderer : renderer[node.listItem]) || renderers.unknownListItem, children = buildMarksTree(node).map((child, i) => renderNode({
214
+ flushLine();
215
+ let escaped = [];
216
+ return pieces.forEach((piece, index) => {
217
+ piece.kind === "text" && escaped.push(pieceOutputs[index] ?? "");
218
+ }), escaped;
219
+ }
220
+ function processLine(args) {
221
+ let { text, chars, isLinkLabelChar, markSignature, pieceOutputs } = args, linkifyMask = computeLinkifyMask(text, chars, isLinkLabelChar, markSignature);
222
+ applyEdits(text, chars, [...collectInlineEdits(text, isLinkLabelChar), ...collectLineStartEdits(text, args)].filter((edit) => edit.bypassLinkifyMask || !isMasked(edit, linkifyMask)), pieceOutputs);
223
+ }
224
+ /**
225
+ * Marks every character of this line that markdown-it's linkify pass would
226
+ * claim as part of a bare URL or email. Link-label and opaque characters
227
+ * are blanked out first: a link label's visible text sits inside `[...]`
228
+ * markup real linkify never reconsiders, and an opaque child's rendered
229
+ * text is unknown at plan time, so neither should join or seed a match.
230
+ *
231
+ * The probe only sees this line's raw, undecoded text, one hazard pass
232
+ * ahead of markdown-it's own pipeline: it runs linkify against inline
233
+ * tokenization and entity decoding, not before them. A claim survives only
234
+ * if it lies entirely inside one run of identical decorator marks: a
235
+ * decorator boundary crossing it splices that decorator's delimiters
236
+ * (`**`, `` ` ``, ...) into the middle of the range real linkify would see,
237
+ * which breaks the very claim being trusted. An annotation-only boundary
238
+ * (a link's own label text is already excluded above; any other
239
+ * annotation type falls back to rendering with no delimiters at all,
240
+ * same as an unregistered decorator) never splices, so it can't invalidate
241
+ * a claim either.
242
+ */
243
+ function computeLinkifyMask(text, chars, isLinkLabelChar, markSignature) {
244
+ let mask = Array(text.length).fill(!1);
245
+ if (!/[.:@]/.test(text)) return mask;
246
+ let probe = "";
247
+ for (let index = 0; index < text.length; index++) probe += chars[index] === null || isLinkLabelChar[index] ? " " : text[index];
248
+ let matches = linkify.match(probe) ?? [];
249
+ for (let match of matches) {
250
+ if (match.schema === "") continue;
251
+ let signature = markSignature[match.index], staysWithinOneMarkRun = !0;
252
+ for (let index = match.index; index < match.lastIndex; index++) if (markSignature[index] !== signature) {
253
+ staysWithinOneMarkRun = !1;
254
+ break;
255
+ }
256
+ if (staysWithinOneMarkRun) for (let index = match.index; index < match.lastIndex; index++) mask[index] = !0;
257
+ }
258
+ return mask;
259
+ }
260
+ function isMasked(edit, mask) {
261
+ let end = edit.at + Math.max(edit.deleteCount, 1);
262
+ for (let index = edit.at; index < end; index++) if (mask[index]) return !0;
263
+ return !1;
264
+ }
265
+ /** Rewrites a line's raw text into each contributing leaf's escaped text by
266
+ * walking it once, left to right, applying at most one edit per position.
267
+ * Every hazard is keyed off its own trigger character - a backslash, a
268
+ * tilde, a backtick, an `&`, a `<`, a `*`/`_`, a `]`, or (line-start only,
269
+ * one hazard per line) a `#`, `>`, `[`, `-`/`+`/`*`, the `.`/`)` after an
270
+ * ordered-list marker's digits, `=`, 4 spaces, or a tab - and no two of
271
+ * those characters coincide at one position, so two edits can never target
272
+ * the same position. */
273
+ function applyEdits(text, chars, edits, pieceOutputs) {
274
+ let editsByPosition = /* @__PURE__ */ new Map();
275
+ for (let edit of edits) {
276
+ if (editsByPosition.has(edit.at)) throw Error(`Two hazard edits targeted the same position (${edit.at}); hazard trigger characters are assumed disjoint by construction.`);
277
+ editsByPosition.set(edit.at, edit);
278
+ }
279
+ let index = 0;
280
+ for (; index < text.length;) {
281
+ let edit = editsByPosition.get(index), owner = chars[index];
282
+ if (edit && (owner && (pieceOutputs[owner.pieceIndex] = (pieceOutputs[owner.pieceIndex] ?? "") + edit.insert), edit.deleteCount > 0)) {
283
+ index += edit.deleteCount;
284
+ continue;
285
+ }
286
+ owner && (pieceOutputs[owner.pieceIndex] = (pieceOutputs[owner.pieceIndex] ?? "") + (text[index] ?? "")), index++;
287
+ }
288
+ }
289
+ /**
290
+ * Hazards that can appear anywhere on a line: emphasis/strikethrough runs,
291
+ * a backtick, an entity reference, an HTML/autolink-shaped `<`, a literal
292
+ * backslash before punctuation, and a `]` immediately before `(`/`[`
293
+ * (which would otherwise read back as a link/image open).
294
+ */
295
+ function collectInlineEdits(text, isLinkLabelChar) {
296
+ let edits = [];
297
+ for (let match of text.matchAll(BACKSLASH_BEFORE_PUNCTUATION)) {
298
+ let at = match.index ?? 0;
299
+ isLinkLabelChar[at] || edits.push({
300
+ at,
301
+ deleteCount: 0,
302
+ insert: "\\"
303
+ });
304
+ }
305
+ for (let match of text.matchAll(TILDE_RUN)) {
306
+ let start = match.index ?? 0;
307
+ for (let index = start; index < start + match[0].length; index++) edits.push({
308
+ at: index,
309
+ deleteCount: 0,
310
+ insert: "\\"
311
+ });
312
+ }
313
+ for (let index = 0; index < text.length; index++) text[index] === "`" && edits.push({
314
+ at: index,
315
+ deleteCount: 0,
316
+ insert: "\\",
317
+ bypassLinkifyMask: !0
318
+ });
319
+ for (let match of text.matchAll(ENTITY_REFERENCE)) edits.push({
320
+ at: match.index ?? 0,
321
+ deleteCount: 0,
322
+ insert: "\\",
323
+ bypassLinkifyMask: !0
324
+ });
325
+ for (let match of text.matchAll(HTML_LIKE_ANGLE_BRACKET)) edits.push({
326
+ at: match.index ?? 0,
327
+ deleteCount: 0,
328
+ insert: "\\"
329
+ });
330
+ edits.push(...collectEmphasisEdits(text));
331
+ for (let match of text.matchAll(BRACKET_BEFORE_LINK_OPEN)) {
332
+ let at = match.index ?? 0;
333
+ isLinkLabelChar[at] || edits.push({
334
+ at,
335
+ deleteCount: 0,
336
+ insert: "\\"
337
+ });
338
+ }
339
+ return edits;
340
+ }
341
+ function isWhitespace(char) {
342
+ return char === void 0 || /\s/.test(char);
343
+ }
344
+ function isPunctuation(char) {
345
+ return char !== void 0 && UNICODE_PUNCTUATION_OR_SYMBOL.test(char);
346
+ }
347
+ /**
348
+ * The full code point sitting immediately before `index`: two UTF-16 code
349
+ * units for an astral character (eg an emoji) whose low surrogate lands at
350
+ * `index - 1`, one otherwise.
351
+ */
352
+ function codePointBefore(text, index) {
353
+ if (!(index <= 0)) return index >= 2 && isLowSurrogate(text[index - 1]) && isHighSurrogate(text[index - 2]) ? text.slice(index - 2, index) : text[index - 1];
354
+ }
355
+ /**
356
+ * The full code point sitting immediately at `index`: two UTF-16 code units
357
+ * for an astral character whose high surrogate lands at `index`, one
358
+ * otherwise.
359
+ */
360
+ function codePointAt(text, index) {
361
+ if (!(index >= text.length)) return isHighSurrogate(text[index]) && isLowSurrogate(text[index + 1]) ? text.slice(index, index + 2) : text[index];
362
+ }
363
+ function isHighSurrogate(char) {
364
+ if (char === void 0) return !1;
365
+ let code = char.charCodeAt(0);
366
+ return code >= 55296 && code <= 56319;
367
+ }
368
+ function isLowSurrogate(char) {
369
+ if (char === void 0) return !1;
370
+ let code = char.charCodeAt(0);
371
+ return code >= 56320 && code <= 57343;
372
+ }
373
+ function isLeftFlanking(before, after) {
374
+ return isWhitespace(after) ? !1 : !isPunctuation(after) || isWhitespace(before) || isPunctuation(before);
375
+ }
376
+ function isRightFlanking(before, after) {
377
+ return isWhitespace(before) ? !1 : !isPunctuation(before) || isWhitespace(after) || isPunctuation(after);
378
+ }
379
+ /**
380
+ * Finds `*`/`_` runs CommonMark would treat as flanking delimiters, using
381
+ * each run's true neighbors on the joined line (the start/end of the line
382
+ * itself counts as whitespace, matching the spec's treatment of line
383
+ * boundaries).
384
+ */
385
+ function collectEmphasisEdits(text) {
386
+ let edits = [], index = 0;
387
+ for (; index < text.length;) {
388
+ let char = text[index];
389
+ if (char !== "*" && char !== "_") {
390
+ index++;
391
+ continue;
392
+ }
393
+ let end = index;
394
+ for (; end < text.length && text[end] === char;) end++;
395
+ let before = codePointBefore(text, index), after = codePointAt(text, end), leftFlanking = isLeftFlanking(before, after), rightFlanking = isRightFlanking(before, after), canOpen = char === "_" ? leftFlanking && (!rightFlanking || isPunctuation(before)) : leftFlanking, canClose = char === "_" ? rightFlanking && (!leftFlanking || isPunctuation(after)) : rightFlanking;
396
+ if (canOpen || canClose) for (let position = index; position < end; position++) edits.push({
397
+ at: position,
398
+ deleteCount: 0,
399
+ insert: "\\"
400
+ });
401
+ index = end;
402
+ }
403
+ return edits;
404
+ }
405
+ /**
406
+ * Hazards that only matter at the start (or, for a handful of whole-line
407
+ * constructs, the start *and* end) of a line: headings, blockquotes, list
408
+ * markers, ref-defs, setext underlines, thematic breaks, indented code, and
409
+ * a list item's own GFM task-checkbox prefix. A fence needs no branch of
410
+ * its own here: the inline backtick/tilde escaping every line already
411
+ * neutralizes the run a fence needs, so it can never open one on reparse.
412
+ * The remaining branches are mutually exclusive by construction
413
+ * (each targets a disjoint leading character) and return as soon as one
414
+ * matches, mirroring how CommonMark itself commits to one block-start
415
+ * interpretation per line; the checkbox branch above is the one exception,
416
+ * since a list item's checkbox prefix and, say, its heading marker are two
417
+ * independent hazards that can both apply to the same first line.
418
+ */
419
+ function collectLineStartEdits(text, context) {
420
+ let edits = [], isFirstLine = context.lineIndex === 0, leadingSpaces = /^ {0,3}/.exec(text)?.[0].length ?? 0, rest = text.slice(leadingSpaces);
421
+ if (context.isListItem && isFirstLine && /^\[[ xX]\] /.test(rest) && edits.push({
422
+ at: leadingSpaces,
423
+ deleteCount: 0,
424
+ insert: "\\"
425
+ }), context.isHeading && isFirstLine) {
426
+ let closingSequence = /^(?:(.*[ \t]))?(#+[ \t]*)$/.exec(text);
427
+ return closingSequence && edits.push({
428
+ at: closingSequence[1]?.length ?? 0,
429
+ deleteCount: 0,
430
+ insert: "\\"
431
+ }), edits;
432
+ }
433
+ let orderedListMarker = /^ {0,3}(\d{1,9})([.)])(?=[ \t]|$)/.exec(text);
434
+ if (orderedListMarker) return edits.push({
435
+ at: orderedListMarker[0].length - 1,
436
+ deleteCount: 0,
437
+ insert: "\\"
438
+ }), edits;
439
+ if (/^#{1,6}(?:[ \t]|$)/.test(rest) || rest.startsWith(">") || /^\[[^\]\n]*\]:/.test(rest) || /^[-+*](?:[ \t]|$)/.test(rest) || /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/.test(text) || /^ {0,3}=+[ \t]*$/.test(text) || /^ {0,3}-+[ \t]*$/.test(text)) return edits.push({
440
+ at: leadingSpaces,
441
+ deleteCount: 0,
442
+ insert: "\\"
443
+ }), edits;
444
+ if (/^ {4}/.test(text)) return edits.push({
445
+ at: 0,
446
+ deleteCount: 1,
447
+ insert: "&#32;"
448
+ }), edits;
449
+ let tabIndent = /^ {0,3}\t/.exec(text);
450
+ return tabIndent && edits.push({
451
+ at: tabIndent[0].length - 1,
452
+ deleteCount: 1,
453
+ insert: "&#9;"
454
+ }), edits;
455
+ }
456
+ /**
457
+ * Text rendered inside a link label needs every `[`, `]` and `\` escaped
458
+ * unconditionally, on top of the general-purpose hazard escaping every
459
+ * line gets: a link label must stay bracket-balanced, and any literal
460
+ * backslash in it needs protecting regardless of what follows (unlike
461
+ * plain text, where only a backslash immediately before punctuation is a
462
+ * hazard).
463
+ */
464
+ function escapeLinkLabelBrackets(text) {
465
+ return text.replace(/[[\]\\]/g, (char) => `\\${char}`);
466
+ }
467
+ /**
468
+ * Blocks currently known to be a list item's first content block: it shares
469
+ * its first line with the list marker (and, for a task item, its GFM
470
+ * checkbox), which changes how `renderBlock` plans line-start hazard
471
+ * escaping. Internal to this package so the signal never reaches the
472
+ * public `Serializable`/`RenderNode` types a custom renderer's `.d.ts`
473
+ * would otherwise expose it through.
474
+ *
475
+ * A block is marked right before rendering it; the `renderNode` call that
476
+ * dispatches to `renderBlock` consumes the membership on the way past so a
477
+ * later, unrelated render of the same object (still possible - `renderNode`
478
+ * accepts any `TypedObject`) doesn't inherit a stale claim.
479
+ */
480
+ const listItemFirstBlocks = /* @__PURE__ */ new WeakSet();
481
+ function markListItemFirstBlock(block) {
482
+ listItemFirstBlocks.add(block);
483
+ }
484
+ function consumeListItemFirstBlock(block) {
485
+ let isListItemFirstBlock = listItemFirstBlocks.has(block);
486
+ return listItemFirstBlocks.delete(block), isListItemFirstBlock;
487
+ }
488
+ /**
489
+ * ATX headings are single-line, inline-only leaf blocks: an ATX heading's
490
+ * first line sits inside its `# ` prefix and can never be reparsed as a
491
+ * block construct, so line-leading hazards never apply there. A hard
492
+ * break's later lines are ordinary markdown lines outside that prefix and
493
+ * get the full line-start battery, same as any other block's continuation.
494
+ */
495
+ const HEADING_STYLES = /* @__PURE__ */ new Set([
496
+ "h1",
497
+ "h2",
498
+ "h3",
499
+ "h4",
500
+ "h5",
501
+ "h6"
502
+ ]), createRenderNode = (renderers, listIndexMap, listDepthMap) => {
503
+ let escapedTextByNode = /* @__PURE__ */ new WeakMap(), hardBreakOutputHasNewline = renderers.hardBreak().includes("\n");
504
+ function renderBlockChildren(node, isHeading, isListItem = !1) {
505
+ let chunks = planLeafEscaping(node.children ?? [], node.markDefs ?? [], {
506
+ isHeading,
507
+ isListItem,
508
+ hardBreakOutputHasNewline
509
+ }), tree = buildMarksTree(node);
510
+ return assignEscapedText(tree, chunks), tree.map((child, i) => renderNode({
101
511
  node: child,
102
512
  isInline: !0,
103
513
  index: i,
104
514
  renderNode
105
515
  })).join("");
516
+ }
517
+ function assignEscapedText(nodes, chunks) {
518
+ let pointer = 0, visit = (node) => {
519
+ if (isPortableTextToolkitTextNode(node)) {
520
+ if (node.text !== "\n") {
521
+ let escaped = chunks[pointer];
522
+ escaped !== void 0 && escapedTextByNode.set(node, escaped), pointer++;
523
+ }
524
+ return;
525
+ }
526
+ isPortableTextToolkitSpan(node) && node.children.forEach(visit);
527
+ };
528
+ nodes.forEach(visit);
529
+ }
530
+ function renderNode(options) {
531
+ let { node, index, isInline } = options;
532
+ return isPortableTextListItemBlock(node) ? renderListItem(node, index) : isPortableTextToolkitSpan(node) ? renderSpan(node) : isPortableTextBlock(node) ? renderBlock(node, index, isInline, consumeListItemFirstBlock(node)) : isPortableTextToolkitTextNode(node) ? renderText(node) : renderCustomBlock(node, index, isInline);
533
+ }
534
+ function renderListItem(node, index) {
535
+ let renderer = renderers.listItem, itemHandler = (typeof renderer == "function" ? renderer : renderer[node.listItem]) || renderers.unknownListItem, children;
106
536
  if (node.style && node.style !== "normal") {
107
537
  let { listItem: _listItem, ...blockNode } = node;
108
- children = renderNode({
538
+ markListItemFirstBlock(blockNode), children = renderNode({
109
539
  node: blockNode,
110
540
  index,
111
541
  isInline: !1,
112
542
  renderNode
113
543
  }), children = children.replace(/\n+$/, "");
114
- }
544
+ } else children = renderBlockChildren(node, !1, !0);
115
545
  return itemHandler({
116
546
  value: node,
117
547
  index,
@@ -138,21 +568,18 @@ const createRenderNode = (renderers, listIndexMap, listDepthMap) => {
138
568
  children: children.join("")
139
569
  });
140
570
  }
141
- function renderBlock(node, index, isInline) {
142
- let { _key, ...props } = serializeBlock({
143
- node,
571
+ function renderBlock(node, index, isInline, isListItem) {
572
+ let style = node.style || "normal", children = renderBlockChildren(node, HEADING_STYLES.has(style), isListItem);
573
+ return ((typeof renderers.block == "function" ? renderers.block : renderers.block[style]) || renderers.unknownBlockStyle)({
144
574
  index,
145
575
  isInline,
146
- renderNode
147
- }), style = props.node.style || "normal";
148
- return ((typeof renderers.block == "function" ? renderers.block : renderers.block[style]) || renderers.unknownBlockStyle)({
149
- ...props,
150
- value: props.node,
576
+ children,
577
+ value: node,
151
578
  renderNode
152
579
  });
153
580
  }
154
581
  function renderText(node) {
155
- return node.text === "\n" ? renderers.hardBreak() : node.text;
582
+ return node.text === "\n" ? renderers.hardBreak() : escapedTextByNode.get(node) ?? node.text;
156
583
  }
157
584
  function renderCustomBlock(value, index, isInline) {
158
585
  return (renderers.types[value._type] ?? renderers.unknownType)({
@@ -163,26 +590,7 @@ const createRenderNode = (renderers, listIndexMap, listDepthMap) => {
163
590
  });
164
591
  }
165
592
  return renderNode;
166
- };
167
- function serializeBlock(options) {
168
- let { node, index, isInline, renderNode } = options, renderedChildren = buildMarksTree(node).map((child, i) => renderNode({
169
- node: child,
170
- isInline: !0,
171
- index: i,
172
- renderNode
173
- }));
174
- return {
175
- _key: node._key || defaultKeyGenerator(),
176
- children: renderedChildren.join(""),
177
- index,
178
- isInline,
179
- node
180
- };
181
- }
182
- /**
183
- * @public
184
- */
185
- const DefaultBlockSpacingRenderer = ({ current, next }) => isPortableTextListItemBlock(current) && isPortableTextListItemBlock(next) ? "\n" : isPortableTextBlock(current) && isPortableTextBlock(next) && current.style === "blockquote" && next.style === "blockquote" ? "\n>\n" : "\n\n", DefaultHardBreakRenderer = () => " \n", DefaultListItemRenderer = ({ children, value, listIndex, listDepth }) => {
593
+ }, DefaultBlockSpacingRenderer = ({ current, next }) => isPortableTextListItemBlock(current) && isPortableTextListItemBlock(next) ? "\n" : isPortableTextBlock(current) && isPortableTextBlock(next) && current.style === "blockquote" && next.style === "blockquote" ? "\n>\n" : "\n\n", DefaultHardBreakRenderer = () => " \n", DefaultListItemRenderer = ({ children, value, listIndex, listDepth }) => {
186
594
  let listStyle = value.listItem || "bullet", depth = listDepth ?? (value.level || 1) - 1, indent = " ".repeat(depth);
187
595
  return listStyle === "number" ? `${indent}${listIndex ?? 1}. ${children}` : listStyle === "task" ? `${indent}- ${"checked" in value && typeof value.checked == "boolean" && value.checked ? "[x]" : "[ ]"} ${children}` : `${indent}- ${children}`;
188
596
  }, DefaultUnknownListItemRenderer = ({ children }) => `- ${children}\n`;
@@ -208,32 +616,39 @@ function escapeImageAndLinkTitle(text) {
208
616
  * Escapes characters that have special meaning at the row level of a GFM
209
617
  * table cell.
210
618
  *
211
- * A literal `|` ends the cell, so unescaped pipes are replaced with `\|`.
212
- * Newlines end the row, so they are replaced with `<br>` to keep the
213
- * visible line break inside the cell. Already-escaped pipes (`\|`) are
214
- * left intact so that escapes introduced by mark renderers survive the
215
- * pass.
619
+ * A literal `|` ends the cell, so a pipe preceded by an even number of
620
+ * backslashes (including zero) gets one more: paired backslashes cancel
621
+ * out to a literal backslash and leave the pipe live, so parity, not mere
622
+ * presence, decides whether it is already escaped. Newlines end the row,
623
+ * so they are replaced with `<br>` to keep the visible line break inside
624
+ * the cell.
216
625
  *
217
- * Backslashes are intentionally not escaped here so that other escapes
218
- * already in the rendered cell (such as `\[` and `\]` in link text) are
219
- * not double-escaped.
626
+ * Backslashes themselves are left alone here; only the parity check reads
627
+ * them, so escapes already in the rendered cell (such as `\[` and `\]` in
628
+ * link text) survive the pass untouched.
220
629
  */
221
630
  function escapeTableCell(text) {
222
- return text.replace(RegExp("(?<!\\\\)\\|", "g"), "\\|").replace(/\n/g, "<br>");
631
+ return text.replace(/(\\*)\|/g, (match, backslashes) => backslashes.length % 2 == 0 ? `${backslashes}\\|` : match).replace(/\n/g, "<br>");
632
+ }
633
+ /**
634
+ * @public
635
+ */
636
+ const DefaultEmRenderer = ({ children }) => `_${children}_`, DefaultStrongRenderer = ({ children }) => `**${children}**`, DefaultCodeRenderer = ({ text }) => wrapInCodeSpan(text);
637
+ function wrapInCodeSpan(text) {
638
+ let fence = "`".repeat(longestBacktickRun(text) + 1), touchesBacktick = text.startsWith("`") || text.endsWith("`"), wouldBeStripped = text.startsWith(" ") && text.endsWith(" ") && text.trim() !== "", padding = touchesBacktick || wouldBeStripped ? " " : "";
639
+ return `${fence}${padding}${text}${padding}${fence}`;
640
+ }
641
+ function longestBacktickRun(text) {
642
+ let longest = 0;
643
+ for (let run of text.match(/`+/g) ?? []) longest = Math.max(longest, run.length);
644
+ return longest;
223
645
  }
224
646
  /**
225
647
  * @public
226
648
  */
227
- const DefaultEmRenderer = ({ children }) => `_${children}_`, DefaultStrongRenderer = ({ children }) => `**${children}**`, DefaultCodeRenderer = ({ children }) => `\`${children}\``, DefaultUnderlineRenderer = ({ children }) => `<u>${children}</u>`, DefaultStrikeThroughRenderer = ({ children }) => `~~${children}~~`, DefaultLinkRenderer = ({ children, value }) => {
649
+ const DefaultUnderlineRenderer = ({ children }) => `<u>${children}</u>`, DefaultStrikeThroughRenderer = ({ children }) => `~~${children}~~`, DefaultLinkRenderer = ({ children, value }) => {
228
650
  let href = value?.href || "", title = value?.title || "";
229
- if (uriLooksSafe(href)) {
230
- if (/["'][^"']*[<>]|[<>][^<>]*["']/.test(href)) {
231
- let encodedHref = href.replace(/["<>() ]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`);
232
- return `[${escapeImageAndLinkText(children)}](${encodedHref})`;
233
- }
234
- return `[${escapeImageAndLinkText(children)}](${href}${title ? ` "${escapeImageAndLinkTitle(title)}"` : ""})`;
235
- }
236
- return children;
651
+ return uriLooksSafe(href) ? /["'][^"']*[<>]|[<>][^<>]*["']/.test(href) ? `[${children}](${href.replace(/["<>() ]/g, (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}`)})` : `[${children}](${href}${title ? ` "${escapeImageAndLinkTitle(title)}"` : ""})` : children;
237
652
  };
238
653
  function uriLooksSafe(uri) {
239
654
  let url = (uri || "").trim(), first = url.charAt(0);
@@ -267,7 +682,7 @@ function isCodeShaped(value) {
267
682
  * as absent instead of guarded.
268
683
  */
269
684
  function normalizeLanguage(language) {
270
- return typeof language != "string" || language.includes("\n") ? "" : language;
685
+ return typeof language != "string" || language.includes("\n") || language === "json:object" ? "" : language;
271
686
  }
272
687
  /**
273
688
  * @public
@@ -280,10 +695,10 @@ function isHtmlShaped(value) {
280
695
  * @public
281
696
  */
282
697
  const DefaultImageRenderer = (options) => {
283
- if (!isImageShaped(options.value)) return DefaultUnknownTypeRenderer(options);
698
+ if (!isImageShaped(options.value) || !linkValidator(options.value.src)) return DefaultUnknownTypeRenderer(options);
284
699
  let alt = escapeImageAndLinkText(options.value.alt ?? ""), title = options.value.title ? ` "${escapeImageAndLinkTitle(options.value.title)}"` : "";
285
700
  return `![${alt}](${options.value.src}${title})`;
286
- };
701
+ }, linkValidator = new markdownit().validateLink;
287
702
  function isImageShaped(value) {
288
703
  let image = value;
289
704
  return typeof image?.src == "string" && (image.alt == null || typeof image.alt == "string") && (image.title == null || typeof image.title == "string");
@@ -325,12 +740,23 @@ const DefaultTableRenderer = (options) => {
325
740
  function renderTable(value, renderNode) {
326
741
  let rows = value.rows, alignment = Array.isArray(value.alignment) ? value.alignment : void 0, headerRow = rows.at(0);
327
742
  if (!headerRow) return "";
328
- let getCellText = (cellBlocks) => cellBlocks.map((block, index) => renderNode({
329
- node: block,
330
- index,
331
- isInline: !1,
332
- renderNode
333
- })).join(" ").trim(), lines = [], columnCount = rows.reduce((max, row) => Math.max(max, row.cells.length), 0), renderCells = (texts) => {
743
+ let getCellText = (cellBlocks) => cellBlocks.map((block, index) => {
744
+ let rendered = renderNode({
745
+ node: block,
746
+ index,
747
+ isInline: !1,
748
+ renderNode
749
+ }), rendererOptions = {
750
+ value: block,
751
+ isInline: !1,
752
+ index,
753
+ renderNode
754
+ };
755
+ return rendered === DefaultUnknownTypeRenderer(rendererOptions) ? DefaultUnknownTypeRenderer({
756
+ ...rendererOptions,
757
+ isInline: !0
758
+ }) : rendered;
759
+ }).join(" ").trim(), lines = [], columnCount = rows.reduce((max, row) => Math.max(max, row.cells.length), 0), renderCells = (texts) => {
334
760
  let padded = [...texts];
335
761
  for (; padded.length < columnCount;) padded.push("");
336
762
  return `| ${padded.join(" | ")} |`;
@@ -363,7 +789,7 @@ const DefaultCalloutRenderer = (options) => {
363
789
  index,
364
790
  isInline: !1,
365
791
  renderNode
366
- })).join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
792
+ })).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
367
793
  return `> [!${options.value.tone.toUpperCase()}]\n${prefixed}`;
368
794
  };
369
795
  function isCalloutShaped(value) {
@@ -390,20 +816,29 @@ const DefaultBlockquoteObjectRenderer = ({ value, renderNode }) => value.content
390
816
  index,
391
817
  isInline: !1,
392
818
  renderNode
393
- })).join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n"), DefaultListRenderer = ({ value, renderNode }) => {
394
- let itemSeparator = value.items.some((item) => item.content.filter((block) => block._type !== "list").length > 1) ? "\n\n" : "\n";
395
- return value.items.map((item, itemIndex) => {
396
- let marker = getListMarker(value.kind, itemIndex, item.checked), indentWidth = value.kind === "task" ? 2 : marker.length, indent = " ".repeat(indentWidth), [first, ...rest] = item.content.map((block, blockIndex) => ({
397
- isNestedList: block._type === "list",
398
- text: renderNode({
819
+ })).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n"), DefaultListRenderer = ({ value, renderNode }) => {
820
+ let renderedItems = value.items.map((item) => {
821
+ let markerLineSettled = !1;
822
+ return item.content.map((block, blockIndex) => {
823
+ let isNestedList = block._type === "list", isTextBlock = !isNestedList && isPortableTextBlock(block);
824
+ !markerLineSettled && isTextBlock && markListItemFirstBlock(block);
825
+ let text = renderNode({
399
826
  node: block,
400
827
  index: blockIndex,
401
828
  isInline: !1,
402
829
  renderNode
403
- })
404
- })), head = `${marker}${first?.text ?? ""}`.trimEnd();
830
+ });
831
+ return text !== "" && (markerLineSettled = !0), {
832
+ isNestedList,
833
+ isTextBlock,
834
+ text
835
+ };
836
+ });
837
+ }), itemSeparator = renderedItems.some((renderedBlocks) => renderedBlocks.filter((rendered) => !rendered.isNestedList && rendered.text !== "").length > 1) ? "\n\n" : "\n";
838
+ return value.items.map((item, itemIndex) => {
839
+ let marker = getListMarker(value.kind, itemIndex, item.checked), indentWidth = value.kind === "task" ? 2 : marker.length, indent = " ".repeat(indentWidth), indentLines = (text) => text.split("\n").map((line) => line === "" ? "" : `${indent}${line}`).join("\n"), nonEmptyBlocks = (renderedItems[itemIndex] ?? []).filter((rendered) => rendered.text !== ""), markerLineCandidate = nonEmptyBlocks[0], promoted = markerLineCandidate && !markerLineCandidate.isNestedList && (value.kind !== "task" || markerLineCandidate.isTextBlock) ? markerLineCandidate : void 0, rest = promoted ? nonEmptyBlocks.slice(1) : nonEmptyBlocks, [promotedFirstLine = "", ...promotedRestLines] = (promoted?.text ?? "").split("\n"), head = [`${marker}${promotedFirstLine}`, ...promotedRestLines.length > 0 ? [indentLines(promotedRestLines.join("\n"))] : []].join("\n").trimEnd();
405
840
  return rest.length === 0 ? head : `${head}${rest.map((rendered) => {
406
- let indented = rendered.text.split("\n").map((line) => line === "" ? "" : `${indent}${line}`).join("\n");
841
+ let indented = indentLines(rendered.text);
407
842
  return rendered.isNestedList ? `\n${indented}` : `\n\n${indented}`;
408
843
  }).join("")}`;
409
844
  }).join(itemSeparator);
@@ -414,10 +849,7 @@ function getListMarker(kind, itemIndex, checked) {
414
849
  /**
415
850
  * @public
416
851
  */
417
- const DefaultUnknownTypeRenderer = ({ value, isInline }) => {
418
- let json = `\`\`\`json\n${JSON.stringify(value, null, 2)}\n\`\`\``;
419
- return isInline ? `\n${json}\n` : json;
420
- }, defaultRenderers = {
852
+ const DefaultUnknownTypeRenderer = ({ value, isInline }) => isInline ? `json:object${wrapInCodeSpan(JSON.stringify(value))}` : `\`\`\`json:object\n${JSON.stringify(value, null, 2)}\n\`\`\``, defaultRenderers = {
421
853
  types: {
422
854
  callout: DefaultCalloutRenderer,
423
855
  code: DefaultCodeBlockRenderer,
@@ -466,7 +898,7 @@ function portableTextToMarkdown(blocks, options = {}) {
466
898
  ...options.marks
467
899
  },
468
900
  types: {
469
- ...defaultRenderers.types,
901
+ ...gateDefaultTypeRenderers(defaultRenderers.types, options.schema, options.unknownType ?? defaultRenderers.unknownType),
470
902
  ...options.types
471
903
  },
472
904
  hardBreak: options.hardBreak ?? defaultRenderers.hardBreak,
@@ -474,22 +906,26 @@ function portableTextToMarkdown(blocks, options = {}) {
474
906
  unknownBlockStyle: options.unknownBlockStyle ?? defaultRenderers.unknownBlockStyle,
475
907
  unknownListItem: options.unknownListItem ?? defaultRenderers.unknownListItem,
476
908
  unknownMark: options.unknownMark ?? defaultRenderers.unknownMark
477
- }, renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer, { listIndexMap, listDepthMap } = buildListIndexMap(blocks), renderNode = createRenderNode(renderers, listIndexMap, listDepthMap);
478
- return blocks.map((node, index) => {
479
- let renderedNode = renderNode({
909
+ }, renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer, { listIndexMap, listDepthMap } = buildListIndexMap(blocks), renderNode = createRenderNode(renderers, listIndexMap, listDepthMap), renderedBlocks = blocks.map((node, index) => ({
910
+ node,
911
+ rendered: renderNode({
480
912
  node,
481
913
  index,
482
914
  isInline: !1,
483
915
  renderNode
484
- });
485
- if (index === blocks.length - 1) return renderedNode;
486
- let nextNode = blocks.at(index + 1);
487
- return nextNode ? `${renderedNode}${renderBlockSpacing({
916
+ })
917
+ })).filter(({ rendered }) => rendered !== "");
918
+ return renderedBlocks.map(({ node, rendered }, index) => {
919
+ let nextBlock = renderedBlocks.at(index + 1);
920
+ return nextBlock ? `${rendered}${renderBlockSpacing({
488
921
  current: node,
489
- next: nextNode
490
- }) ?? "\n\n"}` : renderedNode;
922
+ next: nextBlock.node
923
+ }) ?? "\n\n"}` : rendered;
491
924
  }).join("");
492
925
  }
926
+ function gateDefaultTypeRenderers(defaultTypeRenderers, schema, resolvedUnknownType) {
927
+ return schema ? Object.fromEntries(Object.entries(defaultTypeRenderers).map(([typeName, renderer]) => [typeName, (rendererOptions) => (rendererOptions.isInline ? schema.inlineObjects : schema.blockObjects).some((item) => item.name === typeName) && renderer ? renderer(rendererOptions) : resolvedUnknownType(rendererOptions)])) : defaultTypeRenderers;
928
+ }
493
929
  /********************
494
930
  * Default style definitions
495
931
  ********************/
@@ -611,7 +1047,66 @@ const normalStyleDefinition = { name: "normal" }, h1StyleDefinition = { name: "h
611
1047
  defaultTableObjectDefinition
612
1048
  ],
613
1049
  inlineObjects: [defaultImageObjectDefinition]
614
- }));
1050
+ })), degradationMessage = {
1051
+ "decorator-dropped": (decorator) => {
1052
+ switch (decorator) {
1053
+ case "code": return "Removed inline-code formatting, kept the text: the schema has no `code` decorator";
1054
+ case "strong": return "Removed bold formatting, kept the text: the schema has no `strong` decorator";
1055
+ case "em": return "Removed italic formatting, kept the text: the schema has no `em` decorator";
1056
+ case "strikeThrough": return "Removed strikethrough formatting, kept the text: the schema has no `strike-through` decorator";
1057
+ }
1058
+ },
1059
+ "annotation-dropped": (cause) => cause === "missing-url" ? "Removed a link that has no URL, kept its text" : "Removed the link, kept its text: the schema has no `link` annotation",
1060
+ "style-fallback": (name) => /^h[1-6]$/.test(name) ? `\`${"#".repeat(Number(name.slice(1)))}\` heading became a normal paragraph: the schema has no \`${name}\` style` : name === "blockquote" ? "Blockquote became normal paragraphs: the schema has no `blockquote` style" : `Fell back to \`normal\` style: \`${name}\` not in schema`,
1061
+ "list-flattened": (kind) => `${kind === "number" ? "Numbered" : "Bullet"} list became plain paragraphs: the schema has no \`${kind}\` list`,
1062
+ "task-checkbox-stripped": (checked) => `Removed the \`${checked ? "[x]" : "[ ]"}\` checkbox, kept a plain list item: the schema has no \`task\` list`,
1063
+ "table-flattened": "Table became plain text blocks, rows and columns lost: the schema has no `table` block object",
1064
+ "code-block-to-text": (language) => language ? `\`${language}\` code block became plain text: the schema has no \`code\` block object` : "Code block became plain text: the schema has no `code` block object",
1065
+ "horizontal-rule-to-text": "Horizontal rule became the text `---`: the schema has no `horizontal-rule` block object",
1066
+ "html-block-to-text": "HTML block became plain text: the schema has no `html` block object",
1067
+ "inline-html-dropped": "Removed inline HTML tags, kept nothing: `html.inline` is `skip` (the default)",
1068
+ "image-block-to-inline": (cause) => cause === "table-cell" ? "The image became inline: a table cell can't hold a block-level `image`" : "The image became inline: the schema has no block-level `image`",
1069
+ "image-inline-to-block": "The image became its own block, splitting the paragraph: the schema has no inline `image`",
1070
+ "image-to-text": "Image became its markdown source as plain text: the schema has no `image` object",
1071
+ "callout-fallback": (calloutType, style) => `\`[!${calloutType.toUpperCase()}]\` callout became ${style}-styled text: the schema has no \`callout\` block object`,
1072
+ "fields-dropped": (names, construct) => `Dropped ${names} from \`${construct}\`: not in the schema's \`${construct}\` fields`,
1073
+ "object-carrier-invalid": (kind, payload) => kind === "fence" ? `\`json:object\` fence fell back to a code block: ${describeObjectCarrierFailure(payload)}` : `\`json:object\`-tagged code span fell back to a plain code span: ${describeObjectCarrierFailure(payload)}`
1074
+ };
1075
+ /**
1076
+ * Names why a `json:object` payload failed to parse as an object carrier:
1077
+ * a payload that isn't a JSON object at all reads differently from one
1078
+ * that is but has no usable `_type`.
1079
+ */
1080
+ function describeObjectCarrierFailure(payload) {
1081
+ let parsed;
1082
+ try {
1083
+ parsed = JSON.parse(payload);
1084
+ } catch {
1085
+ return "the payload is not valid JSON";
1086
+ }
1087
+ return typeof parsed != "object" || !parsed || Array.isArray(parsed) ? "the payload is not a JSON object" : "the payload has no string `_type`";
1088
+ }
1089
+ const droppedFieldsTag = Symbol("droppedFields");
1090
+ function readDroppedFields(object) {
1091
+ if (object) return object[droppedFieldsTag];
1092
+ }
1093
+ function buildFilteredObject(schemaDefinition, value, keyGenerator) {
1094
+ let filteredValue = schemaDefinition.fields.reduce((filteredValue, field) => {
1095
+ let fieldValue = value[field.name];
1096
+ return fieldValue !== void 0 && (filteredValue[field.name] = fieldValue), filteredValue;
1097
+ }, {}), object = {
1098
+ _key: keyGenerator(),
1099
+ _type: schemaDefinition.name,
1100
+ ...filteredValue
1101
+ }, droppedKeys = Object.entries(value).filter(([, fieldValue]) => fieldValue !== void 0).map(([key]) => key).filter((key) => !(key in filteredValue));
1102
+ return droppedKeys.length > 0 && Object.defineProperty(object, droppedFieldsTag, {
1103
+ value: {
1104
+ construct: schemaDefinition.name,
1105
+ keys: droppedKeys
1106
+ },
1107
+ enumerable: !1
1108
+ }), object;
1109
+ }
615
1110
  function buildStyleMatcher(definition) {
616
1111
  return ({ context }) => {
617
1112
  let schemaDefinition = context.schema.styles.find((item) => item.name === definition.name);
@@ -633,31 +1128,13 @@ function buildDecoratorMatcher(definition) {
633
1128
  function buildAnnotationMatcher(definition) {
634
1129
  return ({ context, value }) => {
635
1130
  let schemaDefinition = context.schema.annotations.find((item) => item.name === definition.name);
636
- if (!schemaDefinition) return;
637
- let filteredValue = schemaDefinition.fields.reduce((filteredValue, field) => {
638
- let fieldValue = value[field.name];
639
- return fieldValue !== void 0 && (filteredValue[field.name] = fieldValue), filteredValue;
640
- }, {});
641
- return {
642
- _key: context.keyGenerator(),
643
- _type: schemaDefinition.name,
644
- ...filteredValue
645
- };
1131
+ if (schemaDefinition) return buildFilteredObject(schemaDefinition, value, context.keyGenerator);
646
1132
  };
647
1133
  }
648
1134
  function buildObjectMatcher(definition) {
649
1135
  return ({ context, value, isInline }) => {
650
1136
  let schemaDefinition = (isInline ? context.schema.inlineObjects : context.schema.blockObjects).find((item) => item.name === definition.name);
651
- if (!schemaDefinition) return;
652
- let filteredValue = schemaDefinition.fields.reduce((filteredValue, field) => {
653
- let fieldValue = value[field.name];
654
- return fieldValue !== void 0 && (filteredValue[field.name] = fieldValue), filteredValue;
655
- }, {});
656
- return {
657
- _key: context.keyGenerator(),
658
- _type: schemaDefinition.name,
659
- ...filteredValue
660
- };
1137
+ if (schemaDefinition) return buildFilteredObject(schemaDefinition, value, context.keyGenerator);
661
1138
  };
662
1139
  }
663
1140
  const codeBlockMatcher = ({ context, value, isInline }) => {
@@ -742,6 +1219,83 @@ function flattenTable(table, portableText) {
742
1219
  for (let row of table.rows) for (let cell of row.cells) for (let block of cell.value) portableText.push(block);
743
1220
  }
744
1221
  /**
1222
+ * Truncates a degradation message's snippet to keep the thrown/reported
1223
+ * message readable. Truncates on character count, not word boundaries: the
1224
+ * snippet is a diagnostic pointer back to the source, not prose. Undefined
1225
+ * for empty input, so a construct with nothing to quote (an empty link's
1226
+ * text, say) omits `snippet` entirely instead of reporting `""`. Backs the
1227
+ * cut off by one unit when it would land on a lead surrogate, so a snippet
1228
+ * ending mid-emoji doesn't produce an unpaired surrogate. A literal newline
1229
+ * surviving into the snippet is escaped to `\n`, since the reported message
1230
+ * is one line per finding.
1231
+ */
1232
+ function truncateSnippet(text, maxLength = 40) {
1233
+ if (text.length === 0) return;
1234
+ if (text.length <= maxLength) return text.replace(/\n/g, "\\n");
1235
+ let cut = maxLength, codeUnit = text.charCodeAt(cut - 1);
1236
+ return codeUnit >= 55296 && codeUnit <= 56319 && --cut, `${text.slice(0, cut).replace(/\n/g, "\\n")}...`;
1237
+ }
1238
+ /**
1239
+ * Concatenates the plain text between an inline open token (`strong_open`,
1240
+ * `em_open`, `s_open`, `link_open`) and its matching close, for use as a
1241
+ * degradation message snippet. Tracks nesting depth so a same-type token
1242
+ * nested inside itself doesn't stop the scan at the wrong close.
1243
+ */
1244
+ function collectInlineText(children, openIndex, openType, closeType) {
1245
+ let depth = 1, text = "";
1246
+ for (let i = openIndex + 1; i < children.length; i++) {
1247
+ let child = children[i];
1248
+ if (child) {
1249
+ if (child.type === openType) depth++;
1250
+ else if (child.type === closeType) {
1251
+ if (depth--, depth === 0) break;
1252
+ } else child.type === "text" ? text += child.content : child.type === "softbreak" ? text += " " : child.type === "hardbreak" && (text += "\n");
1253
+ }
1254
+ }
1255
+ return text;
1256
+ }
1257
+ function capList(values) {
1258
+ if (values.length <= 5) return values.join(", ");
1259
+ let shown = values.slice(0, 5), more = values.length - 5;
1260
+ return `${shown.join(", ")}, and ${more} more`;
1261
+ }
1262
+ /**
1263
+ * Builds the canonical grouped message reported alongside a non-empty
1264
+ * `degradations` array: identical (`type`, `message`) pairs collapse into one
1265
+ * line, so a document with the same missing decorator on three spans
1266
+ * doesn't repeat the same sentence three times. Groups sort by their
1267
+ * earliest-lined entry so the message reads top-to-bottom regardless of walk
1268
+ * order: nested constructs (a blockquote inside a blockquote, say) report
1269
+ * the innermost closing first even though it opened last, and that line may
1270
+ * arrive after another entry already in the group. Groups without any lined
1271
+ * entry sort last, in their relative encounter order.
1272
+ */
1273
+ function buildDegradationMessage(degradations) {
1274
+ let groups = [], groupIndexByKey = /* @__PURE__ */ new Map();
1275
+ for (let degradation of degradations) {
1276
+ let key = `${degradation.type}\u0000${degradation.message}`, groupIndex = groupIndexByKey.get(key);
1277
+ groupIndex === void 0 && (groupIndex = groups.length, groupIndexByKey.set(key, groupIndex), groups.push({
1278
+ base: degradation.message,
1279
+ entries: []
1280
+ })), groups[groupIndex].entries.push(degradation);
1281
+ }
1282
+ let minLine = (entries) => {
1283
+ let definedLines = entries.map((entry) => entry.line).filter((line) => line !== void 0);
1284
+ return definedLines.length > 0 ? Math.min(...definedLines) : void 0;
1285
+ };
1286
+ return ["Markdown could not be converted without loss:", ...[...groups].sort((a, b) => {
1287
+ let lineA = minLine(a.entries), lineB = minLine(b.entries);
1288
+ return lineA === void 0 ? lineB === void 0 ? 0 : 1 : lineB === void 0 ? -1 : lineA - lineB;
1289
+ }).map((group) => {
1290
+ if (group.entries.length === 1) {
1291
+ let event = group.entries[0], snippetPart = event.snippet === void 0 ? "" : ` ("${event.snippet}")`;
1292
+ return event.line === void 0 ? `- ${event.message}${snippetPart}` : `- line ${event.line}: ${event.message}${snippetPart}`;
1293
+ }
1294
+ let snippets = group.entries.map((entry) => entry.snippet).filter((snippet) => snippet !== void 0), linesAscending = [...new Set(group.entries.map((entry) => entry.line).filter((line) => line !== void 0).sort((a, b) => a - b))], count = group.entries.length, suffix = snippets.length === count ? `(${count}\u00d7: ${capList(snippets.map((snippet) => `"${snippet}"`))})` : linesAscending.length > 0 ? `(${count}\u00d7: lines ${capList(linesAscending.map(String))})` : `(${count}\u00d7)`;
1295
+ return `- ${group.base} ${suffix}`;
1296
+ })].join("\n");
1297
+ }
1298
+ /**
745
1299
  * Converts a markdown string to an array of Portable Text blocks.
746
1300
  *
747
1301
  * @public
@@ -749,7 +1303,7 @@ function flattenTable(table, portableText) {
749
1303
  function markdownToPortableText(markdown, options) {
750
1304
  let consolidatedOptions = {
751
1305
  schema: options?.schema ?? defaultSchema,
752
- keyGenerator: options?.keyGenerator ?? defaultKeyGenerator,
1306
+ keyGenerator: uniqueKeyGenerator(options?.keyGenerator ?? defaultKeyGenerator),
753
1307
  html: { inline: options?.html?.inline ?? "skip" },
754
1308
  marks: {
755
1309
  ...defaultOptions.marks,
@@ -767,11 +1321,46 @@ function markdownToPortableText(markdown, options) {
767
1321
  ...defaultOptions.types,
768
1322
  ...options?.types
769
1323
  }
1324
+ }, degradationEvents = [], report = (event) => {
1325
+ degradationEvents.push(event);
1326
+ }, lineOf = (candidateToken) => candidateToken?.map ? candidateToken.map[0] + 1 : void 0, reportStyleFallback = (name, line, snippet) => {
1327
+ if (/^h[1-6]$/.test(name)) {
1328
+ let truncated = snippet === void 0 ? void 0 : truncateSnippet(snippet);
1329
+ report({
1330
+ type: "style-fallback",
1331
+ message: degradationMessage["style-fallback"](name),
1332
+ line,
1333
+ snippet: truncated
1334
+ });
1335
+ return;
1336
+ }
1337
+ if (name === "blockquote") {
1338
+ report({
1339
+ type: "style-fallback",
1340
+ message: degradationMessage["style-fallback"](name),
1341
+ line
1342
+ });
1343
+ return;
1344
+ }
1345
+ report({
1346
+ type: "style-fallback",
1347
+ message: degradationMessage["style-fallback"](name),
1348
+ line
1349
+ });
1350
+ }, reportFieldsDropped = (object, line) => {
1351
+ let dropped = readDroppedFields(object);
1352
+ if (!dropped) return;
1353
+ let names = dropped.keys.map((key) => `\`${key}\``).join(", ");
1354
+ report({
1355
+ type: "fields-dropped",
1356
+ message: degradationMessage["fields-dropped"](names, dropped.construct),
1357
+ line
1358
+ });
770
1359
  }, tokens = markdownit({
771
1360
  html: !0,
772
1361
  linkify: !0,
773
1362
  typographer: !1
774
- }).enable(["strikethrough", "table"]).use(alert).parse(markdown, {}), taskCheckedByListItemIndex = /* @__PURE__ */ new Map();
1363
+ }).enable(["strikethrough", "table"]).use(alert).parse(markdown, {}), taskCheckedByListItemIndex = /* @__PURE__ */ new Map(), taskItemTextByListItemIndex = /* @__PURE__ */ new Map();
775
1364
  for (let i = 0; i < tokens.length; i++) {
776
1365
  if (tokens[i]?.type !== "list_item_open") continue;
777
1366
  let inlineIndex = -1;
@@ -791,11 +1380,11 @@ function markdownToPortableText(markdown, options) {
791
1380
  let match = inlineToken.content.match(/^\[([ xX])\] /);
792
1381
  if (!match) continue;
793
1382
  let checked = match[1] !== " ";
794
- taskCheckedByListItemIndex.set(i, checked), inlineToken.content = inlineToken.content.slice(match[0].length);
1383
+ taskCheckedByListItemIndex.set(i, checked), inlineToken.content = inlineToken.content.slice(match[0].length), taskItemTextByListItemIndex.set(i, inlineToken.content);
795
1384
  let firstChild = inlineToken.children?.[0];
796
1385
  firstChild && typeof firstChild.content == "string" && (firstChild.content = firstChild.content.slice(match[0].length));
797
1386
  }
798
- let portableText = [], currentBlock = null, currentListStack = [], markDefRefs = [], currentMarkDefs = [], currentBlockquoteStyle = null, inListItem = !1, calloutStartIndex = null, calloutStartTarget = null, calloutType = null, blockquoteStack = [], currentTable = null, currentTableRow = null, inTableHead = !1, listContainerStack = [], blockTarget = () => {
1387
+ let portableText = [], currentBlock = null, currentListStack = [], markDefRefs = [], currentMarkDefs = [], currentBlockquoteStyle = null, inListItem = !1, currentBlockTookBlockquoteStyle = !1, currentBlockIsPlainParagraph = !1, plainParagraphBlocks = /* @__PURE__ */ new WeakSet(), calloutStartIndex = null, calloutStartTarget = null, calloutType = null, calloutStartLine, calloutPendingStyleFallbacks = [], blockquoteStack = [], currentTable = null, currentTableRow = null, inTableHead = !1, demotedTableImages = /* @__PURE__ */ new WeakMap(), listContainerStack = [], pendingListFlattenedStack = [], taskInfoByListItem = /* @__PURE__ */ new WeakMap(), blockTarget = () => {
799
1388
  for (let i = listContainerStack.length - 1; i >= 0; i--) {
800
1389
  let frame = listContainerStack[i];
801
1390
  if (frame && frame.currentItem) return frame.currentItem.content;
@@ -803,26 +1392,35 @@ function markdownToPortableText(markdown, options) {
803
1392
  return portableText;
804
1393
  }, pushBlock = (block) => {
805
1394
  blockTarget().push(block);
806
- }, startBlock = (style) => {
1395
+ }, startBlock = (style, provenance) => {
807
1396
  flushBlock(), currentBlock = {
808
1397
  _type: "block",
809
1398
  style,
810
1399
  children: [],
811
1400
  _key: consolidatedOptions.keyGenerator(),
812
1401
  markDefs: []
813
- }, currentMarkDefs = [];
1402
+ }, currentMarkDefs = [], currentBlockTookBlockquoteStyle = provenance?.tookBlockquoteStyle ?? !1, currentBlockIsPlainParagraph = provenance?.isPlainParagraph ?? !1;
814
1403
  }, flushBlock = () => {
815
- currentBlock && (currentBlock.children.length === 0 && currentBlock.children.push({
816
- _type: consolidatedOptions.schema.span.name,
817
- _key: consolidatedOptions.keyGenerator(),
818
- text: "",
819
- marks: []
820
- }), currentBlock.markDefs = currentMarkDefs, pushBlock(currentBlock), currentBlock = null, currentMarkDefs = []);
1404
+ if (currentBlock) {
1405
+ if (calloutPendingStyleFallbacks.length > 0 && currentBlockTookBlockquoteStyle) {
1406
+ for (let name of calloutPendingStyleFallbacks) reportStyleFallback(name, calloutStartLine);
1407
+ calloutPendingStyleFallbacks = [];
1408
+ }
1409
+ currentBlock.children.length === 0 && currentBlock.children.push({
1410
+ _type: consolidatedOptions.schema.span.name,
1411
+ _key: consolidatedOptions.keyGenerator(),
1412
+ text: "",
1413
+ marks: []
1414
+ }), currentBlock.markDefs = currentMarkDefs, currentBlockIsPlainParagraph && plainParagraphBlocks.add(currentBlock), pushBlock(currentBlock), currentBlock = null, currentMarkDefs = [];
1415
+ }
821
1416
  }, addSpan = (text) => {
822
1417
  if (text.length === 0) return;
823
1418
  if (!currentBlock) {
824
1419
  let style = currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
825
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal"));
1420
+ style ? startBlock(style, {
1421
+ tookBlockquoteStyle: currentBlockquoteStyle !== null,
1422
+ isPlainParagraph: !0
1423
+ }) : (reportStyleFallback("normal"), startBlock("normal", { isPlainParagraph: !0 }));
826
1424
  }
827
1425
  if (!currentBlock) throw Error("Expected current block");
828
1426
  let lastChild = currentBlock.children.at(-1);
@@ -835,7 +1433,7 @@ function markdownToPortableText(markdown, options) {
835
1433
  }, listLevel = () => currentListStack.length, ensureListBlock = (listItem, checked) => {
836
1434
  if (!currentBlock) {
837
1435
  let style = currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
838
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal"));
1436
+ style ? startBlock(style, { tookBlockquoteStyle: currentBlockquoteStyle !== null }) : (reportStyleFallback("normal"), startBlock("normal"));
839
1437
  }
840
1438
  if (!currentBlock) throw Error("Expected current block");
841
1439
  (currentBlock.listItem !== listItem || currentBlock.level !== listLevel()) && (currentBlock.listItem = listItem, currentBlock.level = listLevel()), checked !== void 0 && (currentBlock.checked = checked);
@@ -846,7 +1444,13 @@ function markdownToPortableText(markdown, options) {
846
1444
  case "paragraph_open": {
847
1445
  if (inListItem) {
848
1446
  if (listContainerStack.at(-1)) {
849
- currentBlock || startBlock(currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } }) ?? "normal");
1447
+ if (!currentBlock) {
1448
+ let style = currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1449
+ style || reportStyleFallback("normal", lineOf(token)), startBlock(style ?? "normal", {
1450
+ tookBlockquoteStyle: currentBlockquoteStyle !== null,
1451
+ isPlainParagraph: !0
1452
+ });
1453
+ }
850
1454
  break;
851
1455
  }
852
1456
  if (!currentBlock) {
@@ -857,10 +1461,13 @@ function markdownToPortableText(markdown, options) {
857
1461
  }
858
1462
  let style = currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
859
1463
  if (!style) {
860
- console.warn("No default style found, using \"normal\""), startBlock("normal");
1464
+ reportStyleFallback("normal", lineOf(token)), startBlock("normal", { isPlainParagraph: !0 });
861
1465
  break;
862
1466
  }
863
- startBlock(style);
1467
+ startBlock(style, {
1468
+ tookBlockquoteStyle: currentBlockquoteStyle !== null,
1469
+ isPlainParagraph: !0
1470
+ });
864
1471
  break;
865
1472
  }
866
1473
  case "paragraph_close":
@@ -878,9 +1485,11 @@ function markdownToPortableText(markdown, options) {
878
1485
  4: consolidatedOptions.block.h4,
879
1486
  5: consolidatedOptions.block.h5,
880
1487
  6: consolidatedOptions.block.h6
881
- }[level], style = headingMatcher?.({ context: { schema: consolidatedOptions.schema } }) ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1488
+ }[level], headingStyle = headingMatcher?.({ context: { schema: consolidatedOptions.schema } });
1489
+ headingStyle || reportStyleFallback(`h${level}`, lineOf(token), tokens[tokenIndex + 1]?.content);
1490
+ let style = headingStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
882
1491
  if (!style) {
883
- console.warn("No heading style found, using \"normal\""), startBlock("normal");
1492
+ reportStyleFallback("normal", lineOf(token)), startBlock("normal");
884
1493
  break;
885
1494
  }
886
1495
  startBlock(style);
@@ -889,17 +1498,22 @@ function markdownToPortableText(markdown, options) {
889
1498
  case "heading_close":
890
1499
  flushBlock();
891
1500
  break;
892
- case "blockquote_open":
1501
+ case "blockquote_open": {
893
1502
  if (flushBlock(), consolidatedOptions.types.blockquote) {
894
1503
  let startTarget = blockTarget();
895
1504
  blockquoteStack.push({
896
1505
  startTarget,
897
- startIndex: startTarget.length
1506
+ startIndex: startTarget.length,
1507
+ line: lineOf(token)
898
1508
  });
899
1509
  break;
900
1510
  }
901
- currentBlockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } }) ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } }) ?? "normal";
1511
+ let blockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } });
1512
+ blockquoteStyle || reportStyleFallback("blockquote", lineOf(token));
1513
+ let style = blockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1514
+ style || reportStyleFallback("normal", lineOf(token)), currentBlockquoteStyle = style ?? "normal";
902
1515
  break;
1516
+ }
903
1517
  case "blockquote_close":
904
1518
  if (flushBlock(), consolidatedOptions.types.blockquote && blockquoteStack.length > 0) {
905
1519
  let frame = blockquoteStack.pop();
@@ -914,11 +1528,18 @@ function markdownToPortableText(markdown, options) {
914
1528
  });
915
1529
  if (blockquoteObject) pushBlock(blockquoteObject);
916
1530
  else {
917
- let blockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } }) ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } }) ?? "blockquote";
918
- for (let block of contentBlocks) block._type === "block" ? pushBlock({
919
- ...block,
920
- style: blockquoteStyle
921
- }) : pushBlock(block);
1531
+ let blockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } });
1532
+ blockquoteStyle || reportStyleFallback("blockquote", frame.line);
1533
+ let resolvedStyle = blockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1534
+ resolvedStyle || reportStyleFallback("normal", frame.line);
1535
+ let fallbackStyle = resolvedStyle ?? "blockquote";
1536
+ for (let block of contentBlocks) if (block._type === "block" && plainParagraphBlocks.has(block)) {
1537
+ let restyledBlock = {
1538
+ ...block,
1539
+ style: fallbackStyle
1540
+ };
1541
+ plainParagraphBlocks.add(restyledBlock), pushBlock(restyledBlock);
1542
+ } else pushBlock(block);
922
1543
  }
923
1544
  }
924
1545
  break;
@@ -930,16 +1551,21 @@ function markdownToPortableText(markdown, options) {
930
1551
  listContainerStack.push({
931
1552
  kind: "bullet",
932
1553
  items: [],
933
- currentItem: null
934
- }), currentListStack.push(null);
1554
+ currentItem: null,
1555
+ line: lineOf(token)
1556
+ }), currentListStack.push(null), pendingListFlattenedStack.push(null);
935
1557
  break;
936
1558
  }
937
1559
  let listItem = consolidatedOptions.listItem.bullet({ context: { schema: consolidatedOptions.schema } });
938
1560
  if (listContainerStack.push(null), !listItem) {
939
- currentListStack.push(null);
1561
+ pendingListFlattenedStack.push({
1562
+ line: lineOf(token),
1563
+ kindName: "bullet",
1564
+ reported: !1
1565
+ }), currentListStack.push(null);
940
1566
  break;
941
1567
  }
942
- currentListStack.push(listItem);
1568
+ pendingListFlattenedStack.push(null), currentListStack.push(listItem);
943
1569
  break;
944
1570
  }
945
1571
  case "ordered_list_open": {
@@ -947,22 +1573,27 @@ function markdownToPortableText(markdown, options) {
947
1573
  listContainerStack.push({
948
1574
  kind: "number",
949
1575
  items: [],
950
- currentItem: null
951
- }), currentListStack.push(null);
1576
+ currentItem: null,
1577
+ line: lineOf(token)
1578
+ }), currentListStack.push(null), pendingListFlattenedStack.push(null);
952
1579
  break;
953
1580
  }
954
1581
  let listItem = consolidatedOptions.listItem.number({ context: { schema: consolidatedOptions.schema } });
955
1582
  if (listContainerStack.push(null), !listItem) {
956
- currentListStack.push(null);
1583
+ pendingListFlattenedStack.push({
1584
+ line: lineOf(token),
1585
+ kindName: "number",
1586
+ reported: !1
1587
+ }), currentListStack.push(null);
957
1588
  break;
958
1589
  }
959
- currentListStack.push(listItem);
1590
+ pendingListFlattenedStack.push(null), currentListStack.push(listItem);
960
1591
  break;
961
1592
  }
962
1593
  case "bullet_list_close":
963
1594
  case "ordered_list_close": {
964
1595
  let frame = listContainerStack.pop();
965
- if (currentListStack.pop(), frame && consolidatedOptions.types.list) {
1596
+ if (currentListStack.pop(), pendingListFlattenedStack.pop(), frame && consolidatedOptions.types.list) {
966
1597
  let kind = frame.items.some((item) => "checked" in item) ? "task" : frame.kind, listObject = consolidatedOptions.types.list({
967
1598
  context: {
968
1599
  schema: consolidatedOptions.schema,
@@ -976,13 +1607,45 @@ function markdownToPortableText(markdown, options) {
976
1607
  });
977
1608
  if (listObject) pushBlock(listObject);
978
1609
  else {
979
- let flatListItem = (kind === "task" ? consolidatedOptions.listItem.task?.({ context: { schema: consolidatedOptions.schema } }) : kind === "number" ? consolidatedOptions.listItem.number({ context: { schema: consolidatedOptions.schema } }) : consolidatedOptions.listItem.bullet({ context: { schema: consolidatedOptions.schema } })) ?? null, level = listContainerStack.length + 1;
980
- for (let item of frame.items) for (let block of item.content) block._type === "block" && flatListItem !== null && !("listItem" in block) ? pushBlock({
981
- ...block,
982
- listItem: flatListItem,
983
- level,
984
- ...item.checked === void 0 ? {} : { checked: item.checked }
985
- }) : pushBlock(block);
1610
+ let kindListItem = (frame.kind === "number" ? consolidatedOptions.listItem.number : consolidatedOptions.listItem.bullet)({ context: { schema: consolidatedOptions.schema } }) ?? null, taskListItemType = consolidatedOptions.listItem.task?.({ context: { schema: consolidatedOptions.schema } }) ?? null, kindName = frame.kind === "number" ? "number" : "bullet", level = listContainerStack.length + 1, flattenedReported = !1;
1611
+ for (let item of frame.items) {
1612
+ let itemListType = kindListItem, itemChecked;
1613
+ if (item.checked !== void 0) {
1614
+ if (taskListItemType) itemListType = taskListItemType, itemChecked = item.checked;
1615
+ else if (kindListItem !== null) {
1616
+ let info = taskInfoByListItem.get(item);
1617
+ report({
1618
+ type: "task-checkbox-stripped",
1619
+ message: degradationMessage["task-checkbox-stripped"](item.checked),
1620
+ line: info?.line,
1621
+ snippet: info?.snippet
1622
+ });
1623
+ }
1624
+ }
1625
+ itemListType === null && !flattenedReported && (report({
1626
+ type: "list-flattened",
1627
+ message: degradationMessage["list-flattened"](kindName),
1628
+ line: frame.line
1629
+ }), flattenedReported = !0);
1630
+ let mergeTarget = null;
1631
+ for (let block of item.content) {
1632
+ if (!(itemListType !== null && block._type === "block" && !("listItem" in block) && !/^h[1-6]$/.test(block.style ?? "") && plainParagraphBlocks.has(block))) {
1633
+ mergeTarget = null, pushBlock(block);
1634
+ continue;
1635
+ }
1636
+ let textBlock = block;
1637
+ if (mergeTarget && mergeTarget.style === textBlock.style) {
1638
+ mergeTarget.children.push(...textBlock.children), mergeTarget.markDefs = [...mergeTarget.markDefs ?? [], ...textBlock.markDefs ?? []];
1639
+ continue;
1640
+ }
1641
+ mergeTarget = {
1642
+ ...textBlock,
1643
+ listItem: itemListType,
1644
+ level,
1645
+ ...itemChecked === void 0 ? {} : { checked: itemChecked }
1646
+ }, pushBlock(mergeTarget);
1647
+ }
1648
+ }
986
1649
  }
987
1650
  }
988
1651
  break;
@@ -996,7 +1659,10 @@ function markdownToPortableText(markdown, options) {
996
1659
  _key: consolidatedOptions.keyGenerator(),
997
1660
  ...taskChecked === void 0 ? {} : { checked: taskChecked },
998
1661
  content: []
999
- }, inListItem = !0;
1662
+ }, taskChecked !== void 0 && taskInfoByListItem.set(frame.currentItem, {
1663
+ line: lineOf(token),
1664
+ snippet: truncateSnippet(taskItemTextByListItemIndex.get(tokenIndex) ?? "")
1665
+ }), inListItem = !0;
1000
1666
  break;
1001
1667
  }
1002
1668
  let baseListType = currentListStack.at(-1);
@@ -1007,10 +1673,25 @@ function markdownToPortableText(markdown, options) {
1007
1673
  taskListType && (listType = taskListType, checked = taskChecked);
1008
1674
  }
1009
1675
  if (listType === null) {
1676
+ let pendingFlattened = pendingListFlattenedStack.at(-1);
1677
+ pendingFlattened && !pendingFlattened.reported && (report({
1678
+ type: "list-flattened",
1679
+ message: degradationMessage["list-flattened"](pendingFlattened.kindName),
1680
+ line: pendingFlattened.line
1681
+ }), pendingFlattened.reported = !0);
1010
1682
  let style = currentBlockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1011
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal")), inListItem = !0;
1683
+ style ? startBlock(style, { tookBlockquoteStyle: currentBlockquoteStyle !== null }) : (reportStyleFallback("normal", lineOf(token)), startBlock("normal")), inListItem = !0;
1012
1684
  break;
1013
1685
  }
1686
+ if (taskChecked !== void 0 && checked === void 0) {
1687
+ let snippet = truncateSnippet(taskItemTextByListItemIndex.get(tokenIndex) ?? "");
1688
+ report({
1689
+ type: "task-checkbox-stripped",
1690
+ message: degradationMessage["task-checkbox-stripped"](taskChecked),
1691
+ line: lineOf(token),
1692
+ snippet
1693
+ });
1694
+ }
1014
1695
  ensureListBlock(listType, checked), inListItem = !0;
1015
1696
  break;
1016
1697
  }
@@ -1025,7 +1706,21 @@ function markdownToPortableText(markdown, options) {
1025
1706
  }
1026
1707
  case "fence": {
1027
1708
  flushBlock();
1028
- let language = token.info.trim() || void 0, code = token.content.replace(/\n$/, ""), codeObject = consolidatedOptions.types.code({
1709
+ let language = token.info.trim() || void 0, code = token.content.replace(/\n$/, "");
1710
+ if (language === "json:object") {
1711
+ let objectValue = parseJsonObjectFence(code);
1712
+ if (objectValue) {
1713
+ pushBlock(objectValue);
1714
+ break;
1715
+ }
1716
+ report({
1717
+ type: "object-carrier-invalid",
1718
+ message: degradationMessage["object-carrier-invalid"]("fence", code),
1719
+ line: lineOf(token),
1720
+ snippet: truncateSnippet(code)
1721
+ });
1722
+ }
1723
+ let codeObject = consolidatedOptions.types.code({
1029
1724
  context: {
1030
1725
  schema: consolidatedOptions.schema,
1031
1726
  keyGenerator: consolidatedOptions.keyGenerator
@@ -1037,11 +1732,18 @@ function markdownToPortableText(markdown, options) {
1037
1732
  isInline: !1
1038
1733
  });
1039
1734
  if (!codeObject) {
1735
+ let snippet = truncateSnippet(code.split("\n")[0] ?? "");
1736
+ report({
1737
+ type: "code-block-to-text",
1738
+ message: degradationMessage["code-block-to-text"](language),
1739
+ line: lineOf(token),
1740
+ snippet
1741
+ });
1040
1742
  let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1041
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal")), addSpan(code), flushBlock();
1743
+ style ? startBlock(style) : (reportStyleFallback("normal", lineOf(token)), startBlock("normal")), addSpan(code), flushBlock();
1042
1744
  break;
1043
1745
  }
1044
- pushBlock(codeObject);
1746
+ reportFieldsDropped(codeObject, lineOf(token)), pushBlock(codeObject);
1045
1747
  break;
1046
1748
  }
1047
1749
  case "hr": {
@@ -1055,8 +1757,13 @@ function markdownToPortableText(markdown, options) {
1055
1757
  isInline: !1
1056
1758
  });
1057
1759
  if (!hrObject) {
1760
+ report({
1761
+ type: "horizontal-rule-to-text",
1762
+ message: degradationMessage["horizontal-rule-to-text"],
1763
+ line: lineOf(token)
1764
+ });
1058
1765
  let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1059
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal")), addSpan("---"), flushBlock();
1766
+ style ? startBlock(style) : (reportStyleFallback("normal", lineOf(token)), startBlock("normal")), addSpan("---"), flushBlock();
1060
1767
  break;
1061
1768
  }
1062
1769
  pushBlock(hrObject);
@@ -1075,11 +1782,18 @@ function markdownToPortableText(markdown, options) {
1075
1782
  isInline: !1
1076
1783
  });
1077
1784
  if (!htmlObject) {
1785
+ let snippet = truncateSnippet(htmlContent);
1786
+ report({
1787
+ type: "html-block-to-text",
1788
+ message: degradationMessage["html-block-to-text"],
1789
+ line: lineOf(token),
1790
+ snippet
1791
+ });
1078
1792
  let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1079
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal")), addSpan(htmlContent), flushBlock();
1793
+ style ? startBlock(style) : (reportStyleFallback("normal", lineOf(token)), startBlock("normal")), addSpan(htmlContent), flushBlock();
1080
1794
  break;
1081
1795
  }
1082
- pushBlock(htmlObject);
1796
+ reportFieldsDropped(htmlObject, lineOf(token)), pushBlock(htmlObject);
1083
1797
  break;
1084
1798
  }
1085
1799
  case "code_block": {
@@ -1095,10 +1809,17 @@ function markdownToPortableText(markdown, options) {
1095
1809
  },
1096
1810
  isInline: !1
1097
1811
  });
1098
- if (codeObject) pushBlock(codeObject);
1812
+ if (codeObject) reportFieldsDropped(codeObject, lineOf(token)), pushBlock(codeObject);
1099
1813
  else {
1814
+ let snippet = truncateSnippet(code.split("\n")[0] ?? "");
1815
+ report({
1816
+ type: "code-block-to-text",
1817
+ message: degradationMessage["code-block-to-text"](void 0),
1818
+ line: lineOf(token),
1819
+ snippet
1820
+ });
1100
1821
  let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1101
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal")), addSpan(code), flushBlock();
1822
+ style ? startBlock(style) : (reportStyleFallback("normal", lineOf(token)), startBlock("normal")), addSpan(code), flushBlock();
1102
1823
  }
1103
1824
  break;
1104
1825
  }
@@ -1107,7 +1828,8 @@ function markdownToPortableText(markdown, options) {
1107
1828
  rows: [],
1108
1829
  headerRows: 0,
1109
1830
  emptyHeaderDropped: !1,
1110
- alignment: []
1831
+ alignment: [],
1832
+ line: lineOf(token)
1111
1833
  };
1112
1834
  break;
1113
1835
  case "table_close":
@@ -1125,8 +1847,16 @@ function markdownToPortableText(markdown, options) {
1125
1847
  },
1126
1848
  isInline: !1
1127
1849
  });
1128
- tableObject ? pushBlock(tableObject) : flattenTable(currentTable, blockTarget());
1129
- } else flattenTable(currentTable, blockTarget());
1850
+ tableObject ? (reportFieldsDropped(tableObject, currentTable.line), pushBlock(tableObject)) : (report({
1851
+ type: "table-flattened",
1852
+ message: degradationMessage["table-flattened"],
1853
+ line: currentTable.line
1854
+ }), flattenTable(currentTable, blockTarget()));
1855
+ } else report({
1856
+ type: "table-flattened",
1857
+ message: degradationMessage["table-flattened"],
1858
+ line: currentTable.line
1859
+ }), flattenTable(currentTable, blockTarget());
1130
1860
  currentTable = null;
1131
1861
  break;
1132
1862
  case "thead_open":
@@ -1151,7 +1881,7 @@ function markdownToPortableText(markdown, options) {
1151
1881
  case "td_open": {
1152
1882
  currentTable && inTableHead && token.type === "th_open" && currentTable.alignment.push(extractAlignmentFromStyleAttr(token.attrGet("style")));
1153
1883
  let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1154
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal"));
1884
+ style ? startBlock(style) : (reportStyleFallback("normal", currentTable?.line), startBlock("normal"));
1155
1885
  break;
1156
1886
  }
1157
1887
  case "th_close":
@@ -1174,10 +1904,24 @@ function markdownToPortableText(markdown, options) {
1174
1904
  _key: consolidatedOptions.keyGenerator(),
1175
1905
  markDefs: []
1176
1906
  });
1177
- let firstBlock = cellBlocks[0];
1907
+ let demotedInlineImages = [];
1908
+ for (let block of cellBlocks) if (block._type === "block" && "children" in block && Array.isArray(block.children)) for (let child of block.children) typeof child == "object" && child && demotedTableImages.has(child) && demotedInlineImages.push(child);
1909
+ let firstBlock = cellBlocks[0], liftedObject;
1178
1910
  if (cellBlocks.length === 1 && firstBlock && firstBlock._type === "block" && "children" in firstBlock && Array.isArray(firstBlock.children) && firstBlock.children.length === 1) {
1179
1911
  let onlyChild = firstBlock.children[0];
1180
- typeof onlyChild == "object" && onlyChild && "_type" in onlyChild && onlyChild._type !== consolidatedOptions.schema.span.name && onlyChild._type === "image" && (cellBlocks[0] = onlyChild);
1912
+ if (typeof onlyChild == "object" && onlyChild && "_type" in onlyChild && onlyChild._type !== consolidatedOptions.schema.span.name) {
1913
+ let declaredInline = consolidatedOptions.schema.inlineObjects.some((inlineObject) => inlineObject.name === onlyChild._type), declaredBlock = consolidatedOptions.schema.blockObjects.some((blockObject) => blockObject.name === onlyChild._type);
1914
+ declaredInline && !declaredBlock || (cellBlocks[0] = onlyChild, liftedObject = onlyChild);
1915
+ }
1916
+ }
1917
+ for (let demotedImage of demotedInlineImages) if (demotedImage !== liftedObject) {
1918
+ let { alt, src } = demotedImage;
1919
+ report({
1920
+ type: "image-block-to-inline",
1921
+ message: degradationMessage["image-block-to-inline"](demotedTableImages.get(demotedImage) ?? "table-cell"),
1922
+ line: currentTable?.line,
1923
+ snippet: truncateSnippet(alt || src || "")
1924
+ });
1181
1925
  }
1182
1926
  currentTableRow !== null && currentTableRow.push({
1183
1927
  _type: "cell",
@@ -1187,7 +1931,7 @@ function markdownToPortableText(markdown, options) {
1187
1931
  break;
1188
1932
  }
1189
1933
  case "inline": {
1190
- let inTableCell = currentTableRow !== null;
1934
+ let inTableCell = currentTableRow !== null, inlineLine = () => lineOf(token) ?? currentTable?.line;
1191
1935
  if (token.children?.length === 1 && token.children[0]?.type === "image") {
1192
1936
  let imageToken = token.children[0];
1193
1937
  if (!imageToken) break;
@@ -1204,7 +1948,7 @@ function markdownToPortableText(markdown, options) {
1204
1948
  isInline: !1
1205
1949
  });
1206
1950
  if (blockImageObject) {
1207
- inTableCell ? currentBlock && "children" in currentBlock && currentBlock.children.push(blockImageObject) : (currentBlock && "children" in currentBlock && currentBlock.children.length > 0 ? flushBlock() : (currentBlock = null, currentMarkDefs = []), pushBlock(blockImageObject));
1951
+ reportFieldsDropped(blockImageObject, inlineLine()), inTableCell ? (demotedTableImages.set(blockImageObject, "table-cell"), currentBlock && "children" in currentBlock && currentBlock.children.push(blockImageObject)) : (currentBlock && "children" in currentBlock && currentBlock.children.length > 0 ? flushBlock() : (currentBlock = null, currentMarkDefs = []), pushBlock(blockImageObject));
1208
1952
  break;
1209
1953
  }
1210
1954
  let inlineImageObject = consolidatedOptions.types.image({
@@ -1220,165 +1964,281 @@ function markdownToPortableText(markdown, options) {
1220
1964
  isInline: !0
1221
1965
  });
1222
1966
  if (inlineImageObject) {
1223
- if (!currentBlock) if (inListItem) if (listContainerStack.at(-1)) startBlock(consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } }) ?? "normal");
1224
- else {
1225
- let listType = currentListStack.at(-1);
1226
- listType && ensureListBlock(listType);
1227
- }
1228
- else {
1229
- let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1230
- style && startBlock(style);
1967
+ if (reportFieldsDropped(inlineImageObject, inlineLine()), inTableCell ? demotedTableImages.set(inlineImageObject, "no-block-image") : report({
1968
+ type: "image-block-to-inline",
1969
+ message: degradationMessage["image-block-to-inline"]("no-block-image"),
1970
+ line: inlineLine(),
1971
+ snippet: truncateSnippet(alt || src)
1972
+ }), !currentBlock) {
1973
+ if (inListItem) {
1974
+ if (listContainerStack.at(-1)) {
1975
+ let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1976
+ style || reportStyleFallback("normal", inlineLine()), startBlock(style ?? "normal");
1977
+ } else {
1978
+ let listType = currentListStack.at(-1);
1979
+ listType && ensureListBlock(listType);
1980
+ }
1981
+ } else {
1982
+ let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1983
+ style && startBlock(style);
1984
+ }
1231
1985
  }
1232
1986
  currentBlock && "children" in currentBlock && currentBlock.children.push(inlineImageObject);
1233
1987
  break;
1234
1988
  }
1235
- addSpan(`![${alt}](${src})`);
1989
+ let standaloneImageSnippet = truncateSnippet(alt || src);
1990
+ report({
1991
+ type: "image-to-text",
1992
+ message: degradationMessage["image-to-text"],
1993
+ line: inlineLine(),
1994
+ snippet: standaloneImageSnippet
1995
+ }), addSpan(`![${alt}](${src})`);
1236
1996
  break;
1237
1997
  }
1238
- for (let childToken of token.children ?? []) switch (childToken.type) {
1239
- case "text":
1240
- addSpan(childToken.content);
1241
- break;
1242
- case "softbreak":
1243
- case "hardbreak":
1244
- addSpan("\n");
1245
- break;
1246
- case "code_inline": {
1247
- let decorator = consolidatedOptions.marks.code({ context: { schema: consolidatedOptions.schema } });
1248
- if (!decorator) {
1998
+ let inlineChildren = token.children ?? [];
1999
+ for (let childIndex = 0; childIndex < inlineChildren.length; childIndex++) {
2000
+ let childToken = inlineChildren[childIndex];
2001
+ if (childToken) switch (childToken.type) {
2002
+ case "text": {
2003
+ let nextToken = inlineChildren[childIndex + 1];
2004
+ if (childToken.content.endsWith("json:object") && nextToken?.type === "code_inline" && currentBlock && "children" in currentBlock) {
2005
+ let objectValue = parseJsonObjectFence(nextToken.content);
2006
+ if (objectValue) {
2007
+ let prefix = childToken.content.slice(0, -11);
2008
+ prefix.length > 0 && addSpan(prefix), currentBlock.children.push(objectValue), childIndex++;
2009
+ break;
2010
+ }
2011
+ report({
2012
+ type: "object-carrier-invalid",
2013
+ message: degradationMessage["object-carrier-invalid"]("code-span", nextToken.content),
2014
+ line: inlineLine(),
2015
+ snippet: truncateSnippet(nextToken.content)
2016
+ });
2017
+ }
1249
2018
  addSpan(childToken.content);
1250
2019
  break;
1251
2020
  }
1252
- markDefRefs.push(decorator), addSpan(childToken.content);
1253
- let index = markDefRefs.lastIndexOf(decorator);
1254
- index !== -1 && markDefRefs.splice(index, 1);
1255
- break;
1256
- }
1257
- case "strong_open": {
1258
- let decorator = consolidatedOptions.marks.strong({ context: { schema: consolidatedOptions.schema } });
1259
- if (!decorator) break;
1260
- markDefRefs.push(decorator);
1261
- break;
1262
- }
1263
- case "strong_close": {
1264
- let decorator = consolidatedOptions.marks.strong({ context: { schema: consolidatedOptions.schema } });
1265
- if (!decorator) break;
1266
- let index = markDefRefs.lastIndexOf(decorator);
1267
- index !== -1 && markDefRefs.splice(index, 1);
1268
- break;
1269
- }
1270
- case "em_open": {
1271
- let decorator = consolidatedOptions.marks.em({ context: { schema: consolidatedOptions.schema } });
1272
- if (!decorator) break;
1273
- markDefRefs.push(decorator);
1274
- break;
1275
- }
1276
- case "em_close": {
1277
- let decorator = consolidatedOptions.marks.em({ context: { schema: consolidatedOptions.schema } });
1278
- if (!decorator) break;
1279
- let index = markDefRefs.lastIndexOf(decorator);
1280
- index !== -1 && markDefRefs.splice(index, 1);
1281
- break;
1282
- }
1283
- case "s_open": {
1284
- let decorator = consolidatedOptions.marks.strikeThrough({ context: { schema: consolidatedOptions.schema } });
1285
- if (!decorator) break;
1286
- markDefRefs.push(decorator);
1287
- break;
1288
- }
1289
- case "s_close": {
1290
- let decorator = consolidatedOptions.marks.strikeThrough({ context: { schema: consolidatedOptions.schema } });
1291
- if (!decorator) break;
1292
- let index = markDefRefs.lastIndexOf(decorator);
1293
- index !== -1 && markDefRefs.splice(index, 1);
1294
- break;
1295
- }
1296
- case "link_open": {
1297
- let href = childToken.attrs?.find(([name]) => name === "href")?.at(1);
1298
- if (!href) break;
1299
- let title = childToken.attrs?.find(([name]) => name === "title")?.at(1), linkObject = consolidatedOptions.marks.link({
1300
- context: {
1301
- schema: consolidatedOptions.schema,
1302
- keyGenerator: consolidatedOptions.keyGenerator
1303
- },
1304
- value: {
1305
- href,
1306
- title
2021
+ case "softbreak":
2022
+ addSpan(" ");
2023
+ break;
2024
+ case "hardbreak":
2025
+ addSpan("\n");
2026
+ break;
2027
+ case "code_inline": {
2028
+ let decorator = consolidatedOptions.marks.code({ context: { schema: consolidatedOptions.schema } });
2029
+ if (!decorator) {
2030
+ let codeSnippet = truncateSnippet(childToken.content);
2031
+ report({
2032
+ type: "decorator-dropped",
2033
+ message: degradationMessage["decorator-dropped"]("code"),
2034
+ line: inlineLine(),
2035
+ snippet: codeSnippet
2036
+ }), addSpan(childToken.content);
2037
+ break;
1307
2038
  }
1308
- });
1309
- if (!linkObject) break;
1310
- currentMarkDefs.push(linkObject), markDefRefs.push(linkObject._key);
1311
- break;
1312
- }
1313
- case "link_close": {
1314
- let markDefKeys = new Set(currentMarkDefs.map((d) => d._key)), lastLinkIndex;
1315
- for (let markDefRef of markDefRefs.reverse()) if (markDefKeys.has(markDefRef)) {
1316
- lastLinkIndex = markDefRefs.indexOf(markDefRef);
2039
+ markDefRefs.push(decorator), addSpan(childToken.content);
2040
+ let index = markDefRefs.lastIndexOf(decorator);
2041
+ index !== -1 && markDefRefs.splice(index, 1);
2042
+ break;
2043
+ }
2044
+ case "strong_open": {
2045
+ let decorator = consolidatedOptions.marks.strong({ context: { schema: consolidatedOptions.schema } });
2046
+ if (!decorator) {
2047
+ let strongSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "strong_open", "strong_close"));
2048
+ report({
2049
+ type: "decorator-dropped",
2050
+ message: degradationMessage["decorator-dropped"]("strong"),
2051
+ line: inlineLine(),
2052
+ snippet: strongSnippet
2053
+ });
2054
+ break;
2055
+ }
2056
+ markDefRefs.push(decorator);
1317
2057
  break;
1318
2058
  }
1319
- if (lastLinkIndex !== void 0) {
1320
- let realIndex = markDefRefs.length - 1 - lastLinkIndex;
1321
- markDefRefs.splice(realIndex, 1);
2059
+ case "strong_close": {
2060
+ let decorator = consolidatedOptions.marks.strong({ context: { schema: consolidatedOptions.schema } });
2061
+ if (!decorator) break;
2062
+ let index = markDefRefs.lastIndexOf(decorator);
2063
+ index !== -1 && markDefRefs.splice(index, 1);
2064
+ break;
1322
2065
  }
1323
- break;
1324
- }
1325
- case "image": {
1326
- let src = childToken.attrs?.find(([name]) => name === "src")?.at(1) || "", alt = unescapeImageAndLinkText(childToken.content || ""), inlineImageObject = consolidatedOptions.types.image({
1327
- context: {
1328
- schema: consolidatedOptions.schema,
1329
- keyGenerator: consolidatedOptions.keyGenerator
1330
- },
1331
- value: {
1332
- src,
1333
- alt,
1334
- title: void 0
1335
- },
1336
- isInline: !0
1337
- });
1338
- if (inlineImageObject) {
1339
- if (!currentBlock) {
1340
- let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1341
- style ? startBlock(style) : (console.warn("No default style found, using \"normal\""), startBlock("normal"));
2066
+ case "em_open": {
2067
+ let decorator = consolidatedOptions.marks.em({ context: { schema: consolidatedOptions.schema } });
2068
+ if (!decorator) {
2069
+ let emSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "em_open", "em_close"));
2070
+ report({
2071
+ type: "decorator-dropped",
2072
+ message: degradationMessage["decorator-dropped"]("em"),
2073
+ line: inlineLine(),
2074
+ snippet: emSnippet
2075
+ });
2076
+ break;
1342
2077
  }
1343
- if (!currentBlock) throw Error("Expected current block after startBlock");
1344
- currentBlock.children.push(inlineImageObject);
2078
+ markDefRefs.push(decorator);
1345
2079
  break;
1346
2080
  }
1347
- let blockImageObject = consolidatedOptions.types.image({
1348
- context: {
1349
- schema: consolidatedOptions.schema,
1350
- keyGenerator: consolidatedOptions.keyGenerator
1351
- },
1352
- value: {
1353
- src,
1354
- alt,
1355
- title: void 0
1356
- },
1357
- isInline: !1
1358
- });
1359
- if (!blockImageObject) {
1360
- addSpan(`![${alt}](${src})`);
2081
+ case "em_close": {
2082
+ let decorator = consolidatedOptions.marks.em({ context: { schema: consolidatedOptions.schema } });
2083
+ if (!decorator) break;
2084
+ let index = markDefRefs.lastIndexOf(decorator);
2085
+ index !== -1 && markDefRefs.splice(index, 1);
1361
2086
  break;
1362
2087
  }
1363
- if (inTableCell) {
1364
- currentBlock && "children" in currentBlock && currentBlock.children.push(blockImageObject);
2088
+ case "s_open": {
2089
+ let decorator = consolidatedOptions.marks.strikeThrough({ context: { schema: consolidatedOptions.schema } });
2090
+ if (!decorator) {
2091
+ let strikeSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "s_open", "s_close"));
2092
+ report({
2093
+ type: "decorator-dropped",
2094
+ message: degradationMessage["decorator-dropped"]("strikeThrough"),
2095
+ line: inlineLine(),
2096
+ snippet: strikeSnippet
2097
+ });
2098
+ break;
2099
+ }
2100
+ markDefRefs.push(decorator);
1365
2101
  break;
1366
2102
  }
1367
- flushBlock(), pushBlock(blockImageObject);
1368
- let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
1369
- style && startBlock(style);
1370
- break;
2103
+ case "s_close": {
2104
+ let decorator = consolidatedOptions.marks.strikeThrough({ context: { schema: consolidatedOptions.schema } });
2105
+ if (!decorator) break;
2106
+ let index = markDefRefs.lastIndexOf(decorator);
2107
+ index !== -1 && markDefRefs.splice(index, 1);
2108
+ break;
2109
+ }
2110
+ case "link_open": {
2111
+ let href = childToken.attrs?.find(([name]) => name === "href")?.at(1);
2112
+ if (!href) {
2113
+ let missingHrefSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "link_open", "link_close"));
2114
+ report({
2115
+ type: "annotation-dropped",
2116
+ message: degradationMessage["annotation-dropped"]("missing-url"),
2117
+ line: inlineLine(),
2118
+ snippet: missingHrefSnippet
2119
+ });
2120
+ break;
2121
+ }
2122
+ let title = childToken.attrs?.find(([name]) => name === "title")?.at(1), linkObject = consolidatedOptions.marks.link({
2123
+ context: {
2124
+ schema: consolidatedOptions.schema,
2125
+ keyGenerator: consolidatedOptions.keyGenerator
2126
+ },
2127
+ value: {
2128
+ href,
2129
+ title
2130
+ }
2131
+ });
2132
+ if (!linkObject) {
2133
+ let linkSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "link_open", "link_close"));
2134
+ report({
2135
+ type: "annotation-dropped",
2136
+ message: degradationMessage["annotation-dropped"]("no-annotation"),
2137
+ line: inlineLine(),
2138
+ snippet: linkSnippet
2139
+ });
2140
+ break;
2141
+ }
2142
+ reportFieldsDropped(linkObject, inlineLine()), currentMarkDefs.push(linkObject), markDefRefs.push(linkObject._key);
2143
+ break;
2144
+ }
2145
+ case "link_close": {
2146
+ let markDefKeys = new Set(currentMarkDefs.map((d) => d._key)), lastLinkIndex;
2147
+ for (let markDefRef of markDefRefs.reverse()) if (markDefKeys.has(markDefRef)) {
2148
+ lastLinkIndex = markDefRefs.indexOf(markDefRef);
2149
+ break;
2150
+ }
2151
+ if (lastLinkIndex !== void 0) {
2152
+ let realIndex = markDefRefs.length - 1 - lastLinkIndex;
2153
+ markDefRefs.splice(realIndex, 1);
2154
+ }
2155
+ break;
2156
+ }
2157
+ case "image": {
2158
+ let src = childToken.attrs?.find(([name]) => name === "src")?.at(1) || "", alt = unescapeImageAndLinkText(childToken.content || ""), inlineImageObject = consolidatedOptions.types.image({
2159
+ context: {
2160
+ schema: consolidatedOptions.schema,
2161
+ keyGenerator: consolidatedOptions.keyGenerator
2162
+ },
2163
+ value: {
2164
+ src,
2165
+ alt,
2166
+ title: void 0
2167
+ },
2168
+ isInline: !0
2169
+ });
2170
+ if (inlineImageObject) {
2171
+ if (reportFieldsDropped(inlineImageObject, inlineLine()), !currentBlock) {
2172
+ let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
2173
+ style ? startBlock(style) : (reportStyleFallback("normal", inlineLine()), startBlock("normal"));
2174
+ }
2175
+ if (!currentBlock) throw Error("Expected current block after startBlock");
2176
+ currentBlock.children.push(inlineImageObject);
2177
+ break;
2178
+ }
2179
+ let blockImageObject = consolidatedOptions.types.image({
2180
+ context: {
2181
+ schema: consolidatedOptions.schema,
2182
+ keyGenerator: consolidatedOptions.keyGenerator
2183
+ },
2184
+ value: {
2185
+ src,
2186
+ alt,
2187
+ title: void 0
2188
+ },
2189
+ isInline: !1
2190
+ });
2191
+ if (!blockImageObject) {
2192
+ let inlineImageSnippet = truncateSnippet(alt || src);
2193
+ report({
2194
+ type: "image-to-text",
2195
+ message: degradationMessage["image-to-text"],
2196
+ line: inlineLine(),
2197
+ snippet: inlineImageSnippet
2198
+ }), addSpan(`![${alt}](${src})`);
2199
+ break;
2200
+ }
2201
+ if (inTableCell) {
2202
+ reportFieldsDropped(blockImageObject, inlineLine()), demotedTableImages.set(blockImageObject, "table-cell"), currentBlock && "children" in currentBlock && currentBlock.children.push(blockImageObject);
2203
+ break;
2204
+ }
2205
+ reportFieldsDropped(blockImageObject, inlineLine()), report({
2206
+ type: "image-inline-to-block",
2207
+ message: degradationMessage["image-inline-to-block"],
2208
+ line: inlineLine(),
2209
+ snippet: truncateSnippet(alt || src)
2210
+ }), flushBlock(), pushBlock(blockImageObject);
2211
+ let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
2212
+ style && startBlock(style);
2213
+ break;
2214
+ }
2215
+ case "html_inline": if (consolidatedOptions.html.inline === "text") addSpan(childToken.content);
2216
+ else if (childToken.content) {
2217
+ let htmlInlineSnippet = truncateSnippet(childToken.content);
2218
+ report({
2219
+ type: "inline-html-dropped",
2220
+ message: degradationMessage["inline-html-dropped"],
2221
+ line: inlineLine(),
2222
+ snippet: htmlInlineSnippet
2223
+ });
2224
+ }
1371
2225
  }
1372
- case "html_inline": consolidatedOptions.html.inline === "text" && addSpan(childToken.content);
1373
2226
  }
1374
2227
  break;
1375
2228
  }
1376
- case "alert_open":
1377
- flushBlock(), calloutStartTarget = blockTarget(), calloutStartIndex = calloutStartTarget.length, calloutType = token.markup, currentBlockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } }) ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } }) ?? "normal";
2229
+ case "alert_open": {
2230
+ flushBlock(), calloutStartTarget = blockTarget(), calloutStartIndex = calloutStartTarget.length, calloutType = token.markup, calloutStartLine = lineOf(token);
2231
+ let blockquoteStyle = consolidatedOptions.block.blockquote({ context: { schema: consolidatedOptions.schema } });
2232
+ calloutPendingStyleFallbacks = [], blockquoteStyle || calloutPendingStyleFallbacks.push("blockquote");
2233
+ let style = blockquoteStyle ?? consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
2234
+ style || calloutPendingStyleFallbacks.push("normal"), currentBlockquoteStyle = style ?? "normal";
2235
+ break;
2236
+ }
2237
+ case "alert_title":
2238
+ calloutStartLine = lineOf(token);
1378
2239
  break;
1379
- case "alert_title": break;
1380
2240
  case "alert_close":
1381
- if (flushBlock(), calloutStartIndex !== null && calloutType !== null && calloutStartTarget !== null) {
2241
+ if (flushBlock(), calloutPendingStyleFallbacks = [], calloutStartIndex !== null && calloutType !== null && calloutStartTarget !== null) {
1382
2242
  let contentBlocks = calloutStartTarget.splice(calloutStartIndex), calloutObject = consolidatedOptions.types.callout?.({
1383
2243
  context: {
1384
2244
  schema: consolidatedOptions.schema,
@@ -1390,14 +2250,864 @@ function markdownToPortableText(markdown, options) {
1390
2250
  },
1391
2251
  isInline: !1
1392
2252
  });
1393
- if (calloutObject) pushBlock(calloutObject);
1394
- else for (let block of contentBlocks) pushBlock(block);
2253
+ if (calloutObject) reportFieldsDropped(calloutObject, calloutStartLine), pushBlock(calloutObject);
2254
+ else {
2255
+ report({
2256
+ type: "callout-fallback",
2257
+ message: degradationMessage["callout-fallback"](calloutType, currentBlockquoteStyle ?? "normal"),
2258
+ line: calloutStartLine
2259
+ });
2260
+ for (let block of contentBlocks) pushBlock(block);
2261
+ }
1395
2262
  }
1396
- calloutStartIndex = null, calloutStartTarget = null, calloutType = null, currentBlockquoteStyle = null;
2263
+ calloutStartIndex = null, calloutStartTarget = null, calloutType = null, calloutStartLine = void 0, currentBlockquoteStyle = null;
2264
+ }
2265
+ }
2266
+ return flushBlock(), degradationEvents.length > 0 && options?.onDegradation?.({
2267
+ degradations: degradationEvents,
2268
+ message: buildDegradationMessage(degradationEvents)
2269
+ }), portableText;
2270
+ }
2271
+ /**
2272
+ * A `json:object` fence always reconstructs its object, schema or no
2273
+ * schema: the fence carries its own `_type`, and degrading it to a code
2274
+ * block would reintroduce the loss the syntax exists to remove. Returns
2275
+ * `undefined` instead of throwing, so an unusable fence falls through to
2276
+ * the regular code path.
2277
+ */
2278
+ function parseJsonObjectFence(code) {
2279
+ let parsed;
2280
+ try {
2281
+ parsed = JSON.parse(code);
2282
+ } catch {
2283
+ return;
2284
+ }
2285
+ if (typeof parsed != "object" || !parsed || Array.isArray(parsed)) return;
2286
+ let objectValue = parsed;
2287
+ if (typeof objectValue._type == "string" && objectValue._type.length !== 0) return objectValue;
2288
+ }
2289
+ /**
2290
+ * Deliberately high pending calibration against real agent edit
2291
+ * traces: a wrong match moves anchors onto unrelated text, a fresh
2292
+ * key resets one block.
2293
+ */
2294
+ const MIN_BLOCK_SIMILARITY = .8, MAX_SIMILARITY_PAIRS = 2500;
2295
+ /**
2296
+ * Converts edited markdown to Portable Text, restores stored keys, and
2297
+ * restores fields the markdown dialect cannot express (dropped by
2298
+ * serialization, so the edit could not have touched them); a field
2299
+ * markdown does express follows the edit. The same rule covers a
2300
+ * custom style or decorator markdown has no syntax for, coerced to a
2301
+ * built-in on the round trip. An empty or whitespace-only text block
2302
+ * is the same case taken to the whole block: markdown has no form for
2303
+ * either, so it is restored next to its surviving neighbor and
2304
+ * dropped along with that neighbor if the neighbor does not survive.
2305
+ * Keys aim for what the
2306
+ * same edit would have produced in an editor:
2307
+ * unchanged, moved, and rewritten-in-place content keeps its keys
2308
+ * (rewriting a paragraph in place keeps its identity, like typing over
2309
+ * it), a split keeps the key on its first non-empty fragment, a merge
2310
+ * keeps the first source block's key, and a `json:object` payload keeps
2311
+ * the key it carries, unless reconciliation matches it to stored
2312
+ * content, which takes the stored key even over a differing key in the
2313
+ * payload. When an insertion or deletion makes positions ambiguous,
2314
+ * only clear similarity evidence adopts a key and everything else gets
2315
+ * a new one; gathering that evidence is time-capped, so on very large
2316
+ * ambiguous edits the set of adopted keys can differ across machine
2317
+ * speeds, degrading toward fresh keys.
2318
+ * Output keys are unique among siblings. The function does not mutate
2319
+ * `storedPortableText` and returns a value, not patches. The `schema`
2320
+ * is taken once and governs both directions; pass the same `serialize`
2321
+ * options that produced the markdown that was edited. A throwing or
2322
+ * stateful custom matcher or renderer propagates or degrades matching
2323
+ * respectively.
2324
+ * Reconciliation never merges concurrent edits: compare the stored
2325
+ * field against the live document before writing the result back.
2326
+ * The trades in one line: same-position replacement inherits identity,
2327
+ * a count-preserving rewrite pairs positionally (block-level and
2328
+ * sibling-level alike), and evidence gathering is capped, degrading to
2329
+ * fresh keys.
2330
+ *
2331
+ * @public
2332
+ */
2333
+ function applyMarkdownEdit(storedPortableText, editedMarkdown, options) {
2334
+ let onReconciliation = options?.onReconciliation, recorder = onReconciliation ? {
2335
+ preservationBasis: /* @__PURE__ */ new WeakMap(),
2336
+ renamePreviousKey: /* @__PURE__ */ new WeakMap(),
2337
+ annotationKeyConflicts: [],
2338
+ ambiguousRegionGroups: [],
2339
+ skipReason: void 0
2340
+ } : void 0, result = structuredClone(markdownToPortableText(editedMarkdown, {
2341
+ ...options?.deserialize,
2342
+ schema: options?.schema
2343
+ })), canonical = canonicalizeStored(storedPortableText, options), originOf = traceOrigins(nonEmptyBlocks(storedPortableText, options), canonical, recorder), adoptedNodes = /* @__PURE__ */ new WeakSet();
2344
+ if (originOf) {
2345
+ let alignment = alignBlocks(canonical, result, recorder);
2346
+ if (alignment) {
2347
+ adoptAnchors(alignment.anchors, originOf, result, adoptedNodes, recorder);
2348
+ let gaps = adoptMoves(alignment.gaps, canonical, result, originOf, adoptedNodes, recorder);
2349
+ for (let gap of gaps) resolveGap(gap.storedIndexes, gap.editedIndexes, canonical, result, originOf, adoptedNodes, recorder);
2350
+ reinsertEmptyRuns(storedPortableText, result, adoptedNodes, options, recorder);
2351
+ }
2352
+ }
2353
+ return enforceSiblingKeyUniqueness(result, options?.deserialize?.keyGenerator ?? defaultKeyGenerator, adoptedNodes, recorder), recorder && onReconciliation && onReconciliation(buildReconciliationReport(result, recorder)), result;
2354
+ }
2355
+ /**
2356
+ * Re-expresses the stored value in the parser's dialect by serializing
2357
+ * it and parsing it right back: the parser merges same-mark sibling
2358
+ * spans, reorders marks, collapses list levels, and fills defaults, so
2359
+ * content the edit never touched only deep-equals its parsed
2360
+ * counterpart after both sides have been through the same round trip.
2361
+ * The replacement keys are positional (`__canonical_<n>`), so a match
2362
+ * against canonical node `n` can be traded back for stored node `n`'s
2363
+ * real `_key`.
2364
+ */
2365
+ function canonicalizeStored(stored, options) {
2366
+ let storedMarkdown = portableTextToMarkdown(structuredClone(stored), {
2367
+ ...options?.serialize,
2368
+ schema: options?.schema
2369
+ }), { onDegradation, ...canonicalDeserializeOptions } = options?.deserialize ?? {}, canonicalKeyCounter = 0;
2370
+ return markdownToPortableText(storedMarkdown, {
2371
+ ...canonicalDeserializeOptions,
2372
+ schema: options?.schema,
2373
+ keyGenerator: () => `__canonical_${canonicalKeyCounter++}`
2374
+ });
2375
+ }
2376
+ /**
2377
+ * Positional pairing between `stored` and `canonical` is only
2378
+ * trustworthy when serialization preserved the node count and the
2379
+ * type sequence; when it did not (heading hard-break splits, lossy
2380
+ * table normalization), no key can be traced back to its owner, so
2381
+ * nothing adopts. `stored` is already the non-empty subsequence: empty
2382
+ * text blocks have no markdown form, so `canonical` never carries them
2383
+ * either, and `reinsertEmptyRuns` restores them afterward.
2384
+ */
2385
+ function traceOrigins(stored, canonical, recorder) {
2386
+ if (canonical.length !== stored.length) {
2387
+ recorder && (recorder.skipReason = "round-trip-mismatch");
2388
+ return;
2389
+ }
2390
+ for (let index = 0; index < stored.length; index++) if (stored[index]._type !== canonical[index]?._type) {
2391
+ recorder && (recorder.skipReason = "round-trip-mismatch");
2392
+ return;
2393
+ }
2394
+ for (let index = 0; index < stored.length; index++) {
2395
+ let storedNode = stored[index], canonicalNode = canonical[index];
2396
+ if (isTextBlock$1(storedNode) && isTextBlock$1(canonicalNode) && blockText(storedNode).trim() !== blockText(canonicalNode).trim()) {
2397
+ recorder && (recorder.skipReason = "round-trip-mismatch");
2398
+ return;
2399
+ }
2400
+ }
2401
+ return (canonicalIndex) => stored[canonicalIndex];
2402
+ }
2403
+ /**
2404
+ * Equal runs become anchor pairs (so repeated content pairs
2405
+ * first-to-first when adopted), and the delete/insert runs between
2406
+ * them form the gaps.
2407
+ */
2408
+ function alignBlocks(canonical, result, recorder) {
2409
+ let tokenByNeutral = /* @__PURE__ */ new Map(), tokenOf = (node) => {
2410
+ let neutral = neutralForm(node), token = tokenByNeutral.get(neutral);
2411
+ return token === void 0 && (token = String.fromCharCode(tokenByNeutral.size + 1), tokenByNeutral.set(neutral, token)), token;
2412
+ }, canonicalTokens = canonical.map(tokenOf).join(""), resultTokens = result.map(tokenOf).join("");
2413
+ if (tokenByNeutral.size > 55e3) {
2414
+ recorder && (recorder.skipReason = "document-too-large");
2415
+ return;
2416
+ }
2417
+ let diffs = makeDiff(canonicalTokens, resultTokens, { checkLines: !1 }), anchors = [], gaps = [], gap = {
2418
+ storedIndexes: [],
2419
+ editedIndexes: []
2420
+ }, flushGap = () => {
2421
+ (gap.storedIndexes.length > 0 || gap.editedIndexes.length > 0) && (gaps.push(gap), gap = {
2422
+ storedIndexes: [],
2423
+ editedIndexes: []
2424
+ });
2425
+ }, canonicalIndex = 0, resultIndex = 0;
2426
+ for (let [operation, text] of diffs) if (operation === 0) {
2427
+ flushGap();
2428
+ for (let offset = 0; offset < text.length; offset++) anchors.push({
2429
+ canonicalIndex,
2430
+ resultIndex
2431
+ }), canonicalIndex++, resultIndex++;
2432
+ } else if (operation === -1) for (let offset = 0; offset < text.length; offset++) gap.storedIndexes.push(canonicalIndex++);
2433
+ else for (let offset = 0; offset < text.length; offset++) gap.editedIndexes.push(resultIndex++);
2434
+ return flushGap(), {
2435
+ anchors,
2436
+ gaps
2437
+ };
2438
+ }
2439
+ function adoptAnchors(anchors, originOf, result, adoptedNodes, recorder) {
2440
+ for (let anchor of anchors) result[anchor.resultIndex] = adoptVerbatim(originOf(anchor.canonicalIndex), result[anchor.resultIndex], adoptedNodes, "content-unchanged", recorder);
2441
+ }
2442
+ /**
2443
+ * An anchor or a unique exact leftover pairs a canonical block against
2444
+ * a parsed one that share the same neutral form (that is what put them
2445
+ * in the same equal-diff run or the same neutral-form bucket), so
2446
+ * everything the dialect can express is untouched and everything it
2447
+ * cannot express was invisible to the edit. The stored subtree is the
2448
+ * truth at every depth, spans, marks, markDefs, and any field the
2449
+ * dialect drops, so adoption replaces the whole node rather than
2450
+ * reconciling into the parsed shape (which would otherwise, for
2451
+ * instance, keep a parser-side span merge that collapsed an
2452
+ * unmappable mark boundary the edit never touched). `restoreFields`
2453
+ * and per-child reconciliation are skipped entirely: a verbatim clone
2454
+ * of the stored node is already complete.
2455
+ */
2456
+ function adoptVerbatim(original, target, adoptedNodes, basis, recorder) {
2457
+ let clone = structuredClone(original);
2458
+ return fillMissingKeysFromTarget(clone, target), markSubtreeAdopted(clone, adoptedNodes), recorder && (tagSubtreePreserved(clone, recorder), basis !== "content-unchanged" && typeof clone._key == "string" && recorder.preservationBasis.set(clone, basis)), clone;
2459
+ }
2460
+ /**
2461
+ * A stored node practically always carries its own `_key`; when it
2462
+ * genuinely does not, there is nothing to adopt, so the clone keeps
2463
+ * whatever key the plain parse already minted at the corresponding
2464
+ * position, the same key adoption would have left in place. The walk
2465
+ * follows both trees positionally (not by content matching, which is
2466
+ * exactly what an exact-signature match already guarantees agrees at
2467
+ * every position the two sides both still have).
2468
+ */
2469
+ function fillMissingKeysFromTarget(clone, target) {
2470
+ typeof clone._key != "string" && typeof target._key == "string" && (clone._key = target._key);
2471
+ for (let field of Object.keys(clone)) {
2472
+ let cloneValue = clone[field], targetValue = target[field];
2473
+ if (isTypedObjectArray(cloneValue) && isTypedObjectArray(targetValue)) {
2474
+ let length = Math.min(cloneValue.length, targetValue.length);
2475
+ for (let index = 0; index < length; index++) fillMissingKeysFromTarget(cloneValue[index], targetValue[index]);
2476
+ } else typeof cloneValue == "object" && cloneValue && !Array.isArray(cloneValue) && typeof targetValue == "object" && targetValue && !Array.isArray(targetValue) && fillMissingKeysFromTarget(cloneValue, targetValue);
2477
+ }
2478
+ }
2479
+ /**
2480
+ * Every keyed node in a verbatim clone is adopted, not only its root:
2481
+ * the sibling-key-uniqueness pass recurses into every nested keyed
2482
+ * array, and an adopted-first ordering there only favors a clone's
2483
+ * descendants if they are themselves marked adopted.
2484
+ */
2485
+ function markSubtreeAdopted(node, adoptedNodes) {
2486
+ adoptedNodes.add(node);
2487
+ for (let value of Object.values(node)) if (Array.isArray(value) && value.every((item) => typeof item == "object" && !!item) && value.length > 0) for (let child of value) markSubtreeAdopted(child, adoptedNodes);
2488
+ else typeof value == "object" && value && markSubtreeAdopted(value, adoptedNodes);
2489
+ }
2490
+ /**
2491
+ * Moves: content that left one gap and reappeared in another. Unique
2492
+ * exact pairs across all gaps adopt before any gap-local pairing can
2493
+ * consume the keys they need.
2494
+ */
2495
+ function adoptMoves(gaps, canonical, result, originOf, adoptedNodes, recorder) {
2496
+ let consumedStored = /* @__PURE__ */ new Set(), consumedEdited = /* @__PURE__ */ new Set(), storedLeftovers = gaps.flatMap((g) => g.storedIndexes), editedLeftovers = gaps.flatMap((g) => g.editedIndexes), storedByNeutral = /* @__PURE__ */ new Map();
2497
+ for (let index of storedLeftovers) {
2498
+ let neutral = neutralForm(canonical[index]);
2499
+ storedByNeutral.set(neutral, [...storedByNeutral.get(neutral) ?? [], index]);
2500
+ }
2501
+ let editedByNeutral = /* @__PURE__ */ new Map();
2502
+ for (let index of editedLeftovers) {
2503
+ let neutral = neutralForm(result[index]);
2504
+ editedByNeutral.set(neutral, [...editedByNeutral.get(neutral) ?? [], index]);
2505
+ }
2506
+ for (let [neutral, storedIndexes] of storedByNeutral) {
2507
+ let editedIndexes = editedByNeutral.get(neutral);
2508
+ if (storedIndexes.length !== 1 || !editedIndexes || editedIndexes.length !== 1) continue;
2509
+ consumedStored.add(storedIndexes[0]), consumedEdited.add(editedIndexes[0]);
2510
+ let clone = adoptVerbatim(originOf(storedIndexes[0]), result[editedIndexes[0]], adoptedNodes, "content-moved", recorder);
2511
+ result[editedIndexes[0]] = clone;
2512
+ }
2513
+ return gaps.map((currentGap) => ({
2514
+ storedIndexes: currentGap.storedIndexes.filter((index) => !consumedStored.has(index)),
2515
+ editedIndexes: currentGap.editedIndexes.filter((index) => !consumedEdited.has(index))
2516
+ }));
2517
+ }
2518
+ /**
2519
+ * Gap policy, in order: split/merge survivor (the first fragment or
2520
+ * first source block keeps the key, matching what pressing enter or
2521
+ * backspace does in the editor), positional zip for equal counts
2522
+ * (typing over a paragraph keeps its identity), similarity for
2523
+ * unequal counts (an insertion or deletion shifted positions, so
2524
+ * position lies and only mutual unique best evidence adopts).
2525
+ */
2526
+ function resolveGap(storedIndexes, editedIndexes, canonical, result, originOf, adoptedNodes, recorder) {
2527
+ let remainingStored = new Set(storedIndexes), remainingEdited = new Set(editedIndexes), withinConcatenationCap = storedIndexes.length * editedIndexes.length <= MAX_SIMILARITY_PAIRS;
2528
+ if (withinConcatenationCap) for (let storedIndex of storedIndexes) {
2529
+ if (!remainingStored.has(storedIndex)) continue;
2530
+ let storedBlock = canonical[storedIndex];
2531
+ if (!isTextBlock$1(storedBlock)) continue;
2532
+ let fragments = findConcatenation(blockText(storedBlock), editedIndexes.filter((index) => remainingEdited.has(index)), result);
2533
+ if (fragments) {
2534
+ remainingStored.delete(storedIndex);
2535
+ for (let fragment of fragments) remainingEdited.delete(fragment);
2536
+ let survivor = fragments.find((fragment) => blockText(result[fragment]).length > 0) ?? fragments[0];
2537
+ adoptNode(originOf(storedIndex), canonical[storedIndex], result[survivor], adoptedNodes, "content-split", recorder);
2538
+ }
2539
+ }
2540
+ if (withinConcatenationCap) for (let editedIndex of editedIndexes) {
2541
+ if (!remainingEdited.has(editedIndex)) continue;
2542
+ let editedBlock = result[editedIndex];
2543
+ if (!isTextBlock$1(editedBlock)) continue;
2544
+ let sources = findConcatenation(blockText(editedBlock), storedIndexes.filter((index) => remainingStored.has(index)), canonical);
2545
+ if (sources) {
2546
+ remainingEdited.delete(editedIndex);
2547
+ for (let source of sources) remainingStored.delete(source);
2548
+ adoptNode(originOf(sources[0]), canonical[sources[0]], result[editedIndex], adoptedNodes, "content-merged", recorder);
1397
2549
  }
1398
2550
  }
1399
- return flushBlock(), portableText;
2551
+ let storedRest = [...remainingStored], editedRest = [...remainingEdited];
2552
+ if (storedRest.length === editedRest.length) {
2553
+ for (let offset = 0; offset < storedRest.length; offset++) {
2554
+ let storedBlock = canonical[storedRest[offset]], editedBlock = result[editedRest[offset]];
2555
+ storedBlock._type === editedBlock._type && adoptNode(originOf(storedRest[offset]), canonical[storedRest[offset]], editedBlock, adoptedNodes, "same-position", recorder);
2556
+ }
2557
+ return;
2558
+ }
2559
+ if (storedRest.length * editedRest.length > MAX_SIMILARITY_PAIRS) {
2560
+ recorder && recorder.ambiguousRegionGroups.push(editedRest.map((editedIndex) => result[editedIndex]));
2561
+ return;
2562
+ }
2563
+ let scores = /* @__PURE__ */ new Map();
2564
+ for (let storedIndex of storedRest) for (let editedIndex of editedRest) {
2565
+ let score = blockSimilarity(canonical[storedIndex], result[editedIndex]);
2566
+ score >= MIN_BLOCK_SIMILARITY && scores.set(`${storedIndex}:${editedIndex}`, score);
2567
+ }
2568
+ for (let storedIndex of storedRest) {
2569
+ let best = uniqueBest(editedRest, (editedIndex) => scores.get(`${storedIndex}:${editedIndex}`));
2570
+ best !== void 0 && uniqueBest(storedRest, (otherStoredIndex) => scores.get(`${otherStoredIndex}:${best}`)) === storedIndex && adoptNode(originOf(storedIndex), canonical[storedIndex], result[best], adoptedNodes, "similar-content", recorder);
2571
+ }
2572
+ }
2573
+ /**
2574
+ * Fragments join with nothing or a single space, since a markdown
2575
+ * merge is often a soft-wrap join that inserts one ("alpha\nbeta"
2576
+ * parses to "alpha beta").
2577
+ */
2578
+ function findConcatenation(wholeText, candidateIndexes, nodes) {
2579
+ if (wholeText.length !== 0) for (let joiner of ["", " "]) for (let start = 0; start < candidateIndexes.length; start++) {
2580
+ let concatenated = "", used = [];
2581
+ for (let position = start; position < candidateIndexes.length; position++) {
2582
+ let index = candidateIndexes[position];
2583
+ if (position > start && candidateIndexes[position - 1] !== index - 1 || !isTextBlock$1(nodes[index]) || (concatenated = used.length === 0 ? blockText(nodes[index]) : concatenated + joiner + blockText(nodes[index]), used.push(index), concatenated.length > wholeText.length)) break;
2584
+ if (concatenated === wholeText && used.length > 1) return used;
2585
+ }
2586
+ }
2587
+ }
2588
+ /**
2589
+ * Adopts the original node's `_key`, its markdown-inexpressible
2590
+ * fields, then its `markDefs` before its other keyed children, since
2591
+ * `span.marks` references need the adopted `markDefs` keys already in
2592
+ * place.
2593
+ */
2594
+ function adoptNode(original, canonicalCounterpart, target, adoptedNodes, basis, recorder) {
2595
+ adoptedNodes.add(target), typeof original._key == "string" && (target._key = original._key, recorder?.preservationBasis.set(target, basis)), restoreFields(original, canonicalCounterpart, target), rewriteMarkReferences(target, adoptMarkDefs(original, canonicalCounterpart, target, recorder));
2596
+ for (let field of Object.keys(target)) {
2597
+ if (field === "markDefs") continue;
2598
+ let originalChildren = original[field], targetChildren = target[field];
2599
+ if (!isTypedObjectArray(originalChildren) || !isTypedObjectArray(targetChildren)) continue;
2600
+ let canonicalChildren = canonicalChildArray(canonicalCounterpart, field, originalChildren), matchedOriginal = /* @__PURE__ */ new Set(), matchedTarget = /* @__PURE__ */ new Set(), originalGroups = groupByNeutralForm(originalChildren, matchedOriginal, buildAliasMap(original)), targetGroups = groupByNeutralForm(targetChildren, matchedTarget, buildAliasMap(target));
2601
+ for (let [neutral, originalIndexes] of originalGroups) {
2602
+ let targetIndexes = targetGroups.get(neutral);
2603
+ originalIndexes.length !== 1 || !targetIndexes || targetIndexes.length !== 1 || (matchedOriginal.add(originalIndexes[0]), matchedTarget.add(targetIndexes[0]), adoptNode(originalChildren[originalIndexes[0]], canonicalChildren?.[originalIndexes[0]], targetChildren[targetIndexes[0]], adoptedNodes, "content-unchanged", recorder));
2604
+ }
2605
+ field === "children" && originalChildren.length * targetChildren.length <= MAX_SIMILARITY_PAIRS && adoptMergedSpans(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder), adoptResidualZip(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder);
2606
+ }
2607
+ }
2608
+ /**
2609
+ * The container-level counterpart of `traceOrigins`'s guard: a
2610
+ * child's canonical form is only trustworthy when the canonical
2611
+ * container holds the same field as the same typed object array,
2612
+ * equal in length and `_type` sequence to the original's, so pairing
2613
+ * by index (original child `i` to canonical child `i`) means the same
2614
+ * content on both sides.
2615
+ */
2616
+ function canonicalChildArray(canonicalCounterpart, field, originalChildren) {
2617
+ if (!canonicalCounterpart) return;
2618
+ let candidate = canonicalCounterpart[field];
2619
+ if (!(!isTypedObjectArray(candidate) || candidate.length !== originalChildren.length)) {
2620
+ for (let index = 0; index < originalChildren.length; index++) if (originalChildren[index]._type !== candidate[index]._type) return;
2621
+ return candidate;
2622
+ }
2623
+ }
2624
+ /**
2625
+ * Restores fields the markdown dialect dropped or altered: present on
2626
+ * the original, unchanged by the edit (the target still agrees with
2627
+ * the original's own canonical round trip), and different on that
2628
+ * round trip from the original (so the parse, left to itself, could
2629
+ * never have produced the original's value). Absence counts as a
2630
+ * value under both comparisons, which is what folds a wholly dropped
2631
+ * field (no markdown form at all) and a coerced one (a custom style
2632
+ * or decorator markdown silently maps to its closest built-in) into
2633
+ * one rule. Structural child arrays (`children`, `markDefs`, and
2634
+ * typed object arrays generally) are excluded: their elements adopt
2635
+ * individually through the recursive per-child walk instead, except
2636
+ * when the target has no such element to walk at all: a typed-object
2637
+ * array field the dialect drops entirely leaves nothing on the target
2638
+ * side for that walk to reconcile, so it falls through to the same
2639
+ * absent-on-target, absent-on-canonical oracle as every scalar field,
2640
+ * restored verbatim rather than left missing. Restored values are
2641
+ * cloned, since the sibling-key-uniqueness pass may rewrite `_key`s
2642
+ * inside a restored array of objects, and the original must stay
2643
+ * untouched.
2644
+ */
2645
+ function restoreFields(original, canonicalCounterpart, target) {
2646
+ if (canonicalCounterpart) for (let field of Object.keys(original)) {
2647
+ if (field === "_key" || field === "_type" || field === "markDefs" || field === "children") continue;
2648
+ if (isTypedObjectArray(original[field])) {
2649
+ target[field] === void 0 && canonicalCounterpart[field] === void 0 && (target[field] = structuredClone(original[field]));
2650
+ continue;
2651
+ }
2652
+ let canonicalValue = canonicalCounterpart[field];
2653
+ valuesEqual(target[field], canonicalValue) && (valuesEqual(canonicalValue, original[field]) || (target[field] = structuredClone(original[field])));
2654
+ }
2655
+ }
2656
+ /**
2657
+ * Deep equality for restoration's before/after comparison: the
2658
+ * `encodeNeutral` encoding without alias rewriting, so it compares
2659
+ * `marks` arrays (and any other array or nested object) by value
2660
+ * rather than by identity. Absent (`undefined`) encodes to the same
2661
+ * string on both sides, so two absent fields count as equal.
2662
+ */
2663
+ function valuesEqual(a, b) {
2664
+ return encodeNeutral(a, void 0) === encodeNeutral(b, void 0);
2665
+ }
2666
+ /**
2667
+ * A span in the edited output can be the merge of several stored
2668
+ * spans: the parser merges adjacent same-mark spans, and an edit that
2669
+ * removes formatting merges across the old mark boundary too. Matching
2670
+ * is by text alone, and the first contributor's key survives, matching
2671
+ * the editor's own span-merge normalization.
2672
+ */
2673
+ function adoptMergedSpans(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder) {
2674
+ for (let targetIndex = 0; targetIndex < targetChildren.length; targetIndex++) {
2675
+ if (matchedTarget.has(targetIndex)) continue;
2676
+ let targetSpan = targetChildren[targetIndex], targetText = targetSpan.text;
2677
+ if (typeof targetText == "string") for (let start = 0; start < originalChildren.length; start++) {
2678
+ if (matchedOriginal.has(start)) continue;
2679
+ let concatenated = "", used = [];
2680
+ for (let index = start; index < originalChildren.length && !matchedOriginal.has(index); index++) {
2681
+ let originalSpan = originalChildren[index];
2682
+ if (typeof originalSpan.text != "string" || (concatenated += originalSpan.text, used.push(index), concatenated.length > targetText.length)) break;
2683
+ if (concatenated === targetText && used.length > 1) {
2684
+ matchedTarget.add(targetIndex);
2685
+ for (let usedIndex of used) matchedOriginal.add(usedIndex);
2686
+ adoptNode(originalChildren[used[0]], canonicalChildren?.[used[0]], targetSpan, adoptedNodes, "content-merged", recorder);
2687
+ break;
2688
+ }
2689
+ }
2690
+ if (matchedTarget.has(targetIndex)) break;
2691
+ }
2692
+ }
2693
+ }
2694
+ /**
2695
+ * The equal-count residual rule, mirroring `resolveGap`'s positional
2696
+ * zip: elements left unmatched after neutral-form (and, for
2697
+ * `children`, span-merge) matching are treated as in-place edits when
2698
+ * both sides leave the same count, position being the same evidence
2699
+ * the block-level zip already trusts, and the trade is the same too:
2700
+ * a reorder-plus-edit with balanced counts mispairs. Unequal counts
2701
+ * adopt nothing, since position no longer lines up.
2702
+ */
2703
+ function adoptResidualZip(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder) {
2704
+ let originalRest = originalChildren.map((node, index) => ({
2705
+ node,
2706
+ index
2707
+ })).filter(({ index }) => !matchedOriginal.has(index)), targetRest = targetChildren.filter((_, index) => !matchedTarget.has(index));
2708
+ if (originalRest.length === targetRest.length) for (let offset = 0; offset < originalRest.length; offset++) {
2709
+ let originalNode = originalRest[offset].node, targetNode = targetRest[offset];
2710
+ originalNode._type === targetNode._type && adoptNode(originalNode, canonicalChildren?.[originalRest[offset].index], targetNode, adoptedNodes, "same-position", recorder);
2711
+ }
2712
+ }
2713
+ /**
2714
+ * Matches `markDefs` by definition content, `_key` excluded. Returns
2715
+ * the mapping from the target's fresh keys to the adopted stored
2716
+ * keys, for rewriting `span.marks` references.
2717
+ */
2718
+ function adoptMarkDefs(original, canonicalCounterpart, target, recorder) {
2719
+ let keyMap = /* @__PURE__ */ new Map(), originalDefs = original.markDefs, targetDefs = target.markDefs;
2720
+ if (!isTypedObjectArray(originalDefs) || !isTypedObjectArray(targetDefs)) return keyMap;
2721
+ let canonicalDefs = canonicalChildArray(canonicalCounterpart, "markDefs", originalDefs), matchedOriginal = /* @__PURE__ */ new Set(), matchedTarget = /* @__PURE__ */ new Set(), originalGroups = groupByNeutralForm(originalDefs.map((def, index) => canonicalDefs?.[index] ?? def), matchedOriginal), targetGroups = groupByNeutralForm(targetDefs, matchedTarget), adoptDef = (originalDef, canonicalDef, targetDef, basis) => {
2722
+ if (restoreFields(originalDef, canonicalDef, targetDef), typeof originalDef._key == "string" && typeof targetDef._key == "string") {
2723
+ let adoptedKey = originalDef._key;
2724
+ if (targetDefs.some((def) => def !== targetDef && def._key === adoptedKey)) {
2725
+ recorder?.annotationKeyConflicts.push(targetDef);
2726
+ return;
2727
+ }
2728
+ keyMap.set(targetDef._key, adoptedKey), targetDef._key = adoptedKey, recorder?.preservationBasis.set(targetDef, basis);
2729
+ }
2730
+ };
2731
+ for (let [neutral, originalIndexes] of originalGroups) {
2732
+ let targetIndexes = targetGroups.get(neutral);
2733
+ if (!(!targetIndexes || targetIndexes.length !== originalIndexes.length)) for (let offset = 0; offset < originalIndexes.length; offset++) matchedOriginal.add(originalIndexes[offset]), matchedTarget.add(targetIndexes[offset]), adoptDef(originalDefs[originalIndexes[offset]], canonicalDefs?.[originalIndexes[offset]], targetDefs[targetIndexes[offset]], originalIndexes.length === 1 ? "content-unchanged" : "same-position");
2734
+ }
2735
+ let originalRest = originalDefs.map((node, index) => ({
2736
+ node,
2737
+ index
2738
+ })).filter(({ index }) => !matchedOriginal.has(index)), targetRest = targetDefs.filter((_, index) => !matchedTarget.has(index));
2739
+ return originalRest.length === 1 && targetRest.length === 1 && originalRest[0].node._type === targetRest[0]._type && adoptDef(originalRest[0].node, canonicalDefs?.[originalRest[0].index], targetRest[0], "same-position"), keyMap;
2740
+ }
2741
+ function rewriteMarkReferences(block, keyMap) {
2742
+ if (keyMap.size === 0) return;
2743
+ let children = block.children;
2744
+ if (isTypedObjectArray(children)) for (let child of children) {
2745
+ let marks = child.marks;
2746
+ Array.isArray(marks) && (child.marks = marks.map((mark) => typeof mark == "string" && keyMap.has(mark) ? keyMap.get(mark) : mark));
2747
+ }
2748
+ }
2749
+ function groupByNeutralForm(nodes, exclude, aliasByKey) {
2750
+ let groups = /* @__PURE__ */ new Map();
2751
+ for (let index = 0; index < nodes.length; index++) {
2752
+ if (exclude.has(index)) continue;
2753
+ let neutral = aliasByKey ? encodeNeutral(nodes[index], aliasByKey) : neutralForm(nodes[index]), group = groups.get(neutral);
2754
+ group ? group.push(index) : groups.set(neutral, [index]);
2755
+ }
2756
+ return groups;
2757
+ }
2758
+ /**
2759
+ * A canonical JSON encoding that erases identity: `_key` properties
2760
+ * are dropped, object properties are sorted, and annotation `_key`
2761
+ * references inside `span.marks` are rewritten to the definition's
2762
+ * position in `markDefs` (dropping `_key` alone would compare the
2763
+ * stored annotation key against the fresh one and reject an unchanged
2764
+ * link).
2765
+ */
2766
+ function neutralForm(node) {
2767
+ return encodeNeutral(node, buildAliasMap(node));
2768
+ }
2769
+ /**
2770
+ * Aliases each `markDefs` key to a key-independent spelling of the
2771
+ * definition itself, so `span.marks` references compare by what the
2772
+ * annotation is rather than which key it carries. The spelling is the
2773
+ * definition's own neutral form, not its array position: aliasing by
2774
+ * position made every annotated sibling span's neutral form shift when
2775
+ * a definition was inserted or removed before its own, so adding one
2776
+ * link re-keyed unrelated annotated spans. Identical definitions (the
2777
+ * same link twice) are disambiguated by occurrence order among
2778
+ * identical forms only, which no unrelated insertion can shift.
2779
+ */
2780
+ function buildAliasMap(node) {
2781
+ let markDefs = node.markDefs;
2782
+ if (!isTypedObjectArray(markDefs)) return;
2783
+ let aliasByKey = /* @__PURE__ */ new Map(), occurrenceByForm = /* @__PURE__ */ new Map();
2784
+ for (let definition of markDefs) {
2785
+ let key = definition._key;
2786
+ if (typeof key != "string") continue;
2787
+ let form = encodeNeutral(definition, void 0), occurrence = occurrenceByForm.get(form) ?? 0;
2788
+ occurrenceByForm.set(form, occurrence + 1), aliasByKey.set(key, `@annotation:${occurrence}:${form}`);
2789
+ }
2790
+ return aliasByKey;
2791
+ }
2792
+ function encodeNeutral(value, aliasByKey) {
2793
+ return Array.isArray(value) ? `[${value.map((item) => encodeNeutral(item, aliasByKey)).join(",")}]` : typeof value == "object" && value ? `{${Object.entries(value).filter(([field]) => field !== "_key").sort(([a], [b]) => a < b ? -1 : +(a > b)).map(([field, fieldValue]) => {
2794
+ if (field === "marks" && aliasByKey && Array.isArray(fieldValue)) {
2795
+ let aliased = fieldValue.map((mark) => typeof mark == "string" && aliasByKey.has(mark) ? aliasByKey.get(mark) : mark);
2796
+ return `${JSON.stringify(field)}:${JSON.stringify(aliased)}`;
2797
+ }
2798
+ return `${JSON.stringify(field)}:${encodeNeutral(fieldValue, aliasByKey)}`;
2799
+ }).join(",")}}` : JSON.stringify(value) ?? "undefined";
2800
+ }
2801
+ /**
2802
+ * The block's comparison text: concatenated span text with inline
2803
+ * objects as sentinels. Marks are ignored, since a formatting-only
2804
+ * edit does not change textual identity.
2805
+ */
2806
+ function blockText(block) {
2807
+ let children = block.children;
2808
+ return isTypedObjectArray(children) ? children.map((child) => typeof child.text == "string" ? child.text : "").join("") : "";
2809
+ }
2810
+ /**
2811
+ * Text similarity in `[0, 1]`, gated to `0` unless the block shells
2812
+ * agree and the inline objects are compatible: however alike the
2813
+ * prose, blocks that disagree on structure are not the same block.
2814
+ * The length prescreen skips the diff when the size difference alone
2815
+ * puts the score under `MIN_BLOCK_SIMILARITY`.
2816
+ */
2817
+ function blockSimilarity(canonicalBlock, resultBlock) {
2818
+ if (!shellEquals(canonicalBlock, resultBlock) || !inlineObjectsCompatible(canonicalBlock, resultBlock)) return 0;
2819
+ let canonicalText = blockText(canonicalBlock), resultText = blockText(resultBlock);
2820
+ if (canonicalText.length === 0 || resultText.length === 0) return 0;
2821
+ let longer = Math.max(canonicalText.length, resultText.length);
2822
+ return 1 - Math.abs(canonicalText.length - resultText.length) / longer < MIN_BLOCK_SIMILARITY ? 0 : 1 - levenshteinFromDiffs(cleanupEfficiency(makeDiff(canonicalText, resultText, { timeout: .05 }))) / longer;
2823
+ }
2824
+ /**
2825
+ * The distance derivation from diff runs: insertions and deletions
2826
+ * between equality runs accumulate as the larger of the two, so a
2827
+ * delete-plus-insert counts as one substitution.
2828
+ */
2829
+ function levenshteinFromDiffs(diffs) {
2830
+ let distance = 0, insertions = 0, deletions = 0;
2831
+ for (let [operation, text] of diffs) operation === 1 ? insertions += text.length : operation === -1 ? deletions += text.length : (distance += Math.max(insertions, deletions), insertions = 0, deletions = 0);
2832
+ return distance + Math.max(insertions, deletions);
2833
+ }
2834
+ /**
2835
+ * The block minus its content: `_type`, `style`, `listItem`, `level`,
2836
+ * and any custom fields must agree before text similarity means
2837
+ * anything.
2838
+ */
2839
+ function shellEquals(a, b) {
2840
+ let shellOf = (node) => encodeNeutral(Object.fromEntries(Object.entries(node).filter(([field]) => field !== "children" && field !== "markDefs")), void 0);
2841
+ return shellOf(a) === shellOf(b);
2842
+ }
2843
+ /**
2844
+ * Inline objects are opaque content: two blocks whose objects differ
2845
+ * are different blocks no matter how similar their prose is.
2846
+ */
2847
+ function inlineObjectsCompatible(a, b) {
2848
+ let objectsOf = (node) => {
2849
+ let children = node.children;
2850
+ return isTypedObjectArray(children) ? children.filter((child) => typeof child.text != "string") : [];
2851
+ }, aObjects = objectsOf(a), bObjects = objectsOf(b);
2852
+ return aObjects.length === bObjects.length && aObjects.every((aObject, index) => {
2853
+ let bObject = bObjects[index];
2854
+ return aObject._type === bObject._type ? typeof aObject._key == "string" && aObject._key === bObject._key || neutralForm(aObject) === neutralForm(bObject) : !1;
2855
+ });
2856
+ }
2857
+ /**
2858
+ * The highest-scoring candidate, or `undefined` on a tie: a tie is
2859
+ * ambiguity, and ambiguity refuses adoption rather than guessing.
2860
+ */
2861
+ function uniqueBest(candidates, scoreOf) {
2862
+ let best, bestScore = 0, tied = !1;
2863
+ for (let candidate of candidates) {
2864
+ let score = scoreOf(candidate);
2865
+ score !== void 0 && (score > bestScore ? (best = candidate, bestScore = score, tied = !1) : score === bestScore && best !== void 0 && (tied = !0));
2866
+ }
2867
+ return tied ? void 0 : best;
2868
+ }
2869
+ /**
2870
+ * Deliberately loose: any node with a `children` array reconciles like
2871
+ * a text block, custom block types included.
2872
+ */
2873
+ function isTextBlock$1(node) {
2874
+ return Array.isArray(node.children);
2875
+ }
2876
+ /**
2877
+ * A text block the round trip drops. Whether a whitespace-only block
2878
+ * survives serialize→parse depends on the converters, not on
2879
+ * structure: a heading renders `## ` and survives, a custom style
2880
+ * falls back to a plain paragraph and vanishes, and a non-breaking
2881
+ * space renders "blank-looking" output the parser still keeps (JS
2882
+ * `trim` folds NBSP, CommonMark does not), so any string check here is
2883
+ * a hand-written mirror of one converter or the other that drifts. The
2884
+ * block's own render-then-reparse is the authority, the same round
2885
+ * trip `canonicalizeStored` performs, so this predicate cannot
2886
+ * disagree with the canonical node count. The JS-trim pre-check only
2887
+ * keeps the per-block round trip off paths that cannot qualify: it
2888
+ * over-admits candidates (NBSP text passes it), and the reparse then
2889
+ * decides.
2890
+ */
2891
+ function isEmptyTextBlock(node, options) {
2892
+ if (!(isTextBlock$1(node) && blockText(node).trim() === "")) return !1;
2893
+ let rendered = portableTextToMarkdown([structuredClone(node)], {
2894
+ ...options?.serialize,
2895
+ schema: options?.schema
2896
+ });
2897
+ if (rendered === "") return !0;
2898
+ let { onDegradation: _onDegradation, ...deserializeOptions } = options?.deserialize ?? {}, probeKeyCounter = 0;
2899
+ return markdownToPortableText(rendered, {
2900
+ ...deserializeOptions,
2901
+ schema: options?.schema,
2902
+ keyGenerator: () => `empty-probe-${probeKeyCounter++}`
2903
+ }).length === 0;
2904
+ }
2905
+ /**
2906
+ * Blank lines are markdown's block separator, so an empty text block
2907
+ * has no serialized form: `canonicalizeStored`'s round trip drops it,
2908
+ * the same way `markdownToPortableText` would if it were parsed back
2909
+ * from `editedMarkdown`. Tracing origins and aligning blocks over this
2910
+ * subsequence keeps both sides the same length; `reinsertEmptyRuns`
2911
+ * restores the dropped blocks afterward.
2912
+ */
2913
+ function nonEmptyBlocks(stored, options) {
2914
+ return stored.filter((node) => !isEmptyTextBlock(node, options));
2915
+ }
2916
+ function isTypedObjectArray(value) {
2917
+ return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item == "object" && !!item && typeof item._type == "string");
2918
+ }
2919
+ /**
2920
+ * Maximal runs of empty text blocks, each paired with the surviving
2921
+ * neighbor its restoration hangs off: a run with a preceding block
2922
+ * anchors to it (insert after); a run at the document's start, with
2923
+ * none, anchors to the block that follows it (insert before). A run
2924
+ * with neither (the whole document is empty blocks) has nothing to
2925
+ * anchor to and is dropped.
2926
+ */
2927
+ function findEmptyRuns(stored, options) {
2928
+ let runs = [], index = 0;
2929
+ for (; index < stored.length;) {
2930
+ if (!isEmptyTextBlock(stored[index], options)) {
2931
+ index++;
2932
+ continue;
2933
+ }
2934
+ let runStart = index;
2935
+ for (; index < stored.length && isEmptyTextBlock(stored[index], options);) index++;
2936
+ let precedingBlock = runStart > 0 ? stored[runStart - 1] : void 0, followingBlock = index < stored.length ? stored[index] : void 0, anchor = precedingBlock ?? followingBlock;
2937
+ anchor && typeof anchor._key == "string" && runs.push({
2938
+ anchorKey: anchor._key,
2939
+ insertAfter: precedingBlock !== void 0,
2940
+ blocks: stored.slice(runStart, index)
2941
+ });
2942
+ }
2943
+ return runs;
2944
+ }
2945
+ /**
2946
+ * Restores each empty run next to the result node that adopted its
2947
+ * anchor's key, found by `_key` since positions have already shifted
2948
+ * under insertion, deletion, and move. An anchor whose key did not
2949
+ * survive into `result` (its region was rewritten or deleted) drops
2950
+ * the run with it, consistent with rewrite semantics elsewhere in this
2951
+ * module. Runs are cloned and marked adopted, the same authoritative
2952
+ * status as every other restored key, so a collision resolves in
2953
+ * their favor like `enforceSiblingKeyUniqueness` already does for
2954
+ * `json:object` duplicates.
2955
+ */
2956
+ function reinsertEmptyRuns(stored, result, adoptedNodes, options, recorder) {
2957
+ for (let run of findEmptyRuns(stored, options)) {
2958
+ let adoptedAnchorIndex = result.findIndex((node) => node._key === run.anchorKey && adoptedNodes.has(node)), anchorIndex = adoptedAnchorIndex === -1 ? result.findIndex((node) => node._key === run.anchorKey) : adoptedAnchorIndex;
2959
+ if (anchorIndex === -1) continue;
2960
+ let clones = run.blocks.map((block) => structuredClone(block));
2961
+ for (let clone of clones) adoptedNodes.add(clone), recorder && tagSubtreePreserved(clone, recorder);
2962
+ result.splice(run.insertAfter ? anchorIndex + 1 : anchorIndex, 0, ...clones);
2963
+ }
2964
+ }
2965
+ /**
2966
+ * Every keyed node in a reinserted empty run is the stored value
2967
+ * verbatim, at every depth, so key resolution reporting tags the
2968
+ * whole subtree `content-unchanged` rather than only the run's top
2969
+ * block.
2970
+ */
2971
+ function tagSubtreePreserved(node, recorder) {
2972
+ typeof node._key == "string" && recorder.preservationBasis.set(node, "content-unchanged");
2973
+ for (let value of Object.values(node)) if (Array.isArray(value) && value.every((item) => typeof item == "object" && !!item) && value.length > 0) for (let child of value) tagSubtreePreserved(child, recorder);
2974
+ else typeof value == "object" && value && tagSubtreePreserved(value, recorder);
2975
+ }
2976
+ /**
2977
+ * `json:object` payloads transport their `_key` verbatim, so a
2978
+ * copy-pasted fence puts the same key on two siblings, and everything
2979
+ * downstream (patches, anchors, editor normalization) assumes sibling
2980
+ * keys are unique. Adopted keys are authoritative, so a duplicate that
2981
+ * was adopted from the stored value wins and the other occurrences are
2982
+ * regenerated.
2983
+ */
2984
+ function enforceSiblingKeyUniqueness(nodes, keyGenerator, adoptedNodes, recorder, onKeyRewritten) {
2985
+ let usedKeys = /* @__PURE__ */ new Set(), claim = (node) => {
2986
+ let key = node._key;
2987
+ if (typeof key == "string") {
2988
+ if (usedKeys.has(key)) {
2989
+ let freshKey;
2990
+ for (let attempt = 0; attempt < 3; attempt++) {
2991
+ let candidate = keyGenerator();
2992
+ if (!usedKeys.has(candidate)) {
2993
+ freshKey = candidate;
2994
+ break;
2995
+ }
2996
+ }
2997
+ if (freshKey === void 0) {
2998
+ let suffix = 1, candidate = `${key}_${suffix}`;
2999
+ for (; usedKeys.has(candidate);) suffix++, candidate = `${key}_${suffix}`;
3000
+ freshKey = candidate;
3001
+ }
3002
+ node._key = freshKey, usedKeys.add(freshKey), onKeyRewritten?.(key, freshKey), recorder?.renamePreviousKey.set(node, key);
3003
+ return;
3004
+ }
3005
+ usedKeys.add(key);
3006
+ }
3007
+ }, adopted = nodes.filter((node) => adoptedNodes.has(node)), rest = nodes.filter((node) => !adoptedNodes.has(node));
3008
+ for (let node of [...adopted, ...rest]) claim(node);
3009
+ for (let node of nodes) enforceNestedSiblingKeyUniqueness(node, keyGenerator, adoptedNodes, recorder);
3010
+ }
3011
+ function enforceNestedSiblingKeyUniqueness(node, keyGenerator, adoptedNodes, recorder) {
3012
+ for (let [field, value] of Object.entries(node)) Array.isArray(value) && value.every((item) => typeof item == "object" && !!item) && value.length > 0 ? enforceSiblingKeyUniqueness(value, keyGenerator, adoptedNodes, recorder, field === "markDefs" ? buildMarkDefKeyRewriter(node) : void 0) : typeof value == "object" && value && enforceNestedSiblingKeyUniqueness(value, keyGenerator, adoptedNodes, recorder);
3013
+ }
3014
+ /**
3015
+ * A colliding `keyGenerator` can mint the identical string for two
3016
+ * `markDefs` entries, which means the block's spans already reference
3017
+ * that shared string ambiguously before any rewrite happens: a
3018
+ * blanket find-and-replace of the old key would move every span's
3019
+ * reference, including the one that was never renamed. The
3020
+ * `markDefs` array and each span's `marks` are walked in the same
3021
+ * fixed document order, so the Nth occurrence of a given key in one
3022
+ * lines up with the Nth occurrence in the other; the first occurrence
3023
+ * is always the survivor (`enforceSiblingKeyUniqueness` only renames
3024
+ * on collision, never the first sighting of a key), so each rename
3025
+ * event retargets the next occurrence in that shared order instead of
3026
+ * every occurrence.
3027
+ */
3028
+ function buildMarkDefKeyRewriter(block) {
3029
+ let children = block.children;
3030
+ if (!isTypedObjectArray(children)) return () => {};
3031
+ let occurrencesByKey = /* @__PURE__ */ new Map();
3032
+ for (let child of children) {
3033
+ let marks = child.marks;
3034
+ if (Array.isArray(marks)) for (let markIndex = 0; markIndex < marks.length; markIndex++) {
3035
+ let mark = marks[markIndex];
3036
+ if (typeof mark != "string") continue;
3037
+ let occurrences = occurrencesByKey.get(mark) ?? [];
3038
+ occurrences.push({
3039
+ child,
3040
+ markIndex
3041
+ }), occurrencesByKey.set(mark, occurrences);
3042
+ }
3043
+ }
3044
+ let consumedByKey = /* @__PURE__ */ new Map();
3045
+ return (oldKey, newKey) => {
3046
+ let occurrences = occurrencesByKey.get(oldKey), index = consumedByKey.get(oldKey) ?? 1;
3047
+ consumedByKey.set(oldKey, index + 1);
3048
+ let target = occurrences?.[index];
3049
+ if (!target) return;
3050
+ let marks = target.child.marks;
3051
+ Array.isArray(marks) && (marks[target.markIndex] = newKey);
3052
+ };
3053
+ }
3054
+ /**
3055
+ * Materializes the public report from the recorder's node-identity
3056
+ * decisions, walking the settled result tree once so every `key` and
3057
+ * `path` matches the returned value exactly: key resolution records
3058
+ * decisions before the sibling-uniqueness pass can still rewrite a
3059
+ * key, so keys and paths are only trustworthy read back from the
3060
+ * final tree, not from the moment a decision was made.
3061
+ */
3062
+ function buildReconciliationReport(result, recorder) {
3063
+ let preservedKeys = [], renamedKeys = [], pathByNode = /* @__PURE__ */ new WeakMap(), walk = (node, path) => {
3064
+ pathByNode.set(node, path);
3065
+ let key = node._key;
3066
+ if (typeof key == "string") {
3067
+ let basis = recorder.preservationBasis.get(node), previousKey = recorder.renamePreviousKey.get(node);
3068
+ basis && previousKey === void 0 && preservedKeys.push({
3069
+ basis,
3070
+ key,
3071
+ path
3072
+ }), previousKey !== void 0 && renamedKeys.push({
3073
+ previousKey,
3074
+ key,
3075
+ path
3076
+ });
3077
+ }
3078
+ for (let [field, value] of Object.entries(node)) Array.isArray(value) && value.every((item) => typeof item == "object" && !!item) && value.length > 0 ? value.forEach((child, index) => {
3079
+ let childKey = child._key;
3080
+ walk(child, [
3081
+ ...path,
3082
+ field,
3083
+ typeof childKey == "string" ? { _key: childKey } : index
3084
+ ]);
3085
+ }) : typeof value == "object" && value && walk(value, [...path, field]);
3086
+ };
3087
+ if (result.forEach((block, index) => {
3088
+ let key = block._key;
3089
+ walk(block, [typeof key == "string" ? { _key: key } : index]);
3090
+ }), recorder.skipReason) return {
3091
+ keyMatching: "skipped",
3092
+ reason: recorder.skipReason,
3093
+ renamedKeys
3094
+ };
3095
+ let keyFallbacks = [];
3096
+ for (let group of recorder.ambiguousRegionGroups) keyFallbacks.push({
3097
+ type: "ambiguous-region-too-large",
3098
+ keys: group.map((node) => node._key).filter((key) => typeof key == "string")
3099
+ });
3100
+ for (let node of recorder.annotationKeyConflicts) keyFallbacks.push({
3101
+ type: "annotation-key-conflict",
3102
+ path: pathByNode.get(node)
3103
+ });
3104
+ return {
3105
+ keyMatching: "performed",
3106
+ preservedKeys,
3107
+ keyFallbacks,
3108
+ renamedKeys
3109
+ };
1400
3110
  }
1401
- export { DefaultBlockSpacingRenderer, DefaultBlockquoteObjectRenderer, DefaultBlockquoteRenderer, DefaultCalloutRenderer, DefaultCodeBlockRenderer, DefaultCodeRenderer, DefaultEmRenderer, DefaultH1Renderer, DefaultH2Renderer, DefaultH3Renderer, DefaultH4Renderer, DefaultH5Renderer, DefaultH6Renderer, DefaultHardBreakRenderer, DefaultHorizontalRuleRenderer, DefaultHtmlRenderer, DefaultImageRenderer, DefaultLinkRenderer, DefaultListItemRenderer, DefaultListRenderer, DefaultNormalRenderer, DefaultStrikeThroughRenderer, DefaultStrongRenderer, DefaultTableRenderer, DefaultUnderlineRenderer, markdownToPortableText, portableTextToMarkdown };
3111
+ export { DefaultBlockSpacingRenderer, DefaultBlockquoteObjectRenderer, DefaultBlockquoteRenderer, DefaultCalloutRenderer, DefaultCodeBlockRenderer, DefaultCodeRenderer, DefaultEmRenderer, DefaultH1Renderer, DefaultH2Renderer, DefaultH3Renderer, DefaultH4Renderer, DefaultH5Renderer, DefaultH6Renderer, DefaultHardBreakRenderer, DefaultHorizontalRuleRenderer, DefaultHtmlRenderer, DefaultImageRenderer, DefaultLinkRenderer, DefaultListItemRenderer, DefaultListRenderer, DefaultNormalRenderer, DefaultStrikeThroughRenderer, DefaultStrongRenderer, DefaultTableRenderer, DefaultUnderlineRenderer, applyMarkdownEdit, markdownToPortableText, portableTextToMarkdown };
1402
3112
 
1403
3113
  //# sourceMappingURL=index.js.map