@aicayzer/inkkit 0.0.15 → 0.0.17
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 +0 -4
- package/README.md +27 -24
- package/dist/dialect.js +10 -0
- package/dist/highlight.js +109 -22
- package/dist/mermaid.js +108 -6
- package/dist/paste-html.d.ts +23 -0
- package/dist/paste-html.js +441 -0
- package/dist/paste.js +31 -49
- package/dist/preserve.d.ts +1 -0
- package/dist/preserve.js +242 -83
- package/package.json +1 -1
package/dist/preserve.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parserCtx, remarkCtx } from '@milkdown/kit/core';
|
|
2
2
|
import { serialize } from './dialect.js';
|
|
3
3
|
import { restoreCodeEndings, finalCodeEnding } from './code-fences.js';
|
|
4
|
+
import { sourceFrontmatter } from './literals.js';
|
|
4
5
|
function signature(node) {
|
|
5
6
|
return JSON.stringify(node, (key, value) => ['position', 'data', 'marker'].includes(key) ? undefined : value);
|
|
6
7
|
}
|
|
@@ -163,7 +164,7 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
163
164
|
destination[3]);
|
|
164
165
|
}
|
|
165
166
|
if (before.type === 'text' && after.type === 'text')
|
|
166
|
-
return retainBoundaryWhitespace(retainText(raw, before.value, after.value, false, inTable), canonical.slice(newStart, newEnd));
|
|
167
|
+
return retainBoundaryWhitespace(withEnding(retainText(raw, before.value, after.value, false, inTable), lineEnding), canonical.slice(newStart, newEnd));
|
|
167
168
|
if (signature(before) === signature(after) &&
|
|
168
169
|
ownShape(before) === ownShape(after))
|
|
169
170
|
return raw;
|
|
@@ -202,9 +203,7 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
202
203
|
? source.slice(childStart, from) +
|
|
203
204
|
source.slice(originalFrom, originalTo) +
|
|
204
205
|
source.slice(to, childEnd)
|
|
205
|
-
: canonical
|
|
206
|
-
.slice(incoming.position.start.offset, incoming.position.end.offset)
|
|
207
|
-
.replaceAll('\n', lineEnding);
|
|
206
|
+
: withEnding(canonical.slice(incoming.position.start.offset, incoming.position.end.offset), lineEnding);
|
|
208
207
|
}
|
|
209
208
|
output += source.slice(previous, childStart) + retainedSource;
|
|
210
209
|
previous = childEnd;
|
|
@@ -239,10 +238,14 @@ function retainSource(before, after, source, canonical, lineEnding, inTable = fa
|
|
|
239
238
|
: newEnd;
|
|
240
239
|
if (from != null && to != null && newFrom != null && newTo != null)
|
|
241
240
|
return retainBoundaryWhitespace(source.slice(start, from) +
|
|
242
|
-
canonical.slice(newFrom, newTo)
|
|
241
|
+
withEnding(canonical.slice(newFrom, newTo), lineEnding) +
|
|
243
242
|
source.slice(to, end), canonical.slice(newStart, newEnd));
|
|
244
243
|
}
|
|
245
|
-
return canonical.slice(newStart, newEnd)
|
|
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);
|
|
246
249
|
}
|
|
247
250
|
function positionalNode(tree, transformed) {
|
|
248
251
|
return (tree.children.find((node) => node.position?.start.offset === transformed.position?.start.offset &&
|
|
@@ -323,74 +326,207 @@ function withoutHeadingId(node) {
|
|
|
323
326
|
? node.type.create({ ...node.attrs, id: '' }, node.content, node.marks)
|
|
324
327
|
: node;
|
|
325
328
|
}
|
|
326
|
-
|
|
327
|
-
|
|
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) {
|
|
329
334
|
const result = new Array(after.length);
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
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 });
|
|
336
363
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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;
|
|
343
402
|
}
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
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);
|
|
348
421
|
for (;;) {
|
|
349
422
|
const costs = [];
|
|
350
423
|
const get = (i, j) => i < 0 || j < 0 || Math.abs(i - j) > width
|
|
351
|
-
?
|
|
352
|
-
: (costs[i]?.[j - Math.max(0, i - width)] ??
|
|
424
|
+
? Infinity
|
|
425
|
+
: (costs[i]?.[j - Math.max(0, i - width)] ?? Infinity);
|
|
353
426
|
for (let i = 0; i < rows; i++) {
|
|
354
427
|
const from = Math.max(0, i - width);
|
|
355
428
|
const to = Math.min(columns - 1, i + width);
|
|
356
|
-
budget -= to - from + 1;
|
|
357
|
-
if (budget < 0)
|
|
358
|
-
throw
|
|
429
|
+
work.budget -= to - from + 1;
|
|
430
|
+
if (work.budget < 0)
|
|
431
|
+
throw tooManyBlocks();
|
|
359
432
|
const row = new Uint32Array(to - from + 1);
|
|
360
433
|
costs.push(row);
|
|
361
434
|
for (let j = from; j <= to; j++) {
|
|
362
|
-
const change = before[
|
|
435
|
+
const change = before[b + i - 1] === after[a + j - 1] ? 0 : REPLACE;
|
|
363
436
|
row[j - from] =
|
|
364
437
|
i === 0
|
|
365
|
-
? j
|
|
438
|
+
? j * INDEL
|
|
366
439
|
: j === 0
|
|
367
|
-
? i
|
|
368
|
-
: Math.min(get(i - 1, j) +
|
|
440
|
+
? i * INDEL
|
|
441
|
+
: Math.min(get(i - 1, j) + INDEL, get(i, j - 1) + INDEL, get(i - 1, j - 1) + change);
|
|
369
442
|
}
|
|
370
443
|
}
|
|
371
|
-
// A path
|
|
372
|
-
//
|
|
373
|
-
|
|
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)) {
|
|
374
450
|
width *= 2;
|
|
375
451
|
continue;
|
|
376
452
|
}
|
|
453
|
+
const unchanged = [];
|
|
377
454
|
let i = rows - 1;
|
|
378
455
|
let j = columns - 1;
|
|
379
456
|
while (i || j) {
|
|
380
|
-
const
|
|
381
|
-
if (i && j && get(i, j) === get(i - 1, j - 1) +
|
|
382
|
-
result[
|
|
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 });
|
|
383
462
|
i--;
|
|
384
463
|
j--;
|
|
385
464
|
}
|
|
386
|
-
else if (i && get(i, j) === get(i - 1, j) +
|
|
465
|
+
else if (i && get(i, j) === get(i - 1, j) + INDEL)
|
|
387
466
|
i--;
|
|
388
467
|
else
|
|
389
468
|
j--;
|
|
390
469
|
}
|
|
391
|
-
|
|
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--;
|
|
392
517
|
}
|
|
393
|
-
|
|
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;
|
|
394
530
|
}
|
|
395
531
|
/** Retains the loaded source of blocks the edit leaves alone. */
|
|
396
532
|
export class Preservation {
|
|
@@ -538,53 +674,76 @@ export class Preservation {
|
|
|
538
674
|
}
|
|
539
675
|
current.push(fingerprint);
|
|
540
676
|
});
|
|
541
|
-
const
|
|
677
|
+
const forms = this.blocks.map((block) => block.form);
|
|
678
|
+
const resembling = correspondence(forms, current);
|
|
542
679
|
const processor = this.ctx.get(remarkCtx);
|
|
543
680
|
const currentRawTree = processor.parse(canonical);
|
|
544
681
|
const currentTree = processor.runSync(structuredClone(currentRawTree), {
|
|
545
682
|
value: canonical,
|
|
546
683
|
});
|
|
547
|
-
let output = '';
|
|
548
|
-
let previous;
|
|
549
|
-
current.forEach((text, index) => {
|
|
550
|
-
const position = positions[index];
|
|
551
|
-
const block = position == null ? undefined : this.blocks[position];
|
|
552
|
-
if (index === 0) {
|
|
553
|
-
if (position === 0 && block)
|
|
554
|
-
output += this.source.slice(0, block.start);
|
|
555
|
-
else if (this.source.startsWith('\uFEFF'))
|
|
556
|
-
output += '\uFEFF';
|
|
557
|
-
}
|
|
558
|
-
else if (previous != null && position === previous + 1 && block) {
|
|
559
|
-
output += this.source.slice(this.blocks[previous].end, block.start);
|
|
560
|
-
}
|
|
561
|
-
else
|
|
562
|
-
output += this.lineEnding.repeat(2);
|
|
563
|
-
if (block && block.form === text)
|
|
564
|
-
output += this.source.slice(block.start, block.end);
|
|
565
|
-
else if (block && currentTree.children.length === current.length) {
|
|
566
|
-
output += this.retainBlock(block, positionalNode(currentRawTree, currentTree.children[index]), canonical);
|
|
567
|
-
}
|
|
568
|
-
else
|
|
569
|
-
output += restoreCodeEndings(this.ctx, doc.type.create(null, [doc.child(index)]), text.replaceAll('\n', this.lineEnding));
|
|
570
|
-
previous = position;
|
|
571
|
-
});
|
|
572
|
-
if (previous === this.blocks.length - 1 && this.blocks.length)
|
|
573
|
-
output += this.source.slice(this.blocks[previous].end);
|
|
574
|
-
else if (output)
|
|
575
|
-
output += finalCodeEnding(doc) ?? this.lineEnding;
|
|
576
684
|
const parse = this.ctx.get(parserCtx);
|
|
577
|
-
const reopened = parse(output);
|
|
578
685
|
const normalized = (text) => text.replace(/\r\n?/g, '\n');
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
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;
|
|
588
747
|
throw new PreservationError('The edited Markdown cannot be reopened without changing its content');
|
|
589
748
|
}
|
|
590
749
|
catch (cause) {
|