@portabletext/markdown 2.1.0 → 2.2.1
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/README.md +145 -3
- package/dist/index.d.ts +415 -186
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +936 -43
- package/dist/index.js.map +1 -1
- package/package.json +5 -6
package/dist/index.js
CHANGED
|
@@ -1,11 +1,36 @@
|
|
|
1
|
+
import { cleanupEfficiency, makeDiff } from "@sanity/diff-match-patch";
|
|
1
2
|
import { compileSchema, defineSchema, isSpan, isTextBlock, isTypedObject } from "@portabletext/schema";
|
|
2
3
|
import { buildMarksTree, isPortableTextBlock, isPortableTextListItemBlock, isPortableTextSpan, isPortableTextToolkitSpan, isPortableTextToolkitTextNode, spanToPlainText } from "@portabletext/toolkit";
|
|
3
|
-
import LinkifyIt from "linkify-it";
|
|
4
|
-
import { alert } from "@mdit/plugin-alert";
|
|
4
|
+
import { LinkifyIt } from "linkify-it";
|
|
5
5
|
import markdownit from "markdown-it";
|
|
6
|
+
import { alert } from "@mdit/plugin-alert";
|
|
6
7
|
function defaultKeyGenerator() {
|
|
7
8
|
return randomKey(12);
|
|
8
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
|
+
}
|
|
9
34
|
const getByteHexTable = (() => {
|
|
10
35
|
let table;
|
|
11
36
|
return () => {
|
|
@@ -96,7 +121,10 @@ function buildListIndexMap(blocks) {
|
|
|
96
121
|
* The CommonMark ASCII punctuation set. Only these characters can be
|
|
97
122
|
* backslash-escaped into a literal without changing the parsed text.
|
|
98
123
|
*/
|
|
99
|
-
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(
|
|
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
|
+
fuzzyLink: !0,
|
|
126
|
+
urlAuth: !0
|
|
127
|
+
});
|
|
100
128
|
/**
|
|
101
129
|
* Plans the escaped replacement for every plain-text leaf a block's children
|
|
102
130
|
* will produce, in the exact left-to-right order `renderText` visits them
|
|
@@ -670,10 +698,10 @@ function isHtmlShaped(value) {
|
|
|
670
698
|
* @public
|
|
671
699
|
*/
|
|
672
700
|
const DefaultImageRenderer = (options) => {
|
|
673
|
-
if (!isImageShaped(options.value)) return DefaultUnknownTypeRenderer(options);
|
|
701
|
+
if (!isImageShaped(options.value) || !linkValidator(options.value.src)) return DefaultUnknownTypeRenderer(options);
|
|
674
702
|
let alt = escapeImageAndLinkText(options.value.alt ?? ""), title = options.value.title ? ` "${escapeImageAndLinkTitle(options.value.title)}"` : "";
|
|
675
703
|
return ``;
|
|
676
|
-
};
|
|
704
|
+
}, md = new markdownit(), linkValidator = (url) => md.validateLink(url);
|
|
677
705
|
function isImageShaped(value) {
|
|
678
706
|
let image = value;
|
|
679
707
|
return typeof image?.src == "string" && (image.alt == null || typeof image.alt == "string") && (image.title == null || typeof image.title == "string");
|
|
@@ -715,12 +743,23 @@ const DefaultTableRenderer = (options) => {
|
|
|
715
743
|
function renderTable(value, renderNode) {
|
|
716
744
|
let rows = value.rows, alignment = Array.isArray(value.alignment) ? value.alignment : void 0, headerRow = rows.at(0);
|
|
717
745
|
if (!headerRow) return "";
|
|
718
|
-
let getCellText = (cellBlocks) => cellBlocks.map((block, index) =>
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
746
|
+
let getCellText = (cellBlocks) => cellBlocks.map((block, index) => {
|
|
747
|
+
let rendered = renderNode({
|
|
748
|
+
node: block,
|
|
749
|
+
index,
|
|
750
|
+
isInline: !1,
|
|
751
|
+
renderNode
|
|
752
|
+
}), rendererOptions = {
|
|
753
|
+
value: block,
|
|
754
|
+
isInline: !1,
|
|
755
|
+
index,
|
|
756
|
+
renderNode
|
|
757
|
+
};
|
|
758
|
+
return rendered === DefaultUnknownTypeRenderer(rendererOptions) ? DefaultUnknownTypeRenderer({
|
|
759
|
+
...rendererOptions,
|
|
760
|
+
isInline: !0
|
|
761
|
+
}) : rendered;
|
|
762
|
+
}).join(" ").trim(), lines = [], columnCount = rows.reduce((max, row) => Math.max(max, row.cells.length), 0), renderCells = (texts) => {
|
|
724
763
|
let padded = [...texts];
|
|
725
764
|
for (; padded.length < columnCount;) padded.push("");
|
|
726
765
|
return `| ${padded.join(" | ")} |`;
|
|
@@ -753,7 +792,7 @@ const DefaultCalloutRenderer = (options) => {
|
|
|
753
792
|
index,
|
|
754
793
|
isInline: !1,
|
|
755
794
|
renderNode
|
|
756
|
-
})).join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
|
|
795
|
+
})).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n");
|
|
757
796
|
return `> [!${options.value.tone.toUpperCase()}]\n${prefixed}`;
|
|
758
797
|
};
|
|
759
798
|
function isCalloutShaped(value) {
|
|
@@ -780,20 +819,29 @@ const DefaultBlockquoteObjectRenderer = ({ value, renderNode }) => value.content
|
|
|
780
819
|
index,
|
|
781
820
|
isInline: !1,
|
|
782
821
|
renderNode
|
|
783
|
-
})).join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n"), DefaultListRenderer = ({ value, renderNode }) => {
|
|
784
|
-
let
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
isNestedList
|
|
788
|
-
|
|
822
|
+
})).filter((rendered) => rendered !== "").join("\n\n").split("\n").map((line) => line === "" ? ">" : `> ${line}`).join("\n"), DefaultListRenderer = ({ value, renderNode }) => {
|
|
823
|
+
let renderedItems = value.items.map((item) => {
|
|
824
|
+
let markerLineSettled = !1;
|
|
825
|
+
return item.content.map((block, blockIndex) => {
|
|
826
|
+
let isNestedList = block._type === "list", isTextBlock = !isNestedList && isPortableTextBlock(block);
|
|
827
|
+
!markerLineSettled && isTextBlock && markListItemFirstBlock(block);
|
|
828
|
+
let text = renderNode({
|
|
789
829
|
node: block,
|
|
790
830
|
index: blockIndex,
|
|
791
831
|
isInline: !1,
|
|
792
832
|
renderNode
|
|
793
|
-
})
|
|
794
|
-
|
|
833
|
+
});
|
|
834
|
+
return text !== "" && (markerLineSettled = !0), {
|
|
835
|
+
isNestedList,
|
|
836
|
+
isTextBlock,
|
|
837
|
+
text
|
|
838
|
+
};
|
|
839
|
+
});
|
|
840
|
+
}), itemSeparator = renderedItems.some((renderedBlocks) => renderedBlocks.filter((rendered) => !rendered.isNestedList && rendered.text !== "").length > 1) ? "\n\n" : "\n";
|
|
841
|
+
return value.items.map((item, itemIndex) => {
|
|
842
|
+
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();
|
|
795
843
|
return rest.length === 0 ? head : `${head}${rest.map((rendered) => {
|
|
796
|
-
let indented = rendered.text
|
|
844
|
+
let indented = indentLines(rendered.text);
|
|
797
845
|
return rendered.isNestedList ? `\n${indented}` : `\n\n${indented}`;
|
|
798
846
|
}).join("")}`;
|
|
799
847
|
}).join(itemSeparator);
|
|
@@ -853,7 +901,7 @@ function portableTextToMarkdown(blocks, options = {}) {
|
|
|
853
901
|
...options.marks
|
|
854
902
|
},
|
|
855
903
|
types: {
|
|
856
|
-
...defaultRenderers.types,
|
|
904
|
+
...gateDefaultTypeRenderers(defaultRenderers.types, options.schema, options.unknownType ?? defaultRenderers.unknownType),
|
|
857
905
|
...options.types
|
|
858
906
|
},
|
|
859
907
|
hardBreak: options.hardBreak ?? defaultRenderers.hardBreak,
|
|
@@ -861,22 +909,26 @@ function portableTextToMarkdown(blocks, options = {}) {
|
|
|
861
909
|
unknownBlockStyle: options.unknownBlockStyle ?? defaultRenderers.unknownBlockStyle,
|
|
862
910
|
unknownListItem: options.unknownListItem ?? defaultRenderers.unknownListItem,
|
|
863
911
|
unknownMark: options.unknownMark ?? defaultRenderers.unknownMark
|
|
864
|
-
}, renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer, { listIndexMap, listDepthMap } = buildListIndexMap(blocks), renderNode = createRenderNode(renderers, listIndexMap, listDepthMap)
|
|
865
|
-
|
|
866
|
-
|
|
912
|
+
}, renderBlockSpacing = options.blockSpacing ?? DefaultBlockSpacingRenderer, { listIndexMap, listDepthMap } = buildListIndexMap(blocks), renderNode = createRenderNode(renderers, listIndexMap, listDepthMap), renderedBlocks = blocks.map((node, index) => ({
|
|
913
|
+
node,
|
|
914
|
+
rendered: renderNode({
|
|
867
915
|
node,
|
|
868
916
|
index,
|
|
869
917
|
isInline: !1,
|
|
870
918
|
renderNode
|
|
871
|
-
})
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
919
|
+
})
|
|
920
|
+
})).filter(({ rendered }) => rendered !== "");
|
|
921
|
+
return renderedBlocks.map(({ node, rendered }, index) => {
|
|
922
|
+
let nextBlock = renderedBlocks.at(index + 1);
|
|
923
|
+
return nextBlock ? `${rendered}${renderBlockSpacing({
|
|
875
924
|
current: node,
|
|
876
|
-
next:
|
|
877
|
-
}) ?? "\n\n"}` :
|
|
925
|
+
next: nextBlock.node
|
|
926
|
+
}) ?? "\n\n"}` : rendered;
|
|
878
927
|
}).join("");
|
|
879
928
|
}
|
|
929
|
+
function gateDefaultTypeRenderers(defaultTypeRenderers, schema, resolvedUnknownType) {
|
|
930
|
+
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;
|
|
931
|
+
}
|
|
880
932
|
/********************
|
|
881
933
|
* Default style definitions
|
|
882
934
|
********************/
|
|
@@ -1145,6 +1197,17 @@ const codeBlockMatcher = ({ context, value, isInline }) => {
|
|
|
1145
1197
|
}
|
|
1146
1198
|
};
|
|
1147
1199
|
/**
|
|
1200
|
+
* Reads a token attribute as a string. markdown-it's own `Token#attrs` type
|
|
1201
|
+
* also allows numeric values, for attributes like an ordered list's
|
|
1202
|
+
* `start`, but the attributes this file reads (`src`, `href`, `title`,
|
|
1203
|
+
* `style`) are always strings, set by markdown-it's own link/image/table
|
|
1204
|
+
* parsing.
|
|
1205
|
+
*/
|
|
1206
|
+
function stringAttr(token, name) {
|
|
1207
|
+
let value = token.attrGet(name);
|
|
1208
|
+
return typeof value == "string" ? value : void 0;
|
|
1209
|
+
}
|
|
1210
|
+
/**
|
|
1148
1211
|
* Reads GFM column alignment from a markdown-it cell token's `style`
|
|
1149
1212
|
* attribute. Tolerates other CSS declarations sharing the value.
|
|
1150
1213
|
*/
|
|
@@ -1254,7 +1317,7 @@ function buildDegradationMessage(degradations) {
|
|
|
1254
1317
|
function markdownToPortableText(markdown, options) {
|
|
1255
1318
|
let consolidatedOptions = {
|
|
1256
1319
|
schema: options?.schema ?? defaultSchema,
|
|
1257
|
-
keyGenerator: options?.keyGenerator ?? defaultKeyGenerator,
|
|
1320
|
+
keyGenerator: uniqueKeyGenerator(options?.keyGenerator ?? defaultKeyGenerator),
|
|
1258
1321
|
html: { inline: options?.html?.inline ?? "skip" },
|
|
1259
1322
|
marks: {
|
|
1260
1323
|
...defaultOptions.marks,
|
|
@@ -1307,11 +1370,16 @@ function markdownToPortableText(markdown, options) {
|
|
|
1307
1370
|
message: degradationMessage["fields-dropped"](names, dropped.construct),
|
|
1308
1371
|
line
|
|
1309
1372
|
});
|
|
1310
|
-
},
|
|
1373
|
+
}, md = markdownit({
|
|
1311
1374
|
html: !0,
|
|
1312
1375
|
linkify: !0,
|
|
1313
1376
|
typographer: !1
|
|
1314
|
-
}).enable(["strikethrough", "table"]).use(alert)
|
|
1377
|
+
}).enable(["strikethrough", "table"]).use(alert);
|
|
1378
|
+
md.linkify.set({
|
|
1379
|
+
fuzzyLink: !0,
|
|
1380
|
+
urlAuth: !0
|
|
1381
|
+
});
|
|
1382
|
+
let tokens = md.parse(markdown, {}), taskCheckedByListItemIndex = /* @__PURE__ */ new Map(), taskItemTextByListItemIndex = /* @__PURE__ */ new Map();
|
|
1315
1383
|
for (let i = 0; i < tokens.length; i++) {
|
|
1316
1384
|
if (tokens[i]?.type !== "list_item_open") continue;
|
|
1317
1385
|
let inlineIndex = -1;
|
|
@@ -1830,7 +1898,7 @@ function markdownToPortableText(markdown, options) {
|
|
|
1830
1898
|
break;
|
|
1831
1899
|
case "th_open":
|
|
1832
1900
|
case "td_open": {
|
|
1833
|
-
currentTable && inTableHead && token.type === "th_open" && currentTable.alignment.push(extractAlignmentFromStyleAttr(token
|
|
1901
|
+
currentTable && inTableHead && token.type === "th_open" && currentTable.alignment.push(extractAlignmentFromStyleAttr(stringAttr(token, "style") ?? null));
|
|
1834
1902
|
let style = consolidatedOptions.block.normal({ context: { schema: consolidatedOptions.schema } });
|
|
1835
1903
|
style ? startBlock(style) : (reportStyleFallback("normal", currentTable?.line), startBlock("normal"));
|
|
1836
1904
|
break;
|
|
@@ -1857,12 +1925,15 @@ function markdownToPortableText(markdown, options) {
|
|
|
1857
1925
|
});
|
|
1858
1926
|
let demotedInlineImages = [];
|
|
1859
1927
|
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);
|
|
1860
|
-
let firstBlock = cellBlocks[0],
|
|
1928
|
+
let firstBlock = cellBlocks[0], liftedObject;
|
|
1861
1929
|
if (cellBlocks.length === 1 && firstBlock && firstBlock._type === "block" && "children" in firstBlock && Array.isArray(firstBlock.children) && firstBlock.children.length === 1) {
|
|
1862
1930
|
let onlyChild = firstBlock.children[0];
|
|
1863
|
-
typeof onlyChild == "object" && onlyChild && "_type" in onlyChild && onlyChild._type !== consolidatedOptions.schema.span.name
|
|
1931
|
+
if (typeof onlyChild == "object" && onlyChild && "_type" in onlyChild && onlyChild._type !== consolidatedOptions.schema.span.name) {
|
|
1932
|
+
let declaredInline = consolidatedOptions.schema.inlineObjects.some((inlineObject) => inlineObject.name === onlyChild._type), declaredBlock = consolidatedOptions.schema.blockObjects.some((blockObject) => blockObject.name === onlyChild._type);
|
|
1933
|
+
declaredInline && !declaredBlock || (cellBlocks[0] = onlyChild, liftedObject = onlyChild);
|
|
1934
|
+
}
|
|
1864
1935
|
}
|
|
1865
|
-
for (let demotedImage of demotedInlineImages) if (demotedImage !==
|
|
1936
|
+
for (let demotedImage of demotedInlineImages) if (demotedImage !== liftedObject) {
|
|
1866
1937
|
let { alt, src } = demotedImage;
|
|
1867
1938
|
report({
|
|
1868
1939
|
type: "image-block-to-inline",
|
|
@@ -1883,7 +1954,7 @@ function markdownToPortableText(markdown, options) {
|
|
|
1883
1954
|
if (token.children?.length === 1 && token.children[0]?.type === "image") {
|
|
1884
1955
|
let imageToken = token.children[0];
|
|
1885
1956
|
if (!imageToken) break;
|
|
1886
|
-
let src = imageToken
|
|
1957
|
+
let src = stringAttr(imageToken, "src") || "", alt = unescapeImageAndLinkText(imageToken.content || ""), title = stringAttr(imageToken, "title"), blockImageObject = consolidatedOptions.types.image({
|
|
1887
1958
|
context: {
|
|
1888
1959
|
schema: consolidatedOptions.schema,
|
|
1889
1960
|
keyGenerator: consolidatedOptions.keyGenerator
|
|
@@ -2056,7 +2127,7 @@ function markdownToPortableText(markdown, options) {
|
|
|
2056
2127
|
break;
|
|
2057
2128
|
}
|
|
2058
2129
|
case "link_open": {
|
|
2059
|
-
let href = childToken
|
|
2130
|
+
let href = stringAttr(childToken, "href");
|
|
2060
2131
|
if (!href) {
|
|
2061
2132
|
let missingHrefSnippet = truncateSnippet(collectInlineText(inlineChildren, childIndex, "link_open", "link_close"));
|
|
2062
2133
|
report({
|
|
@@ -2067,7 +2138,7 @@ function markdownToPortableText(markdown, options) {
|
|
|
2067
2138
|
});
|
|
2068
2139
|
break;
|
|
2069
2140
|
}
|
|
2070
|
-
let title = childToken
|
|
2141
|
+
let title = stringAttr(childToken, "title"), linkObject = consolidatedOptions.marks.link({
|
|
2071
2142
|
context: {
|
|
2072
2143
|
schema: consolidatedOptions.schema,
|
|
2073
2144
|
keyGenerator: consolidatedOptions.keyGenerator
|
|
@@ -2103,7 +2174,7 @@ function markdownToPortableText(markdown, options) {
|
|
|
2103
2174
|
break;
|
|
2104
2175
|
}
|
|
2105
2176
|
case "image": {
|
|
2106
|
-
let src = childToken
|
|
2177
|
+
let src = stringAttr(childToken, "src") || "", alt = unescapeImageAndLinkText(childToken.content || ""), inlineImageObject = consolidatedOptions.types.image({
|
|
2107
2178
|
context: {
|
|
2108
2179
|
schema: consolidatedOptions.schema,
|
|
2109
2180
|
keyGenerator: consolidatedOptions.keyGenerator
|
|
@@ -2234,6 +2305,828 @@ function parseJsonObjectFence(code) {
|
|
|
2234
2305
|
let objectValue = parsed;
|
|
2235
2306
|
if (typeof objectValue._type == "string" && objectValue._type.length !== 0) return objectValue;
|
|
2236
2307
|
}
|
|
2237
|
-
|
|
2308
|
+
/**
|
|
2309
|
+
* Deliberately high pending calibration against real agent edit
|
|
2310
|
+
* traces: a wrong match moves anchors onto unrelated text, a fresh
|
|
2311
|
+
* key resets one block.
|
|
2312
|
+
*/
|
|
2313
|
+
const MIN_BLOCK_SIMILARITY = .8, MAX_SIMILARITY_PAIRS = 2500;
|
|
2314
|
+
/**
|
|
2315
|
+
* Converts edited markdown to Portable Text, restores stored keys, and
|
|
2316
|
+
* restores fields the markdown dialect cannot express (dropped by
|
|
2317
|
+
* serialization, so the edit could not have touched them); a field
|
|
2318
|
+
* markdown does express follows the edit. The same rule covers a
|
|
2319
|
+
* custom style or decorator markdown has no syntax for, coerced to a
|
|
2320
|
+
* built-in on the round trip. An empty or whitespace-only text block
|
|
2321
|
+
* is the same case taken to the whole block: markdown has no form for
|
|
2322
|
+
* either, so it is restored next to its surviving neighbor and
|
|
2323
|
+
* dropped along with that neighbor if the neighbor does not survive.
|
|
2324
|
+
* Keys aim for what the
|
|
2325
|
+
* same edit would have produced in an editor:
|
|
2326
|
+
* unchanged, moved, and rewritten-in-place content keeps its keys
|
|
2327
|
+
* (rewriting a paragraph in place keeps its identity, like typing over
|
|
2328
|
+
* it), a split keeps the key on its first non-empty fragment, a merge
|
|
2329
|
+
* keeps the first source block's key, and a `json:object` payload keeps
|
|
2330
|
+
* the key it carries, unless reconciliation matches it to stored
|
|
2331
|
+
* content, which takes the stored key even over a differing key in the
|
|
2332
|
+
* payload. When an insertion or deletion makes positions ambiguous,
|
|
2333
|
+
* only clear similarity evidence adopts a key and everything else gets
|
|
2334
|
+
* a new one; gathering that evidence is time-capped, so on very large
|
|
2335
|
+
* ambiguous edits the set of adopted keys can differ across machine
|
|
2336
|
+
* speeds, degrading toward fresh keys.
|
|
2337
|
+
* Output keys are unique among siblings. The function does not mutate
|
|
2338
|
+
* `storedPortableText` and returns a value, not patches. The `schema`
|
|
2339
|
+
* is taken once and governs both directions; pass the same `serialize`
|
|
2340
|
+
* options that produced the markdown that was edited. A throwing or
|
|
2341
|
+
* stateful custom matcher or renderer propagates or degrades matching
|
|
2342
|
+
* respectively.
|
|
2343
|
+
* Reconciliation never merges concurrent edits: compare the stored
|
|
2344
|
+
* field against the live document before writing the result back.
|
|
2345
|
+
* The trades in one line: same-position replacement inherits identity,
|
|
2346
|
+
* a count-preserving rewrite pairs positionally (block-level and
|
|
2347
|
+
* sibling-level alike), and evidence gathering is capped, degrading to
|
|
2348
|
+
* fresh keys.
|
|
2349
|
+
*
|
|
2350
|
+
* @public
|
|
2351
|
+
*/
|
|
2352
|
+
function applyMarkdownEdit(storedPortableText, editedMarkdown, options) {
|
|
2353
|
+
let onReconciliation = options?.onReconciliation, recorder = onReconciliation ? {
|
|
2354
|
+
preservationBasis: /* @__PURE__ */ new WeakMap(),
|
|
2355
|
+
renamePreviousKey: /* @__PURE__ */ new WeakMap(),
|
|
2356
|
+
annotationKeyConflicts: [],
|
|
2357
|
+
ambiguousRegionGroups: [],
|
|
2358
|
+
skipReason: void 0
|
|
2359
|
+
} : void 0, result = structuredClone(markdownToPortableText(editedMarkdown, {
|
|
2360
|
+
...options?.deserialize,
|
|
2361
|
+
schema: options?.schema
|
|
2362
|
+
})), canonical = canonicalizeStored(storedPortableText, options), originOf = traceOrigins(nonEmptyBlocks(storedPortableText, options), canonical, recorder), adoptedNodes = /* @__PURE__ */ new WeakSet();
|
|
2363
|
+
if (originOf) {
|
|
2364
|
+
let alignment = alignBlocks(canonical, result, recorder);
|
|
2365
|
+
if (alignment) {
|
|
2366
|
+
adoptAnchors(alignment.anchors, originOf, result, adoptedNodes, recorder);
|
|
2367
|
+
let gaps = adoptMoves(alignment.gaps, canonical, result, originOf, adoptedNodes, recorder);
|
|
2368
|
+
for (let gap of gaps) resolveGap(gap.storedIndexes, gap.editedIndexes, canonical, result, originOf, adoptedNodes, recorder);
|
|
2369
|
+
reinsertEmptyRuns(storedPortableText, result, adoptedNodes, options, recorder);
|
|
2370
|
+
}
|
|
2371
|
+
}
|
|
2372
|
+
return enforceSiblingKeyUniqueness(result, options?.deserialize?.keyGenerator ?? defaultKeyGenerator, adoptedNodes, recorder), recorder && onReconciliation && onReconciliation(buildReconciliationReport(result, recorder)), result;
|
|
2373
|
+
}
|
|
2374
|
+
/**
|
|
2375
|
+
* Re-expresses the stored value in the parser's dialect by serializing
|
|
2376
|
+
* it and parsing it right back: the parser merges same-mark sibling
|
|
2377
|
+
* spans, reorders marks, collapses list levels, and fills defaults, so
|
|
2378
|
+
* content the edit never touched only deep-equals its parsed
|
|
2379
|
+
* counterpart after both sides have been through the same round trip.
|
|
2380
|
+
* The replacement keys are positional (`__canonical_<n>`), so a match
|
|
2381
|
+
* against canonical node `n` can be traded back for stored node `n`'s
|
|
2382
|
+
* real `_key`.
|
|
2383
|
+
*/
|
|
2384
|
+
function canonicalizeStored(stored, options) {
|
|
2385
|
+
let storedMarkdown = portableTextToMarkdown(structuredClone(stored), {
|
|
2386
|
+
...options?.serialize,
|
|
2387
|
+
schema: options?.schema
|
|
2388
|
+
}), { onDegradation, ...canonicalDeserializeOptions } = options?.deserialize ?? {}, canonicalKeyCounter = 0;
|
|
2389
|
+
return markdownToPortableText(storedMarkdown, {
|
|
2390
|
+
...canonicalDeserializeOptions,
|
|
2391
|
+
schema: options?.schema,
|
|
2392
|
+
keyGenerator: () => `__canonical_${canonicalKeyCounter++}`
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2395
|
+
/**
|
|
2396
|
+
* Positional pairing between `stored` and `canonical` is only
|
|
2397
|
+
* trustworthy when serialization preserved the node count and the
|
|
2398
|
+
* type sequence; when it did not (heading hard-break splits, lossy
|
|
2399
|
+
* table normalization), no key can be traced back to its owner, so
|
|
2400
|
+
* nothing adopts. `stored` is already the non-empty subsequence: empty
|
|
2401
|
+
* text blocks have no markdown form, so `canonical` never carries them
|
|
2402
|
+
* either, and `reinsertEmptyRuns` restores them afterward.
|
|
2403
|
+
*/
|
|
2404
|
+
function traceOrigins(stored, canonical, recorder) {
|
|
2405
|
+
if (canonical.length !== stored.length) {
|
|
2406
|
+
recorder && (recorder.skipReason = "round-trip-mismatch");
|
|
2407
|
+
return;
|
|
2408
|
+
}
|
|
2409
|
+
for (let index = 0; index < stored.length; index++) if (stored[index]._type !== canonical[index]?._type) {
|
|
2410
|
+
recorder && (recorder.skipReason = "round-trip-mismatch");
|
|
2411
|
+
return;
|
|
2412
|
+
}
|
|
2413
|
+
for (let index = 0; index < stored.length; index++) {
|
|
2414
|
+
let storedNode = stored[index], canonicalNode = canonical[index];
|
|
2415
|
+
if (isTextBlock$1(storedNode) && isTextBlock$1(canonicalNode) && blockText(storedNode).trim() !== blockText(canonicalNode).trim()) {
|
|
2416
|
+
recorder && (recorder.skipReason = "round-trip-mismatch");
|
|
2417
|
+
return;
|
|
2418
|
+
}
|
|
2419
|
+
}
|
|
2420
|
+
return (canonicalIndex) => stored[canonicalIndex];
|
|
2421
|
+
}
|
|
2422
|
+
/**
|
|
2423
|
+
* Equal runs become anchor pairs (so repeated content pairs
|
|
2424
|
+
* first-to-first when adopted), and the delete/insert runs between
|
|
2425
|
+
* them form the gaps.
|
|
2426
|
+
*/
|
|
2427
|
+
function alignBlocks(canonical, result, recorder) {
|
|
2428
|
+
let tokenByNeutral = /* @__PURE__ */ new Map(), tokenOf = (node) => {
|
|
2429
|
+
let neutral = neutralForm(node), token = tokenByNeutral.get(neutral);
|
|
2430
|
+
return token === void 0 && (token = String.fromCharCode(tokenByNeutral.size + 1), tokenByNeutral.set(neutral, token)), token;
|
|
2431
|
+
}, canonicalTokens = canonical.map(tokenOf).join(""), resultTokens = result.map(tokenOf).join("");
|
|
2432
|
+
if (tokenByNeutral.size > 55e3) {
|
|
2433
|
+
recorder && (recorder.skipReason = "document-too-large");
|
|
2434
|
+
return;
|
|
2435
|
+
}
|
|
2436
|
+
let diffs = makeDiff(canonicalTokens, resultTokens, { checkLines: !1 }), anchors = [], gaps = [], gap = {
|
|
2437
|
+
storedIndexes: [],
|
|
2438
|
+
editedIndexes: []
|
|
2439
|
+
}, flushGap = () => {
|
|
2440
|
+
(gap.storedIndexes.length > 0 || gap.editedIndexes.length > 0) && (gaps.push(gap), gap = {
|
|
2441
|
+
storedIndexes: [],
|
|
2442
|
+
editedIndexes: []
|
|
2443
|
+
});
|
|
2444
|
+
}, canonicalIndex = 0, resultIndex = 0;
|
|
2445
|
+
for (let [operation, text] of diffs) if (operation === 0) {
|
|
2446
|
+
flushGap();
|
|
2447
|
+
for (let offset = 0; offset < text.length; offset++) anchors.push({
|
|
2448
|
+
canonicalIndex,
|
|
2449
|
+
resultIndex
|
|
2450
|
+
}), canonicalIndex++, resultIndex++;
|
|
2451
|
+
} else if (operation === -1) for (let offset = 0; offset < text.length; offset++) gap.storedIndexes.push(canonicalIndex++);
|
|
2452
|
+
else for (let offset = 0; offset < text.length; offset++) gap.editedIndexes.push(resultIndex++);
|
|
2453
|
+
return flushGap(), {
|
|
2454
|
+
anchors,
|
|
2455
|
+
gaps
|
|
2456
|
+
};
|
|
2457
|
+
}
|
|
2458
|
+
function adoptAnchors(anchors, originOf, result, adoptedNodes, recorder) {
|
|
2459
|
+
for (let anchor of anchors) result[anchor.resultIndex] = adoptVerbatim(originOf(anchor.canonicalIndex), result[anchor.resultIndex], adoptedNodes, "content-unchanged", recorder);
|
|
2460
|
+
}
|
|
2461
|
+
/**
|
|
2462
|
+
* An anchor or a unique exact leftover pairs a canonical block against
|
|
2463
|
+
* a parsed one that share the same neutral form (that is what put them
|
|
2464
|
+
* in the same equal-diff run or the same neutral-form bucket), so
|
|
2465
|
+
* everything the dialect can express is untouched and everything it
|
|
2466
|
+
* cannot express was invisible to the edit. The stored subtree is the
|
|
2467
|
+
* truth at every depth, spans, marks, markDefs, and any field the
|
|
2468
|
+
* dialect drops, so adoption replaces the whole node rather than
|
|
2469
|
+
* reconciling into the parsed shape (which would otherwise, for
|
|
2470
|
+
* instance, keep a parser-side span merge that collapsed an
|
|
2471
|
+
* unmappable mark boundary the edit never touched). `restoreFields`
|
|
2472
|
+
* and per-child reconciliation are skipped entirely: a verbatim clone
|
|
2473
|
+
* of the stored node is already complete.
|
|
2474
|
+
*/
|
|
2475
|
+
function adoptVerbatim(original, target, adoptedNodes, basis, recorder) {
|
|
2476
|
+
let clone = structuredClone(original);
|
|
2477
|
+
return fillMissingKeysFromTarget(clone, target), markSubtreeAdopted(clone, adoptedNodes), recorder && (tagSubtreePreserved(clone, recorder), basis !== "content-unchanged" && typeof clone._key == "string" && recorder.preservationBasis.set(clone, basis)), clone;
|
|
2478
|
+
}
|
|
2479
|
+
/**
|
|
2480
|
+
* A stored node practically always carries its own `_key`; when it
|
|
2481
|
+
* genuinely does not, there is nothing to adopt, so the clone keeps
|
|
2482
|
+
* whatever key the plain parse already minted at the corresponding
|
|
2483
|
+
* position, the same key adoption would have left in place. The walk
|
|
2484
|
+
* follows both trees positionally (not by content matching, which is
|
|
2485
|
+
* exactly what an exact-signature match already guarantees agrees at
|
|
2486
|
+
* every position the two sides both still have).
|
|
2487
|
+
*/
|
|
2488
|
+
function fillMissingKeysFromTarget(clone, target) {
|
|
2489
|
+
typeof clone._key != "string" && typeof target._key == "string" && (clone._key = target._key);
|
|
2490
|
+
for (let field of Object.keys(clone)) {
|
|
2491
|
+
let cloneValue = clone[field], targetValue = target[field];
|
|
2492
|
+
if (isTypedObjectArray(cloneValue) && isTypedObjectArray(targetValue)) {
|
|
2493
|
+
let length = Math.min(cloneValue.length, targetValue.length);
|
|
2494
|
+
for (let index = 0; index < length; index++) fillMissingKeysFromTarget(cloneValue[index], targetValue[index]);
|
|
2495
|
+
} else typeof cloneValue == "object" && cloneValue && !Array.isArray(cloneValue) && typeof targetValue == "object" && targetValue && !Array.isArray(targetValue) && fillMissingKeysFromTarget(cloneValue, targetValue);
|
|
2496
|
+
}
|
|
2497
|
+
}
|
|
2498
|
+
/**
|
|
2499
|
+
* Every keyed node in a verbatim clone is adopted, not only its root:
|
|
2500
|
+
* the sibling-key-uniqueness pass recurses into every nested keyed
|
|
2501
|
+
* array, and an adopted-first ordering there only favors a clone's
|
|
2502
|
+
* descendants if they are themselves marked adopted.
|
|
2503
|
+
*/
|
|
2504
|
+
function markSubtreeAdopted(node, adoptedNodes) {
|
|
2505
|
+
adoptedNodes.add(node);
|
|
2506
|
+
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);
|
|
2507
|
+
else typeof value == "object" && value && markSubtreeAdopted(value, adoptedNodes);
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Moves: content that left one gap and reappeared in another. Unique
|
|
2511
|
+
* exact pairs across all gaps adopt before any gap-local pairing can
|
|
2512
|
+
* consume the keys they need.
|
|
2513
|
+
*/
|
|
2514
|
+
function adoptMoves(gaps, canonical, result, originOf, adoptedNodes, recorder) {
|
|
2515
|
+
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();
|
|
2516
|
+
for (let index of storedLeftovers) {
|
|
2517
|
+
let neutral = neutralForm(canonical[index]);
|
|
2518
|
+
storedByNeutral.set(neutral, [...storedByNeutral.get(neutral) ?? [], index]);
|
|
2519
|
+
}
|
|
2520
|
+
let editedByNeutral = /* @__PURE__ */ new Map();
|
|
2521
|
+
for (let index of editedLeftovers) {
|
|
2522
|
+
let neutral = neutralForm(result[index]);
|
|
2523
|
+
editedByNeutral.set(neutral, [...editedByNeutral.get(neutral) ?? [], index]);
|
|
2524
|
+
}
|
|
2525
|
+
for (let [neutral, storedIndexes] of storedByNeutral) {
|
|
2526
|
+
let editedIndexes = editedByNeutral.get(neutral);
|
|
2527
|
+
if (storedIndexes.length !== 1 || !editedIndexes || editedIndexes.length !== 1) continue;
|
|
2528
|
+
consumedStored.add(storedIndexes[0]), consumedEdited.add(editedIndexes[0]);
|
|
2529
|
+
let clone = adoptVerbatim(originOf(storedIndexes[0]), result[editedIndexes[0]], adoptedNodes, "content-moved", recorder);
|
|
2530
|
+
result[editedIndexes[0]] = clone;
|
|
2531
|
+
}
|
|
2532
|
+
return gaps.map((currentGap) => ({
|
|
2533
|
+
storedIndexes: currentGap.storedIndexes.filter((index) => !consumedStored.has(index)),
|
|
2534
|
+
editedIndexes: currentGap.editedIndexes.filter((index) => !consumedEdited.has(index))
|
|
2535
|
+
}));
|
|
2536
|
+
}
|
|
2537
|
+
/**
|
|
2538
|
+
* Gap policy, in order: split/merge survivor (the first fragment or
|
|
2539
|
+
* first source block keeps the key, matching what pressing enter or
|
|
2540
|
+
* backspace does in the editor), positional zip for equal counts
|
|
2541
|
+
* (typing over a paragraph keeps its identity), similarity for
|
|
2542
|
+
* unequal counts (an insertion or deletion shifted positions, so
|
|
2543
|
+
* position lies and only mutual unique best evidence adopts).
|
|
2544
|
+
*/
|
|
2545
|
+
function resolveGap(storedIndexes, editedIndexes, canonical, result, originOf, adoptedNodes, recorder) {
|
|
2546
|
+
let remainingStored = new Set(storedIndexes), remainingEdited = new Set(editedIndexes), withinConcatenationCap = storedIndexes.length * editedIndexes.length <= MAX_SIMILARITY_PAIRS;
|
|
2547
|
+
if (withinConcatenationCap) for (let storedIndex of storedIndexes) {
|
|
2548
|
+
if (!remainingStored.has(storedIndex)) continue;
|
|
2549
|
+
let storedBlock = canonical[storedIndex];
|
|
2550
|
+
if (!isTextBlock$1(storedBlock)) continue;
|
|
2551
|
+
let fragments = findConcatenation(blockText(storedBlock), editedIndexes.filter((index) => remainingEdited.has(index)), result);
|
|
2552
|
+
if (fragments) {
|
|
2553
|
+
remainingStored.delete(storedIndex);
|
|
2554
|
+
for (let fragment of fragments) remainingEdited.delete(fragment);
|
|
2555
|
+
let survivor = fragments.find((fragment) => blockText(result[fragment]).length > 0) ?? fragments[0];
|
|
2556
|
+
adoptNode(originOf(storedIndex), canonical[storedIndex], result[survivor], adoptedNodes, "content-split", recorder);
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
if (withinConcatenationCap) for (let editedIndex of editedIndexes) {
|
|
2560
|
+
if (!remainingEdited.has(editedIndex)) continue;
|
|
2561
|
+
let editedBlock = result[editedIndex];
|
|
2562
|
+
if (!isTextBlock$1(editedBlock)) continue;
|
|
2563
|
+
let sources = findConcatenation(blockText(editedBlock), storedIndexes.filter((index) => remainingStored.has(index)), canonical);
|
|
2564
|
+
if (sources) {
|
|
2565
|
+
remainingEdited.delete(editedIndex);
|
|
2566
|
+
for (let source of sources) remainingStored.delete(source);
|
|
2567
|
+
adoptNode(originOf(sources[0]), canonical[sources[0]], result[editedIndex], adoptedNodes, "content-merged", recorder);
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
2570
|
+
let storedRest = [...remainingStored], editedRest = [...remainingEdited];
|
|
2571
|
+
if (storedRest.length === editedRest.length) {
|
|
2572
|
+
for (let offset = 0; offset < storedRest.length; offset++) {
|
|
2573
|
+
let storedBlock = canonical[storedRest[offset]], editedBlock = result[editedRest[offset]];
|
|
2574
|
+
storedBlock._type === editedBlock._type && adoptNode(originOf(storedRest[offset]), canonical[storedRest[offset]], editedBlock, adoptedNodes, "same-position", recorder);
|
|
2575
|
+
}
|
|
2576
|
+
return;
|
|
2577
|
+
}
|
|
2578
|
+
if (storedRest.length * editedRest.length > MAX_SIMILARITY_PAIRS) {
|
|
2579
|
+
recorder && recorder.ambiguousRegionGroups.push(editedRest.map((editedIndex) => result[editedIndex]));
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
let scores = /* @__PURE__ */ new Map();
|
|
2583
|
+
for (let storedIndex of storedRest) for (let editedIndex of editedRest) {
|
|
2584
|
+
let score = blockSimilarity(canonical[storedIndex], result[editedIndex]);
|
|
2585
|
+
score >= MIN_BLOCK_SIMILARITY && scores.set(`${storedIndex}:${editedIndex}`, score);
|
|
2586
|
+
}
|
|
2587
|
+
for (let storedIndex of storedRest) {
|
|
2588
|
+
let best = uniqueBest(editedRest, (editedIndex) => scores.get(`${storedIndex}:${editedIndex}`));
|
|
2589
|
+
best !== void 0 && uniqueBest(storedRest, (otherStoredIndex) => scores.get(`${otherStoredIndex}:${best}`)) === storedIndex && adoptNode(originOf(storedIndex), canonical[storedIndex], result[best], adoptedNodes, "similar-content", recorder);
|
|
2590
|
+
}
|
|
2591
|
+
}
|
|
2592
|
+
/**
|
|
2593
|
+
* Fragments join with nothing or a single space, since a markdown
|
|
2594
|
+
* merge is often a soft-wrap join that inserts one ("alpha\nbeta"
|
|
2595
|
+
* parses to "alpha beta").
|
|
2596
|
+
*/
|
|
2597
|
+
function findConcatenation(wholeText, candidateIndexes, nodes) {
|
|
2598
|
+
if (wholeText.length !== 0) for (let joiner of ["", " "]) for (let start = 0; start < candidateIndexes.length; start++) {
|
|
2599
|
+
let concatenated = "", used = [];
|
|
2600
|
+
for (let position = start; position < candidateIndexes.length; position++) {
|
|
2601
|
+
let index = candidateIndexes[position];
|
|
2602
|
+
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;
|
|
2603
|
+
if (concatenated === wholeText && used.length > 1) return used;
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* Adopts the original node's `_key`, its markdown-inexpressible
|
|
2609
|
+
* fields, then its `markDefs` before its other keyed children, since
|
|
2610
|
+
* `span.marks` references need the adopted `markDefs` keys already in
|
|
2611
|
+
* place.
|
|
2612
|
+
*/
|
|
2613
|
+
function adoptNode(original, canonicalCounterpart, target, adoptedNodes, basis, recorder) {
|
|
2614
|
+
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));
|
|
2615
|
+
for (let field of Object.keys(target)) {
|
|
2616
|
+
if (field === "markDefs") continue;
|
|
2617
|
+
let originalChildren = original[field], targetChildren = target[field];
|
|
2618
|
+
if (!isTypedObjectArray(originalChildren) || !isTypedObjectArray(targetChildren)) continue;
|
|
2619
|
+
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));
|
|
2620
|
+
for (let [neutral, originalIndexes] of originalGroups) {
|
|
2621
|
+
let targetIndexes = targetGroups.get(neutral);
|
|
2622
|
+
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));
|
|
2623
|
+
}
|
|
2624
|
+
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);
|
|
2625
|
+
}
|
|
2626
|
+
}
|
|
2627
|
+
/**
|
|
2628
|
+
* The container-level counterpart of `traceOrigins`'s guard: a
|
|
2629
|
+
* child's canonical form is only trustworthy when the canonical
|
|
2630
|
+
* container holds the same field as the same typed object array,
|
|
2631
|
+
* equal in length and `_type` sequence to the original's, so pairing
|
|
2632
|
+
* by index (original child `i` to canonical child `i`) means the same
|
|
2633
|
+
* content on both sides.
|
|
2634
|
+
*/
|
|
2635
|
+
function canonicalChildArray(canonicalCounterpart, field, originalChildren) {
|
|
2636
|
+
if (!canonicalCounterpart) return;
|
|
2637
|
+
let candidate = canonicalCounterpart[field];
|
|
2638
|
+
if (!(!isTypedObjectArray(candidate) || candidate.length !== originalChildren.length)) {
|
|
2639
|
+
for (let index = 0; index < originalChildren.length; index++) if (originalChildren[index]._type !== candidate[index]._type) return;
|
|
2640
|
+
return candidate;
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
/**
|
|
2644
|
+
* Restores fields the markdown dialect dropped or altered: present on
|
|
2645
|
+
* the original, unchanged by the edit (the target still agrees with
|
|
2646
|
+
* the original's own canonical round trip), and different on that
|
|
2647
|
+
* round trip from the original (so the parse, left to itself, could
|
|
2648
|
+
* never have produced the original's value). Absence counts as a
|
|
2649
|
+
* value under both comparisons, which is what folds a wholly dropped
|
|
2650
|
+
* field (no markdown form at all) and a coerced one (a custom style
|
|
2651
|
+
* or decorator markdown silently maps to its closest built-in) into
|
|
2652
|
+
* one rule. Structural child arrays (`children`, `markDefs`, and
|
|
2653
|
+
* typed object arrays generally) are excluded: their elements adopt
|
|
2654
|
+
* individually through the recursive per-child walk instead, except
|
|
2655
|
+
* when the target has no such element to walk at all: a typed-object
|
|
2656
|
+
* array field the dialect drops entirely leaves nothing on the target
|
|
2657
|
+
* side for that walk to reconcile, so it falls through to the same
|
|
2658
|
+
* absent-on-target, absent-on-canonical oracle as every scalar field,
|
|
2659
|
+
* restored verbatim rather than left missing. Restored values are
|
|
2660
|
+
* cloned, since the sibling-key-uniqueness pass may rewrite `_key`s
|
|
2661
|
+
* inside a restored array of objects, and the original must stay
|
|
2662
|
+
* untouched.
|
|
2663
|
+
*/
|
|
2664
|
+
function restoreFields(original, canonicalCounterpart, target) {
|
|
2665
|
+
if (canonicalCounterpart) for (let field of Object.keys(original)) {
|
|
2666
|
+
if (field === "_key" || field === "_type" || field === "markDefs" || field === "children") continue;
|
|
2667
|
+
if (isTypedObjectArray(original[field])) {
|
|
2668
|
+
target[field] === void 0 && canonicalCounterpart[field] === void 0 && (target[field] = structuredClone(original[field]));
|
|
2669
|
+
continue;
|
|
2670
|
+
}
|
|
2671
|
+
let canonicalValue = canonicalCounterpart[field];
|
|
2672
|
+
valuesEqual(target[field], canonicalValue) && (valuesEqual(canonicalValue, original[field]) || (target[field] = structuredClone(original[field])));
|
|
2673
|
+
}
|
|
2674
|
+
}
|
|
2675
|
+
/**
|
|
2676
|
+
* Deep equality for restoration's before/after comparison: the
|
|
2677
|
+
* `encodeNeutral` encoding without alias rewriting, so it compares
|
|
2678
|
+
* `marks` arrays (and any other array or nested object) by value
|
|
2679
|
+
* rather than by identity. Absent (`undefined`) encodes to the same
|
|
2680
|
+
* string on both sides, so two absent fields count as equal.
|
|
2681
|
+
*/
|
|
2682
|
+
function valuesEqual(a, b) {
|
|
2683
|
+
return encodeNeutral(a, void 0) === encodeNeutral(b, void 0);
|
|
2684
|
+
}
|
|
2685
|
+
/**
|
|
2686
|
+
* A span in the edited output can be the merge of several stored
|
|
2687
|
+
* spans: the parser merges adjacent same-mark spans, and an edit that
|
|
2688
|
+
* removes formatting merges across the old mark boundary too. Matching
|
|
2689
|
+
* is by text alone, and the first contributor's key survives, matching
|
|
2690
|
+
* the editor's own span-merge normalization.
|
|
2691
|
+
*/
|
|
2692
|
+
function adoptMergedSpans(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder) {
|
|
2693
|
+
for (let targetIndex = 0; targetIndex < targetChildren.length; targetIndex++) {
|
|
2694
|
+
if (matchedTarget.has(targetIndex)) continue;
|
|
2695
|
+
let targetSpan = targetChildren[targetIndex], targetText = targetSpan.text;
|
|
2696
|
+
if (typeof targetText == "string") for (let start = 0; start < originalChildren.length; start++) {
|
|
2697
|
+
if (matchedOriginal.has(start)) continue;
|
|
2698
|
+
let concatenated = "", used = [];
|
|
2699
|
+
for (let index = start; index < originalChildren.length && !matchedOriginal.has(index); index++) {
|
|
2700
|
+
let originalSpan = originalChildren[index];
|
|
2701
|
+
if (typeof originalSpan.text != "string" || (concatenated += originalSpan.text, used.push(index), concatenated.length > targetText.length)) break;
|
|
2702
|
+
if (concatenated === targetText && used.length > 1) {
|
|
2703
|
+
matchedTarget.add(targetIndex);
|
|
2704
|
+
for (let usedIndex of used) matchedOriginal.add(usedIndex);
|
|
2705
|
+
adoptNode(originalChildren[used[0]], canonicalChildren?.[used[0]], targetSpan, adoptedNodes, "content-merged", recorder);
|
|
2706
|
+
break;
|
|
2707
|
+
}
|
|
2708
|
+
}
|
|
2709
|
+
if (matchedTarget.has(targetIndex)) break;
|
|
2710
|
+
}
|
|
2711
|
+
}
|
|
2712
|
+
}
|
|
2713
|
+
/**
|
|
2714
|
+
* The equal-count residual rule, mirroring `resolveGap`'s positional
|
|
2715
|
+
* zip: elements left unmatched after neutral-form (and, for
|
|
2716
|
+
* `children`, span-merge) matching are treated as in-place edits when
|
|
2717
|
+
* both sides leave the same count, position being the same evidence
|
|
2718
|
+
* the block-level zip already trusts, and the trade is the same too:
|
|
2719
|
+
* a reorder-plus-edit with balanced counts mispairs. Unequal counts
|
|
2720
|
+
* adopt nothing, since position no longer lines up.
|
|
2721
|
+
*/
|
|
2722
|
+
function adoptResidualZip(originalChildren, canonicalChildren, matchedOriginal, targetChildren, matchedTarget, adoptedNodes, recorder) {
|
|
2723
|
+
let originalRest = originalChildren.map((node, index) => ({
|
|
2724
|
+
node,
|
|
2725
|
+
index
|
|
2726
|
+
})).filter(({ index }) => !matchedOriginal.has(index)), targetRest = targetChildren.filter((_, index) => !matchedTarget.has(index));
|
|
2727
|
+
if (originalRest.length === targetRest.length) for (let offset = 0; offset < originalRest.length; offset++) {
|
|
2728
|
+
let originalNode = originalRest[offset].node, targetNode = targetRest[offset];
|
|
2729
|
+
originalNode._type === targetNode._type && adoptNode(originalNode, canonicalChildren?.[originalRest[offset].index], targetNode, adoptedNodes, "same-position", recorder);
|
|
2730
|
+
}
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* Matches `markDefs` by definition content, `_key` excluded. Returns
|
|
2734
|
+
* the mapping from the target's fresh keys to the adopted stored
|
|
2735
|
+
* keys, for rewriting `span.marks` references.
|
|
2736
|
+
*/
|
|
2737
|
+
function adoptMarkDefs(original, canonicalCounterpart, target, recorder) {
|
|
2738
|
+
let keyMap = /* @__PURE__ */ new Map(), originalDefs = original.markDefs, targetDefs = target.markDefs;
|
|
2739
|
+
if (!isTypedObjectArray(originalDefs) || !isTypedObjectArray(targetDefs)) return keyMap;
|
|
2740
|
+
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) => {
|
|
2741
|
+
if (restoreFields(originalDef, canonicalDef, targetDef), typeof originalDef._key == "string" && typeof targetDef._key == "string") {
|
|
2742
|
+
let adoptedKey = originalDef._key;
|
|
2743
|
+
if (targetDefs.some((def) => def !== targetDef && def._key === adoptedKey)) {
|
|
2744
|
+
recorder?.annotationKeyConflicts.push(targetDef);
|
|
2745
|
+
return;
|
|
2746
|
+
}
|
|
2747
|
+
keyMap.set(targetDef._key, adoptedKey), targetDef._key = adoptedKey, recorder?.preservationBasis.set(targetDef, basis);
|
|
2748
|
+
}
|
|
2749
|
+
};
|
|
2750
|
+
for (let [neutral, originalIndexes] of originalGroups) {
|
|
2751
|
+
let targetIndexes = targetGroups.get(neutral);
|
|
2752
|
+
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");
|
|
2753
|
+
}
|
|
2754
|
+
let originalRest = originalDefs.map((node, index) => ({
|
|
2755
|
+
node,
|
|
2756
|
+
index
|
|
2757
|
+
})).filter(({ index }) => !matchedOriginal.has(index)), targetRest = targetDefs.filter((_, index) => !matchedTarget.has(index));
|
|
2758
|
+
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;
|
|
2759
|
+
}
|
|
2760
|
+
function rewriteMarkReferences(block, keyMap) {
|
|
2761
|
+
if (keyMap.size === 0) return;
|
|
2762
|
+
let children = block.children;
|
|
2763
|
+
if (isTypedObjectArray(children)) for (let child of children) {
|
|
2764
|
+
let marks = child.marks;
|
|
2765
|
+
Array.isArray(marks) && (child.marks = marks.map((mark) => typeof mark == "string" && keyMap.has(mark) ? keyMap.get(mark) : mark));
|
|
2766
|
+
}
|
|
2767
|
+
}
|
|
2768
|
+
function groupByNeutralForm(nodes, exclude, aliasByKey) {
|
|
2769
|
+
let groups = /* @__PURE__ */ new Map();
|
|
2770
|
+
for (let index = 0; index < nodes.length; index++) {
|
|
2771
|
+
if (exclude.has(index)) continue;
|
|
2772
|
+
let neutral = aliasByKey ? encodeNeutral(nodes[index], aliasByKey) : neutralForm(nodes[index]), group = groups.get(neutral);
|
|
2773
|
+
group ? group.push(index) : groups.set(neutral, [index]);
|
|
2774
|
+
}
|
|
2775
|
+
return groups;
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* A canonical JSON encoding that erases identity: `_key` properties
|
|
2779
|
+
* are dropped, object properties are sorted, and annotation `_key`
|
|
2780
|
+
* references inside `span.marks` are rewritten to the definition's
|
|
2781
|
+
* position in `markDefs` (dropping `_key` alone would compare the
|
|
2782
|
+
* stored annotation key against the fresh one and reject an unchanged
|
|
2783
|
+
* link).
|
|
2784
|
+
*/
|
|
2785
|
+
function neutralForm(node) {
|
|
2786
|
+
return encodeNeutral(node, buildAliasMap(node));
|
|
2787
|
+
}
|
|
2788
|
+
/**
|
|
2789
|
+
* Aliases each `markDefs` key to a key-independent spelling of the
|
|
2790
|
+
* definition itself, so `span.marks` references compare by what the
|
|
2791
|
+
* annotation is rather than which key it carries. The spelling is the
|
|
2792
|
+
* definition's own neutral form, not its array position: aliasing by
|
|
2793
|
+
* position made every annotated sibling span's neutral form shift when
|
|
2794
|
+
* a definition was inserted or removed before its own, so adding one
|
|
2795
|
+
* link re-keyed unrelated annotated spans. Identical definitions (the
|
|
2796
|
+
* same link twice) are disambiguated by occurrence order among
|
|
2797
|
+
* identical forms only, which no unrelated insertion can shift.
|
|
2798
|
+
*/
|
|
2799
|
+
function buildAliasMap(node) {
|
|
2800
|
+
let markDefs = node.markDefs;
|
|
2801
|
+
if (!isTypedObjectArray(markDefs)) return;
|
|
2802
|
+
let aliasByKey = /* @__PURE__ */ new Map(), occurrenceByForm = /* @__PURE__ */ new Map();
|
|
2803
|
+
for (let definition of markDefs) {
|
|
2804
|
+
let key = definition._key;
|
|
2805
|
+
if (typeof key != "string") continue;
|
|
2806
|
+
let form = encodeNeutral(definition, void 0), occurrence = occurrenceByForm.get(form) ?? 0;
|
|
2807
|
+
occurrenceByForm.set(form, occurrence + 1), aliasByKey.set(key, `@annotation:${occurrence}:${form}`);
|
|
2808
|
+
}
|
|
2809
|
+
return aliasByKey;
|
|
2810
|
+
}
|
|
2811
|
+
function encodeNeutral(value, aliasByKey) {
|
|
2812
|
+
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]) => {
|
|
2813
|
+
if (field === "marks" && aliasByKey && Array.isArray(fieldValue)) {
|
|
2814
|
+
let aliased = fieldValue.map((mark) => typeof mark == "string" && aliasByKey.has(mark) ? aliasByKey.get(mark) : mark);
|
|
2815
|
+
return `${JSON.stringify(field)}:${JSON.stringify(aliased)}`;
|
|
2816
|
+
}
|
|
2817
|
+
return `${JSON.stringify(field)}:${encodeNeutral(fieldValue, aliasByKey)}`;
|
|
2818
|
+
}).join(",")}}` : JSON.stringify(value) ?? "undefined";
|
|
2819
|
+
}
|
|
2820
|
+
/**
|
|
2821
|
+
* The block's comparison text: concatenated span text with inline
|
|
2822
|
+
* objects as sentinels. Marks are ignored, since a formatting-only
|
|
2823
|
+
* edit does not change textual identity.
|
|
2824
|
+
*/
|
|
2825
|
+
function blockText(block) {
|
|
2826
|
+
let children = block.children;
|
|
2827
|
+
return isTypedObjectArray(children) ? children.map((child) => typeof child.text == "string" ? child.text : "").join("") : "";
|
|
2828
|
+
}
|
|
2829
|
+
/**
|
|
2830
|
+
* Text similarity in `[0, 1]`, gated to `0` unless the block shells
|
|
2831
|
+
* agree and the inline objects are compatible: however alike the
|
|
2832
|
+
* prose, blocks that disagree on structure are not the same block.
|
|
2833
|
+
* The length prescreen skips the diff when the size difference alone
|
|
2834
|
+
* puts the score under `MIN_BLOCK_SIMILARITY`.
|
|
2835
|
+
*/
|
|
2836
|
+
function blockSimilarity(canonicalBlock, resultBlock) {
|
|
2837
|
+
if (!shellEquals(canonicalBlock, resultBlock) || !inlineObjectsCompatible(canonicalBlock, resultBlock)) return 0;
|
|
2838
|
+
let canonicalText = blockText(canonicalBlock), resultText = blockText(resultBlock);
|
|
2839
|
+
if (canonicalText.length === 0 || resultText.length === 0) return 0;
|
|
2840
|
+
let longer = Math.max(canonicalText.length, resultText.length);
|
|
2841
|
+
return 1 - Math.abs(canonicalText.length - resultText.length) / longer < MIN_BLOCK_SIMILARITY ? 0 : 1 - levenshteinFromDiffs(cleanupEfficiency(makeDiff(canonicalText, resultText, { timeout: .05 }))) / longer;
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* The distance derivation from diff runs: insertions and deletions
|
|
2845
|
+
* between equality runs accumulate as the larger of the two, so a
|
|
2846
|
+
* delete-plus-insert counts as one substitution.
|
|
2847
|
+
*/
|
|
2848
|
+
function levenshteinFromDiffs(diffs) {
|
|
2849
|
+
let distance = 0, insertions = 0, deletions = 0;
|
|
2850
|
+
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);
|
|
2851
|
+
return distance + Math.max(insertions, deletions);
|
|
2852
|
+
}
|
|
2853
|
+
/**
|
|
2854
|
+
* The block minus its content: `_type`, `style`, `listItem`, `level`,
|
|
2855
|
+
* and any custom fields must agree before text similarity means
|
|
2856
|
+
* anything.
|
|
2857
|
+
*/
|
|
2858
|
+
function shellEquals(a, b) {
|
|
2859
|
+
let shellOf = (node) => encodeNeutral(Object.fromEntries(Object.entries(node).filter(([field]) => field !== "children" && field !== "markDefs")), void 0);
|
|
2860
|
+
return shellOf(a) === shellOf(b);
|
|
2861
|
+
}
|
|
2862
|
+
/**
|
|
2863
|
+
* Inline objects are opaque content: two blocks whose objects differ
|
|
2864
|
+
* are different blocks no matter how similar their prose is.
|
|
2865
|
+
*/
|
|
2866
|
+
function inlineObjectsCompatible(a, b) {
|
|
2867
|
+
let objectsOf = (node) => {
|
|
2868
|
+
let children = node.children;
|
|
2869
|
+
return isTypedObjectArray(children) ? children.filter((child) => typeof child.text != "string") : [];
|
|
2870
|
+
}, aObjects = objectsOf(a), bObjects = objectsOf(b);
|
|
2871
|
+
return aObjects.length === bObjects.length && aObjects.every((aObject, index) => {
|
|
2872
|
+
let bObject = bObjects[index];
|
|
2873
|
+
return aObject._type === bObject._type ? typeof aObject._key == "string" && aObject._key === bObject._key || neutralForm(aObject) === neutralForm(bObject) : !1;
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
/**
|
|
2877
|
+
* The highest-scoring candidate, or `undefined` on a tie: a tie is
|
|
2878
|
+
* ambiguity, and ambiguity refuses adoption rather than guessing.
|
|
2879
|
+
*/
|
|
2880
|
+
function uniqueBest(candidates, scoreOf) {
|
|
2881
|
+
let best, bestScore = 0, tied = !1;
|
|
2882
|
+
for (let candidate of candidates) {
|
|
2883
|
+
let score = scoreOf(candidate);
|
|
2884
|
+
score !== void 0 && (score > bestScore ? (best = candidate, bestScore = score, tied = !1) : score === bestScore && best !== void 0 && (tied = !0));
|
|
2885
|
+
}
|
|
2886
|
+
return tied ? void 0 : best;
|
|
2887
|
+
}
|
|
2888
|
+
/**
|
|
2889
|
+
* Deliberately loose: any node with a `children` array reconciles like
|
|
2890
|
+
* a text block, custom block types included.
|
|
2891
|
+
*/
|
|
2892
|
+
function isTextBlock$1(node) {
|
|
2893
|
+
return Array.isArray(node.children);
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* A text block the round trip drops. Whether a whitespace-only block
|
|
2897
|
+
* survives serialize→parse depends on the converters, not on
|
|
2898
|
+
* structure: a heading renders `## ` and survives, a custom style
|
|
2899
|
+
* falls back to a plain paragraph and vanishes, and a non-breaking
|
|
2900
|
+
* space renders "blank-looking" output the parser still keeps (JS
|
|
2901
|
+
* `trim` folds NBSP, CommonMark does not), so any string check here is
|
|
2902
|
+
* a hand-written mirror of one converter or the other that drifts. The
|
|
2903
|
+
* block's own render-then-reparse is the authority, the same round
|
|
2904
|
+
* trip `canonicalizeStored` performs, so this predicate cannot
|
|
2905
|
+
* disagree with the canonical node count. The JS-trim pre-check only
|
|
2906
|
+
* keeps the per-block round trip off paths that cannot qualify: it
|
|
2907
|
+
* over-admits candidates (NBSP text passes it), and the reparse then
|
|
2908
|
+
* decides.
|
|
2909
|
+
*/
|
|
2910
|
+
function isEmptyTextBlock(node, options) {
|
|
2911
|
+
if (!(isTextBlock$1(node) && blockText(node).trim() === "")) return !1;
|
|
2912
|
+
let rendered = portableTextToMarkdown([structuredClone(node)], {
|
|
2913
|
+
...options?.serialize,
|
|
2914
|
+
schema: options?.schema
|
|
2915
|
+
});
|
|
2916
|
+
if (rendered === "") return !0;
|
|
2917
|
+
let { onDegradation: _onDegradation, ...deserializeOptions } = options?.deserialize ?? {}, probeKeyCounter = 0;
|
|
2918
|
+
return markdownToPortableText(rendered, {
|
|
2919
|
+
...deserializeOptions,
|
|
2920
|
+
schema: options?.schema,
|
|
2921
|
+
keyGenerator: () => `empty-probe-${probeKeyCounter++}`
|
|
2922
|
+
}).length === 0;
|
|
2923
|
+
}
|
|
2924
|
+
/**
|
|
2925
|
+
* Blank lines are markdown's block separator, so an empty text block
|
|
2926
|
+
* has no serialized form: `canonicalizeStored`'s round trip drops it,
|
|
2927
|
+
* the same way `markdownToPortableText` would if it were parsed back
|
|
2928
|
+
* from `editedMarkdown`. Tracing origins and aligning blocks over this
|
|
2929
|
+
* subsequence keeps both sides the same length; `reinsertEmptyRuns`
|
|
2930
|
+
* restores the dropped blocks afterward.
|
|
2931
|
+
*/
|
|
2932
|
+
function nonEmptyBlocks(stored, options) {
|
|
2933
|
+
return stored.filter((node) => !isEmptyTextBlock(node, options));
|
|
2934
|
+
}
|
|
2935
|
+
function isTypedObjectArray(value) {
|
|
2936
|
+
return Array.isArray(value) && value.length > 0 && value.every((item) => typeof item == "object" && !!item && typeof item._type == "string");
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* Maximal runs of empty text blocks, each paired with the surviving
|
|
2940
|
+
* neighbor its restoration hangs off: a run with a preceding block
|
|
2941
|
+
* anchors to it (insert after); a run at the document's start, with
|
|
2942
|
+
* none, anchors to the block that follows it (insert before). A run
|
|
2943
|
+
* with neither (the whole document is empty blocks) has nothing to
|
|
2944
|
+
* anchor to and is dropped.
|
|
2945
|
+
*/
|
|
2946
|
+
function findEmptyRuns(stored, options) {
|
|
2947
|
+
let runs = [], index = 0;
|
|
2948
|
+
for (; index < stored.length;) {
|
|
2949
|
+
if (!isEmptyTextBlock(stored[index], options)) {
|
|
2950
|
+
index++;
|
|
2951
|
+
continue;
|
|
2952
|
+
}
|
|
2953
|
+
let runStart = index;
|
|
2954
|
+
for (; index < stored.length && isEmptyTextBlock(stored[index], options);) index++;
|
|
2955
|
+
let precedingBlock = runStart > 0 ? stored[runStart - 1] : void 0, followingBlock = index < stored.length ? stored[index] : void 0, anchor = precedingBlock ?? followingBlock;
|
|
2956
|
+
anchor && typeof anchor._key == "string" && runs.push({
|
|
2957
|
+
anchorKey: anchor._key,
|
|
2958
|
+
insertAfter: precedingBlock !== void 0,
|
|
2959
|
+
blocks: stored.slice(runStart, index)
|
|
2960
|
+
});
|
|
2961
|
+
}
|
|
2962
|
+
return runs;
|
|
2963
|
+
}
|
|
2964
|
+
/**
|
|
2965
|
+
* Restores each empty run next to the result node that adopted its
|
|
2966
|
+
* anchor's key, found by `_key` since positions have already shifted
|
|
2967
|
+
* under insertion, deletion, and move. An anchor whose key did not
|
|
2968
|
+
* survive into `result` (its region was rewritten or deleted) drops
|
|
2969
|
+
* the run with it, consistent with rewrite semantics elsewhere in this
|
|
2970
|
+
* module. Runs are cloned and marked adopted, the same authoritative
|
|
2971
|
+
* status as every other restored key, so a collision resolves in
|
|
2972
|
+
* their favor like `enforceSiblingKeyUniqueness` already does for
|
|
2973
|
+
* `json:object` duplicates.
|
|
2974
|
+
*/
|
|
2975
|
+
function reinsertEmptyRuns(stored, result, adoptedNodes, options, recorder) {
|
|
2976
|
+
for (let run of findEmptyRuns(stored, options)) {
|
|
2977
|
+
let adoptedAnchorIndex = result.findIndex((node) => node._key === run.anchorKey && adoptedNodes.has(node)), anchorIndex = adoptedAnchorIndex === -1 ? result.findIndex((node) => node._key === run.anchorKey) : adoptedAnchorIndex;
|
|
2978
|
+
if (anchorIndex === -1) continue;
|
|
2979
|
+
let clones = run.blocks.map((block) => structuredClone(block));
|
|
2980
|
+
for (let clone of clones) adoptedNodes.add(clone), recorder && tagSubtreePreserved(clone, recorder);
|
|
2981
|
+
result.splice(run.insertAfter ? anchorIndex + 1 : anchorIndex, 0, ...clones);
|
|
2982
|
+
}
|
|
2983
|
+
}
|
|
2984
|
+
/**
|
|
2985
|
+
* Every keyed node in a reinserted empty run is the stored value
|
|
2986
|
+
* verbatim, at every depth, so key resolution reporting tags the
|
|
2987
|
+
* whole subtree `content-unchanged` rather than only the run's top
|
|
2988
|
+
* block.
|
|
2989
|
+
*/
|
|
2990
|
+
function tagSubtreePreserved(node, recorder) {
|
|
2991
|
+
typeof node._key == "string" && recorder.preservationBasis.set(node, "content-unchanged");
|
|
2992
|
+
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);
|
|
2993
|
+
else typeof value == "object" && value && tagSubtreePreserved(value, recorder);
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* `json:object` payloads transport their `_key` verbatim, so a
|
|
2997
|
+
* copy-pasted fence puts the same key on two siblings, and everything
|
|
2998
|
+
* downstream (patches, anchors, editor normalization) assumes sibling
|
|
2999
|
+
* keys are unique. Adopted keys are authoritative, so a duplicate that
|
|
3000
|
+
* was adopted from the stored value wins and the other occurrences are
|
|
3001
|
+
* regenerated.
|
|
3002
|
+
*/
|
|
3003
|
+
function enforceSiblingKeyUniqueness(nodes, keyGenerator, adoptedNodes, recorder, onKeyRewritten) {
|
|
3004
|
+
let usedKeys = /* @__PURE__ */ new Set(), claim = (node) => {
|
|
3005
|
+
let key = node._key;
|
|
3006
|
+
if (typeof key == "string") {
|
|
3007
|
+
if (usedKeys.has(key)) {
|
|
3008
|
+
let freshKey;
|
|
3009
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
3010
|
+
let candidate = keyGenerator();
|
|
3011
|
+
if (!usedKeys.has(candidate)) {
|
|
3012
|
+
freshKey = candidate;
|
|
3013
|
+
break;
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
if (freshKey === void 0) {
|
|
3017
|
+
let suffix = 1, candidate = `${key}_${suffix}`;
|
|
3018
|
+
for (; usedKeys.has(candidate);) suffix++, candidate = `${key}_${suffix}`;
|
|
3019
|
+
freshKey = candidate;
|
|
3020
|
+
}
|
|
3021
|
+
node._key = freshKey, usedKeys.add(freshKey), onKeyRewritten?.(key, freshKey), recorder?.renamePreviousKey.set(node, key);
|
|
3022
|
+
return;
|
|
3023
|
+
}
|
|
3024
|
+
usedKeys.add(key);
|
|
3025
|
+
}
|
|
3026
|
+
}, adopted = nodes.filter((node) => adoptedNodes.has(node)), rest = nodes.filter((node) => !adoptedNodes.has(node));
|
|
3027
|
+
for (let node of [...adopted, ...rest]) claim(node);
|
|
3028
|
+
for (let node of nodes) enforceNestedSiblingKeyUniqueness(node, keyGenerator, adoptedNodes, recorder);
|
|
3029
|
+
}
|
|
3030
|
+
function enforceNestedSiblingKeyUniqueness(node, keyGenerator, adoptedNodes, recorder) {
|
|
3031
|
+
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);
|
|
3032
|
+
}
|
|
3033
|
+
/**
|
|
3034
|
+
* A colliding `keyGenerator` can mint the identical string for two
|
|
3035
|
+
* `markDefs` entries, which means the block's spans already reference
|
|
3036
|
+
* that shared string ambiguously before any rewrite happens: a
|
|
3037
|
+
* blanket find-and-replace of the old key would move every span's
|
|
3038
|
+
* reference, including the one that was never renamed. The
|
|
3039
|
+
* `markDefs` array and each span's `marks` are walked in the same
|
|
3040
|
+
* fixed document order, so the Nth occurrence of a given key in one
|
|
3041
|
+
* lines up with the Nth occurrence in the other; the first occurrence
|
|
3042
|
+
* is always the survivor (`enforceSiblingKeyUniqueness` only renames
|
|
3043
|
+
* on collision, never the first sighting of a key), so each rename
|
|
3044
|
+
* event retargets the next occurrence in that shared order instead of
|
|
3045
|
+
* every occurrence.
|
|
3046
|
+
*/
|
|
3047
|
+
function buildMarkDefKeyRewriter(block) {
|
|
3048
|
+
let children = block.children;
|
|
3049
|
+
if (!isTypedObjectArray(children)) return () => {};
|
|
3050
|
+
let occurrencesByKey = /* @__PURE__ */ new Map();
|
|
3051
|
+
for (let child of children) {
|
|
3052
|
+
let marks = child.marks;
|
|
3053
|
+
if (Array.isArray(marks)) for (let markIndex = 0; markIndex < marks.length; markIndex++) {
|
|
3054
|
+
let mark = marks[markIndex];
|
|
3055
|
+
if (typeof mark != "string") continue;
|
|
3056
|
+
let occurrences = occurrencesByKey.get(mark) ?? [];
|
|
3057
|
+
occurrences.push({
|
|
3058
|
+
child,
|
|
3059
|
+
markIndex
|
|
3060
|
+
}), occurrencesByKey.set(mark, occurrences);
|
|
3061
|
+
}
|
|
3062
|
+
}
|
|
3063
|
+
let consumedByKey = /* @__PURE__ */ new Map();
|
|
3064
|
+
return (oldKey, newKey) => {
|
|
3065
|
+
let occurrences = occurrencesByKey.get(oldKey), index = consumedByKey.get(oldKey) ?? 1;
|
|
3066
|
+
consumedByKey.set(oldKey, index + 1);
|
|
3067
|
+
let target = occurrences?.[index];
|
|
3068
|
+
if (!target) return;
|
|
3069
|
+
let marks = target.child.marks;
|
|
3070
|
+
Array.isArray(marks) && (marks[target.markIndex] = newKey);
|
|
3071
|
+
};
|
|
3072
|
+
}
|
|
3073
|
+
/**
|
|
3074
|
+
* Materializes the public report from the recorder's node-identity
|
|
3075
|
+
* decisions, walking the settled result tree once so every `key` and
|
|
3076
|
+
* `path` matches the returned value exactly: key resolution records
|
|
3077
|
+
* decisions before the sibling-uniqueness pass can still rewrite a
|
|
3078
|
+
* key, so keys and paths are only trustworthy read back from the
|
|
3079
|
+
* final tree, not from the moment a decision was made.
|
|
3080
|
+
*/
|
|
3081
|
+
function buildReconciliationReport(result, recorder) {
|
|
3082
|
+
let preservedKeys = [], renamedKeys = [], pathByNode = /* @__PURE__ */ new WeakMap(), walk = (node, path) => {
|
|
3083
|
+
pathByNode.set(node, path);
|
|
3084
|
+
let key = node._key;
|
|
3085
|
+
if (typeof key == "string") {
|
|
3086
|
+
let basis = recorder.preservationBasis.get(node), previousKey = recorder.renamePreviousKey.get(node);
|
|
3087
|
+
basis && previousKey === void 0 && preservedKeys.push({
|
|
3088
|
+
basis,
|
|
3089
|
+
key,
|
|
3090
|
+
path
|
|
3091
|
+
}), previousKey !== void 0 && renamedKeys.push({
|
|
3092
|
+
previousKey,
|
|
3093
|
+
key,
|
|
3094
|
+
path
|
|
3095
|
+
});
|
|
3096
|
+
}
|
|
3097
|
+
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) => {
|
|
3098
|
+
let childKey = child._key;
|
|
3099
|
+
walk(child, [
|
|
3100
|
+
...path,
|
|
3101
|
+
field,
|
|
3102
|
+
typeof childKey == "string" ? { _key: childKey } : index
|
|
3103
|
+
]);
|
|
3104
|
+
}) : typeof value == "object" && value && walk(value, [...path, field]);
|
|
3105
|
+
};
|
|
3106
|
+
if (result.forEach((block, index) => {
|
|
3107
|
+
let key = block._key;
|
|
3108
|
+
walk(block, [typeof key == "string" ? { _key: key } : index]);
|
|
3109
|
+
}), recorder.skipReason) return {
|
|
3110
|
+
keyMatching: "skipped",
|
|
3111
|
+
reason: recorder.skipReason,
|
|
3112
|
+
renamedKeys
|
|
3113
|
+
};
|
|
3114
|
+
let keyFallbacks = [];
|
|
3115
|
+
for (let group of recorder.ambiguousRegionGroups) keyFallbacks.push({
|
|
3116
|
+
type: "ambiguous-region-too-large",
|
|
3117
|
+
keys: group.map((node) => node._key).filter((key) => typeof key == "string")
|
|
3118
|
+
});
|
|
3119
|
+
for (let node of recorder.annotationKeyConflicts) keyFallbacks.push({
|
|
3120
|
+
type: "annotation-key-conflict",
|
|
3121
|
+
path: pathByNode.get(node)
|
|
3122
|
+
});
|
|
3123
|
+
return {
|
|
3124
|
+
keyMatching: "performed",
|
|
3125
|
+
preservedKeys,
|
|
3126
|
+
keyFallbacks,
|
|
3127
|
+
renamedKeys
|
|
3128
|
+
};
|
|
3129
|
+
}
|
|
3130
|
+
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 };
|
|
2238
3131
|
|
|
2239
3132
|
//# sourceMappingURL=index.js.map
|