@aicayzer/inkkit 0.0.17 → 0.1.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/NOTICE +4 -2
- package/README.md +12 -6
- package/dist/THIRD-PARTY-NOTICES.txt +2742 -0
- package/dist/chunks/core-vtCr4MBS.js +56503 -0
- package/dist/chunks/highlight-Daj2fv_u.js +14122 -0
- package/dist/core.d.ts +4 -0
- package/dist/core.js +2 -0
- package/dist/editor.d.ts +261 -48
- package/dist/index.d.ts +1 -7
- package/dist/index.js +5 -2
- package/dist/style.css +242 -221
- package/dist/types.d.ts +143 -18
- package/package.json +26 -15
- package/dist/autolink.d.ts +0 -1
- package/dist/autolink.js +0 -18
- package/dist/callouts.d.ts +0 -26
- package/dist/callouts.js +0 -311
- package/dist/clipboard.d.ts +0 -11
- package/dist/clipboard.js +0 -261
- package/dist/code-fences.d.ts +0 -8
- package/dist/code-fences.js +0 -166
- package/dist/comments.d.ts +0 -55
- package/dist/comments.js +0 -494
- package/dist/decorations.d.ts +0 -2
- package/dist/decorations.js +0 -67
- package/dist/dialect.d.ts +0 -9
- package/dist/dialect.js +0 -358
- package/dist/editor.js +0 -2441
- package/dist/files.d.ts +0 -40
- package/dist/files.js +0 -133
- package/dist/highlight.d.ts +0 -1
- package/dist/highlight.js +0 -136
- package/dist/image-blocks.d.ts +0 -71
- package/dist/image-blocks.js +0 -286
- package/dist/images.d.ts +0 -51
- package/dist/images.js +0 -398
- package/dist/inline-highlight.d.ts +0 -6
- package/dist/inline-highlight.js +0 -112
- package/dist/labels.d.ts +0 -36
- package/dist/labels.js +0 -34
- package/dist/linked-syntax.d.ts +0 -48
- package/dist/linked-syntax.js +0 -320
- package/dist/literals.d.ts +0 -19
- package/dist/literals.js +0 -211
- package/dist/mermaid.d.ts +0 -8
- package/dist/mermaid.js +0 -321
- package/dist/outline.d.ts +0 -22
- package/dist/outline.js +0 -79
- package/dist/paste-html.d.ts +0 -23
- package/dist/paste-html.js +0 -441
- package/dist/paste.d.ts +0 -26
- package/dist/paste.js +0 -482
- package/dist/portable-files.d.ts +0 -10
- package/dist/portable-files.js +0 -36
- package/dist/preserve.d.ts +0 -21
- package/dist/preserve.js +0 -757
- package/dist/print.d.ts +0 -7
- package/dist/print.js +0 -409
- package/dist/reference-clipboard.d.ts +0 -8
- package/dist/reference-clipboard.js +0 -201
- package/dist/references.d.ts +0 -14
- package/dist/references.js +0 -368
- package/dist/search.d.ts +0 -15
- package/dist/search.js +0 -202
- package/dist/selection.d.ts +0 -1
- package/dist/selection.js +0 -59
- package/dist/source.d.ts +0 -16
- package/dist/source.js +0 -57
- package/dist/spreadsheet.d.ts +0 -3
- package/dist/spreadsheet.js +0 -275
- package/dist/table-alignment.d.ts +0 -2
- package/dist/table-alignment.js +0 -46
- package/dist/tables.d.ts +0 -27
- package/dist/tables.js +0 -322
- package/dist/tasks.d.ts +0 -3
- package/dist/tasks.js +0 -51
- package/dist/text-ranges.d.ts +0 -29
- package/dist/text-ranges.js +0 -256
- package/dist/types.js +0 -8
- package/dist/wiki-view.d.ts +0 -21
- package/dist/wiki-view.js +0 -78
package/dist/preserve.js
DELETED
|
@@ -1,757 +0,0 @@
|
|
|
1
|
-
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
|
-
import { serialize } from './dialect.js';
|
|
3
|
-
import { restoreCodeEndings, finalCodeEnding } from './code-fences.js';
|
|
4
|
-
import { sourceFrontmatter } from './literals.js';
|
|
5
|
-
function signature(node) {
|
|
6
|
-
return JSON.stringify(node, (key, value) => ['position', 'data', 'marker'].includes(key) ? undefined : value);
|
|
7
|
-
}
|
|
8
|
-
function ownShape(node) {
|
|
9
|
-
return JSON.stringify(node, (key, value) => ['position', 'data', 'children', 'value', 'marker'].includes(key)
|
|
10
|
-
? undefined
|
|
11
|
-
: value);
|
|
12
|
-
}
|
|
13
|
-
function retainText(raw, before, after, quoteIndent = false, inTable = false) {
|
|
14
|
-
before = before.replace(/\r\n?/g, '\n');
|
|
15
|
-
after = after.replace(/\r\n?/g, '\n');
|
|
16
|
-
let prefix = 0;
|
|
17
|
-
while (prefix < before.length &&
|
|
18
|
-
prefix < after.length &&
|
|
19
|
-
before[prefix] === after[prefix])
|
|
20
|
-
prefix++;
|
|
21
|
-
let suffix = 0;
|
|
22
|
-
while (suffix < before.length - prefix &&
|
|
23
|
-
suffix < after.length - prefix &&
|
|
24
|
-
before[before.length - 1 - suffix] === after[after.length - 1 - suffix])
|
|
25
|
-
suffix++;
|
|
26
|
-
const offsets = [0];
|
|
27
|
-
let decoded = '';
|
|
28
|
-
for (let index = 0; index < raw.length;) {
|
|
29
|
-
if (index > 0 && raw[index - 1] === '\n') {
|
|
30
|
-
const gutter = (quoteIndent ? /^(?:[ \t]*>[ \t]*)+/ : /^(?:[ \t]*>[ \t]?)+/).exec(raw.slice(index));
|
|
31
|
-
if (gutter) {
|
|
32
|
-
index += gutter[0].length;
|
|
33
|
-
offsets[offsets.length - 1] = index;
|
|
34
|
-
continue;
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
const escaped = /^\\([!"#$%&'()*+,\-./:;<=>?@[\]\\^_`{|}~])/.exec(raw.slice(index));
|
|
38
|
-
const entity = /^&(?:#[xX][\da-fA-F]+|#\d+|[A-Za-z][A-Za-z\d]+);/.exec(raw.slice(index));
|
|
39
|
-
let value;
|
|
40
|
-
let length;
|
|
41
|
-
if (escaped) {
|
|
42
|
-
value = escaped[1];
|
|
43
|
-
length = escaped[0].length;
|
|
44
|
-
}
|
|
45
|
-
else if (entity) {
|
|
46
|
-
const element = document.createElement('textarea');
|
|
47
|
-
element.innerHTML = entity[0];
|
|
48
|
-
value = element.value;
|
|
49
|
-
length = entity[0].length;
|
|
50
|
-
}
|
|
51
|
-
else if (raw.slice(index, index + 2) === '\r\n') {
|
|
52
|
-
value = '\n';
|
|
53
|
-
length = 2;
|
|
54
|
-
}
|
|
55
|
-
else {
|
|
56
|
-
value = raw[index];
|
|
57
|
-
length = 1;
|
|
58
|
-
}
|
|
59
|
-
decoded += value;
|
|
60
|
-
index += length;
|
|
61
|
-
for (let part = 0; part < value.length; part++)
|
|
62
|
-
offsets.push(index);
|
|
63
|
-
}
|
|
64
|
-
if (decoded !== before) {
|
|
65
|
-
// Continuation indentation is excluded from a quoted paragraph's text.
|
|
66
|
-
return quoteIndent ? after : retainText(raw, before, after, true, inTable);
|
|
67
|
-
}
|
|
68
|
-
const inserted = after
|
|
69
|
-
.slice(prefix, after.length - suffix)
|
|
70
|
-
.split('')
|
|
71
|
-
.map((character, index) => {
|
|
72
|
-
const at = prefix + index;
|
|
73
|
-
const delimiter = (character === '%' || character === '=') &&
|
|
74
|
-
(after[at - 1] === character || after[at + 1] === character);
|
|
75
|
-
return delimiter ||
|
|
76
|
-
/[\\`*_[\]<>]/.test(character) ||
|
|
77
|
-
(inTable && character === '|')
|
|
78
|
-
? `\\${character}`
|
|
79
|
-
: character;
|
|
80
|
-
})
|
|
81
|
-
.join('');
|
|
82
|
-
return (raw.slice(0, offsets[prefix]) +
|
|
83
|
-
inserted +
|
|
84
|
-
raw.slice(offsets[before.length - suffix]));
|
|
85
|
-
}
|
|
86
|
-
function retainBoundaryWhitespace(raw, canonical) {
|
|
87
|
-
// A middle-of-line space becomes indentation or trimmed text at a block edge.
|
|
88
|
-
const token = '(?:[ \\t]|&#(?:[xX]0*(?:20|9)|0*(?:32|9));|	)';
|
|
89
|
-
const decoded = (value) => (value.match(new RegExp(token, 'g')) ?? [])
|
|
90
|
-
.map((value) => value === '\t' || /(?:9;|Tab;)$/.test(value) ? '\t' : ' ')
|
|
91
|
-
.join('');
|
|
92
|
-
const encode = (value) => value.replace(/[ \t]/g, (value) => (value === '\t' ? '	' : ' '));
|
|
93
|
-
const start = /^[ \t]+/.exec(raw)?.[0];
|
|
94
|
-
const rawStart = new RegExp(`^${token}+`).exec(raw)?.[0];
|
|
95
|
-
const encodedStart = new RegExp(`^${token}+`).exec(canonical)?.[0];
|
|
96
|
-
if (start &&
|
|
97
|
-
rawStart &&
|
|
98
|
-
encodedStart?.includes('&') &&
|
|
99
|
-
decoded(rawStart) === decoded(encodedStart))
|
|
100
|
-
raw = encode(start) + raw.slice(start.length);
|
|
101
|
-
const end = /[ \t]+$/.exec(raw)?.[0];
|
|
102
|
-
const rawEnd = new RegExp(`${token}+$`).exec(raw)?.[0];
|
|
103
|
-
const encodedEnd = new RegExp(`${token}+$`).exec(canonical)?.[0];
|
|
104
|
-
if (end &&
|
|
105
|
-
rawEnd &&
|
|
106
|
-
encodedEnd?.includes('&') &&
|
|
107
|
-
decoded(rawEnd) === decoded(encodedEnd))
|
|
108
|
-
raw = raw.slice(0, -end.length) + encode(end);
|
|
109
|
-
return raw;
|
|
110
|
-
}
|
|
111
|
-
// Patch changed leaves so an adjacent edit retains authored delimiters and labels.
|
|
112
|
-
function retainSource(before, after, source, canonical, lineEnding, inTable = false) {
|
|
113
|
-
const start = before.position?.start.offset;
|
|
114
|
-
const end = before.position?.end.offset;
|
|
115
|
-
const newStart = after.position?.start.offset;
|
|
116
|
-
const newEnd = after.position?.end.offset;
|
|
117
|
-
if (start == null || end == null || newStart == null || newEnd == null)
|
|
118
|
-
throw new PreservationError('A source token has no location');
|
|
119
|
-
const raw = source.slice(start, end);
|
|
120
|
-
if (before.type === 'code' &&
|
|
121
|
-
after.type === 'code' &&
|
|
122
|
-
before.lang === after.lang) {
|
|
123
|
-
const opening = /^([ \t>]*)(`{3,}|~{3,})([^\r\n]*)(\r?\n)/.exec(raw);
|
|
124
|
-
if (opening) {
|
|
125
|
-
const lines = raw.slice(opening[0].length).split(/\r?\n/);
|
|
126
|
-
const closing = /^([ \t>]*)(`{3,}|~{3,})([ \t]*)$/.exec(lines.at(-1) ?? '');
|
|
127
|
-
const originalLine = (before.value ?? '').replace(/\r\n?/g, '\n').split('\n')[0] ?? '';
|
|
128
|
-
const first = lines[0] ?? '';
|
|
129
|
-
const gutter = first.endsWith(originalLine)
|
|
130
|
-
? first.slice(0, first.length - originalLine.length)
|
|
131
|
-
: opening[1];
|
|
132
|
-
const body = (after.value ?? '').replace(/\r\n?/g, '\n').split('\n');
|
|
133
|
-
const character = opening[2][0];
|
|
134
|
-
let length = opening[2].length;
|
|
135
|
-
for (const line of body) {
|
|
136
|
-
const run = new RegExp(`^ {0,3}(${character}+)[ \\t]*$`).exec(line);
|
|
137
|
-
if (run)
|
|
138
|
-
length = Math.max(length, run[1].length + 1);
|
|
139
|
-
}
|
|
140
|
-
const fence = character.repeat(length);
|
|
141
|
-
return (opening[1] +
|
|
142
|
-
fence +
|
|
143
|
-
opening[3] +
|
|
144
|
-
lineEnding +
|
|
145
|
-
body.map((line) => gutter + line).join(lineEnding) +
|
|
146
|
-
(closing
|
|
147
|
-
? lineEnding +
|
|
148
|
-
closing[1] +
|
|
149
|
-
character.repeat(Math.max(length, closing[2].length)) +
|
|
150
|
-
closing[3]
|
|
151
|
-
: ''));
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
if (before.type === 'definition' &&
|
|
155
|
-
after.type === 'definition' &&
|
|
156
|
-
before.label === after.label &&
|
|
157
|
-
before.title === after.title) {
|
|
158
|
-
const destination = /^(\[[\s\S]*?\]:[ \t]*)(<[^>\n]*>|[^\s]+)([\s\S]*)$/.exec(raw);
|
|
159
|
-
if (destination)
|
|
160
|
-
return (destination[1] +
|
|
161
|
-
(destination[2].startsWith('<')
|
|
162
|
-
? `<${after.url}>`
|
|
163
|
-
: String(after.url)) +
|
|
164
|
-
destination[3]);
|
|
165
|
-
}
|
|
166
|
-
if (before.type === 'text' && after.type === 'text')
|
|
167
|
-
return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
|
|
168
|
-
if (signature(before) === signature(after) &&
|
|
169
|
-
ownShape(before) === ownShape(after))
|
|
170
|
-
return raw;
|
|
171
|
-
if (before.type === after.type &&
|
|
172
|
-
ownShape(before) === ownShape(after) &&
|
|
173
|
-
before.children &&
|
|
174
|
-
after.children &&
|
|
175
|
-
before.children.length === after.children.length) {
|
|
176
|
-
let output = '', previous = start;
|
|
177
|
-
const retained = new Set();
|
|
178
|
-
before.children.forEach((child, index) => {
|
|
179
|
-
const incoming = after.children[index];
|
|
180
|
-
const movedIndex = before.type === 'table' || before.type === 'tableRow'
|
|
181
|
-
? before.children.findIndex((candidate, candidateIndex) => !retained.has(candidateIndex) &&
|
|
182
|
-
signature(candidate) === signature(incoming) &&
|
|
183
|
-
ownShape(candidate) === ownShape(incoming))
|
|
184
|
-
: -1;
|
|
185
|
-
const original = movedIndex < 0 ? child : before.children[movedIndex];
|
|
186
|
-
if (movedIndex >= 0)
|
|
187
|
-
retained.add(movedIndex);
|
|
188
|
-
const childStart = child.position?.start.offset, childEnd = child.position?.end.offset;
|
|
189
|
-
if (childStart == null || childEnd == null)
|
|
190
|
-
throw new PreservationError('A source token has no location');
|
|
191
|
-
let retainedSource = retainSource(original, incoming, source, canonical, lineEnding, inTable || before.type === 'tableCell');
|
|
192
|
-
if (before.type === 'tableRow' && movedIndex >= 0) {
|
|
193
|
-
// Cell positions own different pipe boundaries at the row edges.
|
|
194
|
-
const originalFrom = original.children?.[0]?.position?.start.offset;
|
|
195
|
-
const originalTo = original.children?.at(-1)?.position?.end.offset;
|
|
196
|
-
const from = child.children?.[0]?.position?.start.offset;
|
|
197
|
-
const to = child.children?.at(-1)?.position?.end.offset;
|
|
198
|
-
retainedSource =
|
|
199
|
-
originalFrom != null &&
|
|
200
|
-
originalTo != null &&
|
|
201
|
-
from != null &&
|
|
202
|
-
to != null
|
|
203
|
-
? source.slice(childStart, from) +
|
|
204
|
-
source.slice(originalFrom, originalTo) +
|
|
205
|
-
source.slice(to, childEnd)
|
|
206
|
-
: withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
|
|
207
|
-
}
|
|
208
|
-
output += source.slice(previous, childStart) + retainedSource;
|
|
209
|
-
previous = childEnd;
|
|
210
|
-
});
|
|
211
|
-
return output + source.slice(previous, end);
|
|
212
|
-
}
|
|
213
|
-
if (before.type === after.type &&
|
|
214
|
-
ownShape(before) === ownShape(after) &&
|
|
215
|
-
before.children &&
|
|
216
|
-
after.children) {
|
|
217
|
-
let prefix = 0, suffix = 0;
|
|
218
|
-
while (prefix < before.children.length &&
|
|
219
|
-
prefix < after.children.length &&
|
|
220
|
-
signature(before.children[prefix]) === signature(after.children[prefix]))
|
|
221
|
-
prefix++;
|
|
222
|
-
while (suffix < before.children.length - prefix &&
|
|
223
|
-
suffix < after.children.length - prefix &&
|
|
224
|
-
signature(before.children[before.children.length - 1 - suffix]) ===
|
|
225
|
-
signature(after.children[after.children.length - 1 - suffix]))
|
|
226
|
-
suffix++;
|
|
227
|
-
const from = prefix
|
|
228
|
-
? before.children[prefix - 1].position?.end.offset
|
|
229
|
-
: start;
|
|
230
|
-
const to = suffix
|
|
231
|
-
? before.children[before.children.length - suffix].position?.start.offset
|
|
232
|
-
: end;
|
|
233
|
-
const newFrom = prefix
|
|
234
|
-
? after.children[prefix - 1].position?.end.offset
|
|
235
|
-
: newStart;
|
|
236
|
-
const newTo = suffix
|
|
237
|
-
? after.children[after.children.length - suffix].position?.start.offset
|
|
238
|
-
: newEnd;
|
|
239
|
-
if (from != null && to != null && newFrom != null && newTo != null)
|
|
240
|
-
return retainBoundaryWhitespace(source.slice(start, from) +
|
|
241
|
-
withEnding(canonical.slice(newFrom, newTo), lineEnding) +
|
|
242
|
-
source.slice(to, end), canonical.slice(newStart, newEnd));
|
|
243
|
-
}
|
|
244
|
-
return withEnding(canonical.slice(newStart, newEnd), lineEnding);
|
|
245
|
-
}
|
|
246
|
-
// Canonical Markdown keeps authored CRLF inside literal blocks and code fences.
|
|
247
|
-
function withEnding(text, lineEnding) {
|
|
248
|
-
return lineEnding === '\n' ? text : text.replace(/\r?\n/g, lineEnding);
|
|
249
|
-
}
|
|
250
|
-
function positionalNode(tree, transformed) {
|
|
251
|
-
return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
|
|
252
|
-
node.position?.end.offset === transformed.position?.end.offset) ?? transformed);
|
|
253
|
-
}
|
|
254
|
-
export class PreservationError extends Error {
|
|
255
|
-
constructor(message, options) {
|
|
256
|
-
super(message, options);
|
|
257
|
-
this.name = 'PreservationError';
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
function form(text) {
|
|
261
|
-
return text.replace(/\r\n?/g, '\n').replace(/\n+$/, '');
|
|
262
|
-
}
|
|
263
|
-
function blockForm(ctx, node) {
|
|
264
|
-
// An empty document is empty Markdown, but an empty block inside a document
|
|
265
|
-
// is authored spacing and must remain a real block when read again.
|
|
266
|
-
if (node.type.name === 'paragraph' && node.content.size === 0)
|
|
267
|
-
return '<br />';
|
|
268
|
-
return form(serialize(ctx, node.type.schema.topNodeType.create(null, [node])));
|
|
269
|
-
}
|
|
270
|
-
function semanticSignature(doc) {
|
|
271
|
-
const clean = (value) => {
|
|
272
|
-
if (value.type === 'inkkit_wiki_link' && value.attrs) {
|
|
273
|
-
const { raw: _raw, inTable: _inTable, ...attrs } = value.attrs;
|
|
274
|
-
value = { ...value, attrs };
|
|
275
|
-
}
|
|
276
|
-
const content = value.content;
|
|
277
|
-
if (!content)
|
|
278
|
-
return value;
|
|
279
|
-
const tokens = [];
|
|
280
|
-
for (const child of content) {
|
|
281
|
-
if (child.type !== 'text') {
|
|
282
|
-
tokens.push(clean(child));
|
|
283
|
-
continue;
|
|
284
|
-
}
|
|
285
|
-
const parts = /^(\s*)([\s\S]*?)(\s*)$/.exec(String(child.text));
|
|
286
|
-
for (const [index, text] of parts.slice(1).entries()) {
|
|
287
|
-
if (!text)
|
|
288
|
-
continue;
|
|
289
|
-
const token = {
|
|
290
|
-
...child,
|
|
291
|
-
text: text.replace(/\r\n?/g, '\n'),
|
|
292
|
-
marks: index === 1 ? child.marks : undefined,
|
|
293
|
-
};
|
|
294
|
-
const previous = tokens.at(-1);
|
|
295
|
-
if (previous?.type === 'text' &&
|
|
296
|
-
JSON.stringify(previous.marks) === JSON.stringify(token.marks))
|
|
297
|
-
previous.text = String(previous.text) + text;
|
|
298
|
-
else
|
|
299
|
-
tokens.push(token);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
return { ...value, content: tokens };
|
|
303
|
-
};
|
|
304
|
-
const json = JSON.parse(JSON.stringify(doc.toJSON(), (key, value) =>
|
|
305
|
-
// Fresh list items default to loose even when their Markdown reopens tight.
|
|
306
|
-
[
|
|
307
|
-
'referenceContent',
|
|
308
|
-
'referenceType',
|
|
309
|
-
'listPlaceholder',
|
|
310
|
-
'marker',
|
|
311
|
-
'id',
|
|
312
|
-
'label',
|
|
313
|
-
'spread',
|
|
314
|
-
'authoredFence',
|
|
315
|
-
'inkkitFileRaw',
|
|
316
|
-
'inkkitFileWidth',
|
|
317
|
-
'inkkitFileInTable',
|
|
318
|
-
].includes(key)
|
|
319
|
-
? undefined
|
|
320
|
-
: value));
|
|
321
|
-
return JSON.stringify(clean(json));
|
|
322
|
-
}
|
|
323
|
-
// Milkdown derives heading DOM IDs; Markdown stores the level and content.
|
|
324
|
-
function withoutHeadingId(node) {
|
|
325
|
-
return node.type.name === 'heading' && node.attrs.id
|
|
326
|
-
? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
|
|
327
|
-
: node;
|
|
328
|
-
}
|
|
329
|
-
const MATCHING_BUDGET = 1_000_000;
|
|
330
|
-
const SIMILARITY_BUDGET = 4_000_000;
|
|
331
|
-
// Unchanged blocks are anchored first, so a long document with many scattered
|
|
332
|
-
// edits is matched as many short gaps rather than one wide span.
|
|
333
|
-
export function correspondence(before, after, resemblance = true) {
|
|
334
|
-
const result = new Array(after.length);
|
|
335
|
-
const work = {
|
|
336
|
-
budget: MATCHING_BUDGET,
|
|
337
|
-
similarity: resemblance ? SIMILARITY_BUDGET : 0,
|
|
338
|
-
};
|
|
339
|
-
const spans = [{ b: 0, a: 0, bEnd: before.length, aEnd: after.length }];
|
|
340
|
-
for (let span = spans.pop(); span; span = spans.pop()) {
|
|
341
|
-
let { b, a, bEnd, aEnd } = span;
|
|
342
|
-
while (b < bEnd && a < aEnd && before[b] === after[a])
|
|
343
|
-
result[a++] = b++;
|
|
344
|
-
while (b < bEnd && a < aEnd && before[bEnd - 1] === after[aEnd - 1])
|
|
345
|
-
result[--aEnd] = --bEnd;
|
|
346
|
-
if (b === bEnd || a === aEnd)
|
|
347
|
-
continue;
|
|
348
|
-
work.budget -= bEnd - b + (aEnd - a);
|
|
349
|
-
if (work.budget < 0)
|
|
350
|
-
throw tooManyBlocks();
|
|
351
|
-
const anchors = uniqueAnchors(before, after, b, bEnd, a, aEnd);
|
|
352
|
-
if (!anchors.length) {
|
|
353
|
-
align(before, after, b, bEnd, a, aEnd, result, work);
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
for (const anchor of anchors) {
|
|
357
|
-
result[anchor.a] = anchor.b;
|
|
358
|
-
spans.push({ b, a, bEnd: anchor.b, aEnd: anchor.a });
|
|
359
|
-
b = anchor.b + 1;
|
|
360
|
-
a = anchor.a + 1;
|
|
361
|
-
}
|
|
362
|
-
spans.push({ b, a, bEnd, aEnd });
|
|
363
|
-
}
|
|
364
|
-
return result;
|
|
365
|
-
}
|
|
366
|
-
function tooManyBlocks() {
|
|
367
|
-
return new PreservationError('The edit changes too many blocks to preserve safely');
|
|
368
|
-
}
|
|
369
|
-
// Blocks that occur exactly once on each side, in the longest subsequence whose
|
|
370
|
-
// order the two sides share. A repeated block is never an anchor: which copy it
|
|
371
|
-
// belongs to is not known until its neighbours are placed.
|
|
372
|
-
function uniqueAnchors(before, after, b, bEnd, a, aEnd) {
|
|
373
|
-
const once = (list, from, to) => {
|
|
374
|
-
const at = new Map();
|
|
375
|
-
for (let i = from; i < to; i++)
|
|
376
|
-
at.set(list[i], at.has(list[i]) ? -1 : i);
|
|
377
|
-
return at;
|
|
378
|
-
};
|
|
379
|
-
const beforeAt = once(before, b, bEnd);
|
|
380
|
-
const afterAt = once(after, a, aEnd);
|
|
381
|
-
const pairs = [];
|
|
382
|
-
for (let i = a; i < aEnd; i++) {
|
|
383
|
-
const match = beforeAt.get(after[i]);
|
|
384
|
-
if (match != null && match >= 0 && afterAt.get(after[i]) === i)
|
|
385
|
-
pairs.push({ b: match, a: i });
|
|
386
|
-
}
|
|
387
|
-
// Patience sorting: tails[k] ends the best increasing subsequence of length k + 1.
|
|
388
|
-
const tails = [];
|
|
389
|
-
const previous = new Array(pairs.length);
|
|
390
|
-
for (const [index, pair] of pairs.entries()) {
|
|
391
|
-
let low = 0;
|
|
392
|
-
let high = tails.length;
|
|
393
|
-
while (low < high) {
|
|
394
|
-
const middle = (low + high) >> 1;
|
|
395
|
-
if (pairs[tails[middle]].b < pair.b)
|
|
396
|
-
low = middle + 1;
|
|
397
|
-
else
|
|
398
|
-
high = middle;
|
|
399
|
-
}
|
|
400
|
-
previous[index] = low ? tails[low - 1] : -1;
|
|
401
|
-
tails[low] = index;
|
|
402
|
-
}
|
|
403
|
-
const anchors = [];
|
|
404
|
-
let at = tails.length ? tails[tails.length - 1] : -1;
|
|
405
|
-
while (at >= 0) {
|
|
406
|
-
anchors.push(pairs[at]);
|
|
407
|
-
at = previous[at];
|
|
408
|
-
}
|
|
409
|
-
return anchors.reverse();
|
|
410
|
-
}
|
|
411
|
-
// Replacing a block costs more than inserting or deleting one, so an unchanged
|
|
412
|
-
// block keeps its own copy rather than pairing with a deleted neighbour, and
|
|
413
|
-
// less than both, so an edited block still keeps its surrounding whitespace.
|
|
414
|
-
const INDEL = 2;
|
|
415
|
-
const REPLACE = 3;
|
|
416
|
-
function align(before, after, b, bEnd, a, aEnd, result, work) {
|
|
417
|
-
const rows = bEnd - b + 1;
|
|
418
|
-
const columns = aEnd - a + 1;
|
|
419
|
-
const offset = Math.abs(rows - columns);
|
|
420
|
-
let width = Math.max(1, offset);
|
|
421
|
-
for (;;) {
|
|
422
|
-
const costs = [];
|
|
423
|
-
const get = (i, j) => i < 0 || j < 0 || Math.abs(i - j) > width
|
|
424
|
-
? Infinity
|
|
425
|
-
: (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
|
|
426
|
-
for (let i = 0; i < rows; i++) {
|
|
427
|
-
const from = Math.max(0, i - width);
|
|
428
|
-
const to = Math.min(columns - 1, i + width);
|
|
429
|
-
work.budget -= to - from + 1;
|
|
430
|
-
if (work.budget < 0)
|
|
431
|
-
throw tooManyBlocks();
|
|
432
|
-
const row = new Uint32Array(to - from + 1);
|
|
433
|
-
costs.push(row);
|
|
434
|
-
for (let j = from; j <= to; j++) {
|
|
435
|
-
const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
|
|
436
|
-
row[j - from] =
|
|
437
|
-
i === 0
|
|
438
|
-
? j * INDEL
|
|
439
|
-
: j === 0
|
|
440
|
-
? i * INDEL
|
|
441
|
-
: Math.min(get(i - 1, j) + INDEL, get(i, j - 1) + INDEL, get(i - 1, j - 1) + change);
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
// A path that leaves the band takes at least width + 1 insertions or
|
|
445
|
-
// deletions away from the diagonal and width + 1 - offset back to its end,
|
|
446
|
-
// so a path inside the band that costs no more is optimal. Widen only when
|
|
447
|
-
// needed, charging every attempt to the same work budget.
|
|
448
|
-
if (width < Math.max(rows, columns) - 1 &&
|
|
449
|
-
get(rows - 1, columns - 1) > INDEL * (2 * (width + 1) - offset)) {
|
|
450
|
-
width *= 2;
|
|
451
|
-
continue;
|
|
452
|
-
}
|
|
453
|
-
const unchanged = [];
|
|
454
|
-
let i = rows - 1;
|
|
455
|
-
let j = columns - 1;
|
|
456
|
-
while (i || j) {
|
|
457
|
-
const same = i && j && before[b + i - 1] === after[a + j - 1];
|
|
458
|
-
if (i && j && get(i, j) === get(i - 1, j - 1) + (same ? 0 : REPLACE)) {
|
|
459
|
-
result[a + j - 1] = b + i - 1;
|
|
460
|
-
if (same)
|
|
461
|
-
unchanged.push({ b: b + i - 1, a: a + j - 1 });
|
|
462
|
-
i--;
|
|
463
|
-
j--;
|
|
464
|
-
}
|
|
465
|
-
else if (i && get(i, j) === get(i - 1, j) + INDEL)
|
|
466
|
-
i--;
|
|
467
|
-
else
|
|
468
|
-
j--;
|
|
469
|
-
}
|
|
470
|
-
unchanged.push({ b: b - 1, a: a - 1 });
|
|
471
|
-
unchanged.reverse().push({ b: bEnd, a: aEnd });
|
|
472
|
-
for (let index = 1; index < unchanged.length; index++)
|
|
473
|
-
pairSimilar(before, after, unchanged[index - 1].b + 1, unchanged[index].b, unchanged[index - 1].a + 1, unchanged[index].a, result, work);
|
|
474
|
-
return;
|
|
475
|
-
}
|
|
476
|
-
}
|
|
477
|
-
// Between unchanged blocks, edit distance prices every replacement alike, so
|
|
478
|
-
// it cannot tell which original an edited block came from. Pair each with the
|
|
479
|
-
// original it most resembles, keeping their order: typing in a block then keeps
|
|
480
|
-
// its own source even when a neighbour was inserted or deleted beside it.
|
|
481
|
-
function pairSimilar(before, after, b, bEnd, a, aEnd, result, work) {
|
|
482
|
-
const removed = bEnd - b;
|
|
483
|
-
const added = aEnd - a;
|
|
484
|
-
if (!removed || !added || (removed === 1 && added === 1))
|
|
485
|
-
return;
|
|
486
|
-
// Resemblance decides; among equally resembling pairings, more pairs keep
|
|
487
|
-
// more replaced blocks in place with their surrounding whitespace.
|
|
488
|
-
const bonus = 1 / (Math.min(removed, added) + 1);
|
|
489
|
-
// This is a refinement with its own budget of characters compared. When it
|
|
490
|
-
// runs out, the alignment's pairing stands rather than the edit failing.
|
|
491
|
-
if (removed * added > work.similarity)
|
|
492
|
-
return;
|
|
493
|
-
const weights = new Float64Array(removed * added);
|
|
494
|
-
for (let x = 0; x < removed; x++)
|
|
495
|
-
for (let y = 0; y < added; y++) {
|
|
496
|
-
const shared = similarity(before[b + x], after[a + y]);
|
|
497
|
-
work.similarity -= shared + 1;
|
|
498
|
-
if (work.similarity < 0)
|
|
499
|
-
return;
|
|
500
|
-
weights[x * added + y] = shared + bonus;
|
|
501
|
-
}
|
|
502
|
-
const best = Array.from({ length: removed + 1 }, () => new Float64Array(added + 1));
|
|
503
|
-
for (let x = 1; x <= removed; x++)
|
|
504
|
-
for (let y = 1; y <= added; y++)
|
|
505
|
-
best[x][y] = Math.max(best[x - 1][y], best[x][y - 1], best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]);
|
|
506
|
-
for (let index = a; index < aEnd; index++)
|
|
507
|
-
result[index] = undefined;
|
|
508
|
-
for (let x = removed, y = added; x && y;) {
|
|
509
|
-
if (best[x][y] ===
|
|
510
|
-
best[x - 1][y - 1] + weights[(x - 1) * added + y - 1]) {
|
|
511
|
-
result[a + --y] = b + --x;
|
|
512
|
-
}
|
|
513
|
-
else if (best[x][y] === best[x - 1][y])
|
|
514
|
-
x--;
|
|
515
|
-
else
|
|
516
|
-
y--;
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
// Characters an edit left in place at the start and end of a block.
|
|
520
|
-
function similarity(left, right) {
|
|
521
|
-
const limit = Math.min(left.length, right.length);
|
|
522
|
-
let prefix = 0;
|
|
523
|
-
while (prefix < limit && left[prefix] === right[prefix])
|
|
524
|
-
prefix++;
|
|
525
|
-
let suffix = 0;
|
|
526
|
-
while (suffix < limit - prefix &&
|
|
527
|
-
left[left.length - 1 - suffix] === right[right.length - 1 - suffix])
|
|
528
|
-
suffix++;
|
|
529
|
-
return prefix + suffix;
|
|
530
|
-
}
|
|
531
|
-
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
532
|
-
export class Preservation {
|
|
533
|
-
ctx;
|
|
534
|
-
source;
|
|
535
|
-
baseline;
|
|
536
|
-
blocks = [];
|
|
537
|
-
lineEnding;
|
|
538
|
-
originalForms = new Set();
|
|
539
|
-
forms = new WeakMap();
|
|
540
|
-
textBlockEdits = new WeakMap();
|
|
541
|
-
constructor(ctx, source) {
|
|
542
|
-
this.ctx = ctx;
|
|
543
|
-
this.source = source;
|
|
544
|
-
try {
|
|
545
|
-
const parse = ctx.get(parserCtx);
|
|
546
|
-
this.baseline = parse(source);
|
|
547
|
-
this.lineEnding =
|
|
548
|
-
source.includes('\r\n') && !source.replaceAll('\r\n', '').includes('\n')
|
|
549
|
-
? '\r\n'
|
|
550
|
-
: '\n';
|
|
551
|
-
const processor = ctx.get(remarkCtx);
|
|
552
|
-
const offset = source.startsWith('\uFEFF') ? 1 : 0;
|
|
553
|
-
const body = source.slice(offset);
|
|
554
|
-
const rawTree = processor.parse(source);
|
|
555
|
-
const tree = processor.runSync(structuredClone(rawTree), {
|
|
556
|
-
value: body,
|
|
557
|
-
});
|
|
558
|
-
for (const [index, node] of tree.children.entries()) {
|
|
559
|
-
let start = node.position?.start.offset;
|
|
560
|
-
const end = node.position?.end.offset;
|
|
561
|
-
if (start == null || end == null)
|
|
562
|
-
throw new PreservationError('A source block has no location');
|
|
563
|
-
start += offset;
|
|
564
|
-
const sourceEnd = end + offset;
|
|
565
|
-
const firstColumn = source.lastIndexOf('\n', start - 1) + 1;
|
|
566
|
-
if (/^[ \t]*$/.test(source.slice(firstColumn, start)))
|
|
567
|
-
start = firstColumn;
|
|
568
|
-
const text = source.slice(start, sourceEnd);
|
|
569
|
-
const parsed = this.baseline.childCount === tree.children.length
|
|
570
|
-
? this.baseline.type.create(null, [this.baseline.child(index)])
|
|
571
|
-
: parse(text);
|
|
572
|
-
this.blocks.push({
|
|
573
|
-
start,
|
|
574
|
-
end: sourceEnd,
|
|
575
|
-
form: parsed.childCount === 1
|
|
576
|
-
? blockForm(ctx, parsed.firstChild)
|
|
577
|
-
: form(serialize(ctx, parsed)),
|
|
578
|
-
ast: positionalNode(rawTree, node),
|
|
579
|
-
});
|
|
580
|
-
}
|
|
581
|
-
for (const block of this.blocks)
|
|
582
|
-
this.originalForms.add(block.form);
|
|
583
|
-
}
|
|
584
|
-
catch (cause) {
|
|
585
|
-
if (cause instanceof PreservationError)
|
|
586
|
-
throw cause;
|
|
587
|
-
throw new PreservationError('Cannot read Markdown for preservation', {
|
|
588
|
-
cause,
|
|
589
|
-
});
|
|
590
|
-
}
|
|
591
|
-
}
|
|
592
|
-
retainBlock(block, after, canonical) {
|
|
593
|
-
const offset = this.source.startsWith('\uFEFF') ? 1 : 0;
|
|
594
|
-
const start = block.ast.position?.start.offset;
|
|
595
|
-
if (start == null)
|
|
596
|
-
throw new PreservationError('A source block has no location');
|
|
597
|
-
return (this.source.slice(block.start, start + offset) +
|
|
598
|
-
retainSource(block.ast, after, this.source.slice(offset), canonical, this.lineEnding));
|
|
599
|
-
}
|
|
600
|
-
independentTextBlocks(doc) {
|
|
601
|
-
if (this.baseline.childCount !== this.blocks.length ||
|
|
602
|
-
doc.childCount !== this.blocks.length ||
|
|
603
|
-
!doc.sameMarkup(this.baseline))
|
|
604
|
-
return undefined;
|
|
605
|
-
let output = '', previous = 0;
|
|
606
|
-
for (let index = 0; index < doc.childCount; index++) {
|
|
607
|
-
const node = doc.child(index);
|
|
608
|
-
const before = this.baseline.child(index);
|
|
609
|
-
const block = this.blocks[index];
|
|
610
|
-
if (node.eq(before))
|
|
611
|
-
continue;
|
|
612
|
-
let retained = this.textBlockEdits.get(node);
|
|
613
|
-
if (retained?.index !== index) {
|
|
614
|
-
const raw = this.source.slice(block.start, block.end);
|
|
615
|
-
const expected = withoutHeadingId(node);
|
|
616
|
-
// References and HTML/comments can depend on other blocks. Restrict
|
|
617
|
-
// local validation to text blocks without these context-sensitive forms.
|
|
618
|
-
const contextual = /[\[<]|%%/;
|
|
619
|
-
const textOnly = (block) => {
|
|
620
|
-
let valid = true;
|
|
621
|
-
block.forEach((child) => {
|
|
622
|
-
if (!child.isText)
|
|
623
|
-
valid = false;
|
|
624
|
-
});
|
|
625
|
-
return valid;
|
|
626
|
-
};
|
|
627
|
-
if (!['paragraph', 'heading'].includes(node.type.name) ||
|
|
628
|
-
!expected.sameMarkup(withoutHeadingId(before)) ||
|
|
629
|
-
!textOnly(node) ||
|
|
630
|
-
!textOnly(before) ||
|
|
631
|
-
contextual.test(raw) ||
|
|
632
|
-
(index > 0 &&
|
|
633
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(this.blocks[index - 1].end, block.start))) ||
|
|
634
|
-
(index + 1 < this.blocks.length &&
|
|
635
|
-
!/\r?\n[ \t]*\r?\n/.test(this.source.slice(block.end, this.blocks[index + 1].start))))
|
|
636
|
-
return undefined;
|
|
637
|
-
const canonical = blockForm(this.ctx, node);
|
|
638
|
-
if (contextual.test(canonical) ||
|
|
639
|
-
(canonical !== block.form && this.originalForms.has(canonical)))
|
|
640
|
-
return undefined;
|
|
641
|
-
const tree = this.ctx.get(remarkCtx).parse(canonical);
|
|
642
|
-
if (tree.children.length !== 1)
|
|
643
|
-
return undefined;
|
|
644
|
-
const text = this.retainBlock(block, tree.children[0], canonical);
|
|
645
|
-
const reopened = this.ctx.get(parserCtx)(text);
|
|
646
|
-
if (reopened.childCount !== 1)
|
|
647
|
-
return undefined;
|
|
648
|
-
if (!withoutHeadingId(reopened.firstChild).eq(expected))
|
|
649
|
-
return undefined;
|
|
650
|
-
retained = { index, text };
|
|
651
|
-
this.textBlockEdits.set(node, retained);
|
|
652
|
-
}
|
|
653
|
-
output += this.source.slice(previous, block.start) + retained.text;
|
|
654
|
-
previous = block.end;
|
|
655
|
-
}
|
|
656
|
-
return output + this.source.slice(previous);
|
|
657
|
-
}
|
|
658
|
-
serialize(doc) {
|
|
659
|
-
if (doc.eq(this.baseline))
|
|
660
|
-
return this.source;
|
|
661
|
-
try {
|
|
662
|
-
const independent = this.independentTextBlocks(doc);
|
|
663
|
-
if (independent !== undefined)
|
|
664
|
-
return independent;
|
|
665
|
-
const canonical = serialize(this.ctx, doc);
|
|
666
|
-
if (canonical === '')
|
|
667
|
-
return this.source.startsWith('\uFEFF') ? '\uFEFF' : '';
|
|
668
|
-
const current = [];
|
|
669
|
-
doc.forEach((node) => {
|
|
670
|
-
let fingerprint = this.forms.get(node);
|
|
671
|
-
if (fingerprint == null) {
|
|
672
|
-
fingerprint = blockForm(this.ctx, node);
|
|
673
|
-
this.forms.set(node, fingerprint);
|
|
674
|
-
}
|
|
675
|
-
current.push(fingerprint);
|
|
676
|
-
});
|
|
677
|
-
const forms = this.blocks.map((block) => block.form);
|
|
678
|
-
const resembling = correspondence(forms, current);
|
|
679
|
-
const processor = this.ctx.get(remarkCtx);
|
|
680
|
-
const currentRawTree = processor.parse(canonical);
|
|
681
|
-
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
682
|
-
value: canonical,
|
|
683
|
-
});
|
|
684
|
-
const parse = this.ctx.get(parserCtx);
|
|
685
|
-
const normalized = (text) => text.replace(/\r\n?/g, '\n');
|
|
686
|
-
const reconstruct = (positions) => {
|
|
687
|
-
let output = '';
|
|
688
|
-
let previous;
|
|
689
|
-
current.forEach((text, index) => {
|
|
690
|
-
const position = positions[index];
|
|
691
|
-
const block = position == null ? undefined : this.blocks[position];
|
|
692
|
-
if (index === 0) {
|
|
693
|
-
if (position === 0 && block)
|
|
694
|
-
output += this.source.slice(0, block.start);
|
|
695
|
-
else if (this.source.startsWith('\uFEFF'))
|
|
696
|
-
output += '\uFEFF';
|
|
697
|
-
}
|
|
698
|
-
else if (previous != null && position === previous + 1 && block) {
|
|
699
|
-
output += this.source.slice(this.blocks[previous].end, block.start);
|
|
700
|
-
}
|
|
701
|
-
else
|
|
702
|
-
output += this.lineEnding.repeat(2);
|
|
703
|
-
if (block && block.form === text)
|
|
704
|
-
output += this.source.slice(block.start, block.end);
|
|
705
|
-
else if (block && currentTree.children.length === current.length) {
|
|
706
|
-
output += this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical);
|
|
707
|
-
}
|
|
708
|
-
else
|
|
709
|
-
output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), withEnding(text, this.lineEnding));
|
|
710
|
-
previous = position;
|
|
711
|
-
});
|
|
712
|
-
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
713
|
-
output += this.source.slice(this.blocks[previous].end);
|
|
714
|
-
else if (output)
|
|
715
|
-
output += finalCodeEnding(doc) ?? this.lineEnding;
|
|
716
|
-
// A `---` rule that opens the file reads back as front matter when
|
|
717
|
-
// another `---` line follows, whether it is new or moved there.
|
|
718
|
-
const bom = output.startsWith('\uFEFF') ? 1 : 0;
|
|
719
|
-
if (doc.firstChild?.type.name === 'hr' &&
|
|
720
|
-
sourceFrontmatter(output.slice(bom))?.startsWith('---'))
|
|
721
|
-
output = output.slice(0, bom) + '***' + output.slice(bom + 3);
|
|
722
|
-
const reopened = parse(output);
|
|
723
|
-
if (semanticSignature(reopened) === semanticSignature(doc)) {
|
|
724
|
-
const actual = normalized(serialize(this.ctx, reopened));
|
|
725
|
-
if (actual === normalized(canonical))
|
|
726
|
-
return output;
|
|
727
|
-
// Parsing may normalize loose-list spacing. Compare both reconstructed
|
|
728
|
-
// source and intended canonical source after that same normalization.
|
|
729
|
-
if (actual === normalized(serialize(this.ctx, parse(canonical))))
|
|
730
|
-
return output;
|
|
731
|
-
}
|
|
732
|
-
return undefined;
|
|
733
|
-
};
|
|
734
|
-
// Pairing by resemblance can leave a block in canonical form beside an
|
|
735
|
-
// authored neighbour it then merges with, such as two lists. The
|
|
736
|
-
// alignment's own pairing does not depend on resemblance, so try it too.
|
|
737
|
-
const output = reconstruct(resembling) ??
|
|
738
|
-
(() => {
|
|
739
|
-
const aligned = correspondence(forms, current, false);
|
|
740
|
-
// Unmatched blocks are holes, which every() would skip.
|
|
741
|
-
return current.every((_, index) => aligned[index] === resembling[index])
|
|
742
|
-
? undefined
|
|
743
|
-
: reconstruct(aligned);
|
|
744
|
-
})();
|
|
745
|
-
if (output !== undefined)
|
|
746
|
-
return output;
|
|
747
|
-
throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
|
|
748
|
-
}
|
|
749
|
-
catch (cause) {
|
|
750
|
-
if (cause instanceof PreservationError)
|
|
751
|
-
throw cause;
|
|
752
|
-
throw new PreservationError('Cannot preserve the edited Markdown', {
|
|
753
|
-
cause,
|
|
754
|
-
});
|
|
755
|
-
}
|
|
756
|
-
}
|
|
757
|
-
}
|