rapier-markdown-kit 1.2.3 → 1.2.5
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/LICENSE +3 -2
- package/README.md +16 -4
- package/dist/images/gif.mjs +3 -1
- package/dist/images/playback.mjs +6 -4
- package/dist/kit/conformance/run.mjs +1 -1
- package/dist/kit/ledger/authored-origin.mjs +49 -0
- package/dist/kit/ledger/authorship.mjs +18 -9
- package/dist/kit/ledger/data.mjs +7 -4
- package/dist/kit/ledger/format.mjs +25 -8
- package/dist/kit/ledger/history.mjs +31 -5
- package/dist/kit/ledger/inverse-source.mjs +111 -49
- package/dist/kit/ledger/journal-records.mjs +24 -29
- package/dist/kit/ledger/merge.mjs +48 -13
- package/dist/kit/ledger/text-merge.mjs +75 -29
- package/dist/kit/ledger/transport.mjs +30 -45
- package/dist/kit/pdf-vectors.mjs +2 -1
- package/dist/kit/print-capitals.mjs +0 -1
- package/dist/kit/rapier-ledger.mjs +2 -0
- package/dist/kit/render-markdown.mjs +10 -1
- package/dist/kit/render-print.mjs +43 -7
- package/dist/kit/render-sanitize.mjs +19 -12
- package/dist/kit/render-styles.mjs +3 -3
- package/dist/kit/render.mjs +5 -40
- package/dist/layout/paragraph-fit.mjs +1 -15
- package/dist/layout/typography.mjs +2 -1
- package/dist/packages/rapier-diff/concerns.mjs +163 -0
- package/dist/packages/rapier-diff/diff.mjs +68 -0
- package/dist/packages/rapier-diff/index.mjs +4 -744
- package/dist/packages/rapier-diff/matching.mjs +157 -0
- package/dist/packages/rapier-diff/merge.mjs +226 -0
- package/dist/packages/rapier-diff/moves.mjs +130 -0
- package/dist/packages/rapier-diff/structure.mjs +50 -0
- package/dist/packages/rapier-diff/tokens.mjs +72 -0
- package/dist/packages/rapier-diff/walk.mjs +219 -0
- package/dist/spec/html-blocks.mjs +3 -3
- package/dist/spec/md-layout.mjs +1 -1
- package/dist/spec/md-marks.mjs +1 -1
- package/dist/spec/md-source.mjs +15 -7
- package/package.json +1 -1
|
@@ -29,11 +29,6 @@ const RAPIER_SANITIZE_OPTIONS = Object.freeze({
|
|
|
29
29
|
FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
|
|
30
30
|
FORBID_ATTR: RAPIER_SANITIZE_FORBID_ATTR,
|
|
31
31
|
}),
|
|
32
|
-
snippet: Object.freeze({
|
|
33
|
-
..._rapierSanitizeProfile('html'),
|
|
34
|
-
FORBID_TAGS: RAPIER_SANITIZE_FORBID_TAGS,
|
|
35
|
-
FORBID_ATTR: Object.freeze([...'style']),
|
|
36
|
-
}),
|
|
37
32
|
diagram: Object.freeze({
|
|
38
33
|
..._rapierSanitizeProfile('html', 'svg'),
|
|
39
34
|
FORBID_TAGS: Object.freeze(['form', 'dialog', 'template', 'iframe', 'script', 'foreignobject']),
|
|
@@ -59,6 +54,9 @@ const DIAGRAM_LABEL_SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
|
|
|
59
54
|
const DIAGRAM_LABEL_HTML_NAMESPACE = 'http://www.w3.org/1999/xhtml';
|
|
60
55
|
const DIAGRAM_LABEL_MATH_NAMESPACE = 'http://www.w3.org/1998/Math/MathML';
|
|
61
56
|
|
|
57
|
+
// The profile each DOMPurify instance holds (setConfig), shared by every renderer made over it.
|
|
58
|
+
const purifyConfigured = new WeakMap();
|
|
59
|
+
|
|
62
60
|
function _rapierImageDataAttributeAllowed(tag, attribute, value, verify) {
|
|
63
61
|
return (tag === 'img' && attribute === 'src' || tag === 'image' && /^(?:href|xlink:href)$/.test(attribute)
|
|
64
62
|
&& /^data:image\/(?:png|jpeg|webp|jxl);base64,/i.test(value)) && verify(value);
|
|
@@ -376,6 +374,8 @@ function _rapierInstallSanitizeHooks() {
|
|
|
376
374
|
});
|
|
377
375
|
}
|
|
378
376
|
|
|
377
|
+
const purifyOptions = new Map();
|
|
378
|
+
|
|
379
379
|
function sanitizeRapierHtml(value, context = 'render') {
|
|
380
380
|
return sanitize(value, context, false);
|
|
381
381
|
}
|
|
@@ -392,24 +392,31 @@ function sanitize(value, context, fragment) {
|
|
|
392
392
|
// (the hook) without an id the chrome owns. 'source' is Markdown source HTML, written and never shown: the render
|
|
393
393
|
// profile's admission, and the block on remote content stays the reader's (a remote picture's URL is the person's words).
|
|
394
394
|
const selected = context === 'raw' || context === 'export' || context === 'source' || RAPIER_SANITIZE_OPTIONS[context] ? context : 'render';
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
395
|
+
const labels = selected === 'diagram' || selected === 'export', key = selected + (fragment ? ' fragment' : '');
|
|
396
|
+
let options = purifyOptions.get(key);
|
|
397
|
+
if (!options) {
|
|
398
|
+
options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
|
|
399
|
+
if (selected === 'source') options = {...options, ADD_DATA_URI_TAGS: ['image']};
|
|
399
400
|
// DOMPurify's USE_PROFILES replaces explicit allowlists. Add MathML to the
|
|
400
401
|
// selected profile without discarding its admitted HTML and SVG vocabulary.
|
|
401
|
-
options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
|
|
402
|
+
if (labels) options = {...options, ALLOWED_TAGS: [...new Set([...options.ALLOWED_TAGS, ...RAPIER_SANITIZE_PROFILES.mathTags])],
|
|
402
403
|
ALLOWED_ATTR: [...new Set([...options.ALLOWED_ATTR, ...RAPIER_SANITIZE_PROFILES.mathAttributes])], ADD_TAGS: ['foreignobject'], ADD_DATA_URI_TAGS: ['image'],
|
|
403
404
|
HTML_INTEGRATION_POINTS: {foreignobject: true}, FORBID_TAGS: options.FORBID_TAGS.filter(tag => tag !== 'foreignobject')};
|
|
405
|
+
if (fragment) options = {...options, RETURN_DOM_FRAGMENT: true};
|
|
406
|
+
purifyOptions.set(key, options);
|
|
407
|
+
}
|
|
408
|
+
if (labels) {
|
|
404
409
|
DOMPurify.addHook('uponSanitizeElement', diagramLabelElement);
|
|
405
410
|
DOMPurify.addHook('uponSanitizeAttribute', diagramLabelAttribute);
|
|
406
411
|
DOMPurify.addHook('afterSanitizeAttributes', diagramLabelContained);
|
|
407
412
|
}
|
|
408
|
-
|
|
413
|
+
// DOMPurify reparses a per-call configuration on every call (its cache compares its own copy), about 0.45 ms
|
|
414
|
+
// each in Chrome 155. A profile is set once and kept until another profile is asked for; every caller comes here.
|
|
415
|
+
if (purifyConfigured.get(DOMPurify) !== options) { DOMPurify.setConfig(options); purifyConfigured.set(DOMPurify, options); }
|
|
409
416
|
const previous = _rapierSanitizeRuntime.context;
|
|
410
417
|
_rapierSanitizeRuntime.context = selected;
|
|
411
418
|
try {
|
|
412
|
-
return DOMPurify.sanitize(value == null ? '' : String(value)
|
|
419
|
+
return DOMPurify.sanitize(value == null ? '' : String(value));
|
|
413
420
|
} finally {
|
|
414
421
|
if (labels) {
|
|
415
422
|
DOMPurify.removeHook('uponSanitizeElement', diagramLabelElement);
|
|
@@ -103,9 +103,9 @@ function _rapierArtifactStyles(theme, includeFonts, printMode, houseDefaults = f
|
|
|
103
103
|
const highlightCss = _rapierStyleText('rapier-highlight-style');
|
|
104
104
|
parts.push(highlightCss);
|
|
105
105
|
// A page written for the system theme has no body.light to switch: under a light system
|
|
106
|
-
// setting
|
|
107
|
-
|
|
108
|
-
|
|
106
|
+
// setting each palette takes its light values from the sheet's own body.light rule.
|
|
107
|
+
if (theme === 'system') for (const [, palette, light] of highlightCss.matchAll(/html\[data-highlights="(standard|accent)"\] body\.light\s*\{([^}]*)\}/g))
|
|
108
|
+
parts.push('@media (prefers-color-scheme:light){html[data-rapier-theme="system"][data-highlights="' + palette + '"] body{' + light.trim() + '}}');
|
|
109
109
|
if (printMode) parts.push(_rapierArtifactPrintCss());
|
|
110
110
|
return parts.filter(Boolean).join('\n\n');
|
|
111
111
|
}
|
package/dist/kit/render.mjs
CHANGED
|
@@ -8,43 +8,6 @@ import {documentLanguage, documentSettingsFonts} from '../spec/document-settings
|
|
|
8
8
|
import {createLineMeasurer, linesHeight} from '../layout/model.mjs';
|
|
9
9
|
import {markPrintCapitals, createPrintCapitals, followingPrintProse} from './print-capitals.mjs';
|
|
10
10
|
import {freezePublishedInk} from './render-ink.mjs';
|
|
11
|
-
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
12
|
-
// Rapier generates attribute selectors for dark paper. Project those owned rules onto their
|
|
13
|
-
// SVG elements before export removes stylesheets; the inert host and the editor share this pass.
|
|
14
|
-
export function presentNativeDiagram(drawing) {
|
|
15
|
-
const winners=new Map();
|
|
16
|
-
for(const style of drawing.querySelectorAll('style')){
|
|
17
|
-
const media=/^\s*@media\s*\(\s*prefers-color-scheme\s*:\s*dark\s*\)\s*\{([\s\S]*)\}\s*$/.exec(style.textContent);
|
|
18
|
-
if(media)for(const rule of media[1].matchAll(/([^{}]+)\{([^{}]*)\}/g)){
|
|
19
|
-
// The browser's CSS sanitizer serializes owned hex colours as rgb()/rgba().
|
|
20
|
-
const properties=[...rule[2].matchAll(/(?:^|;)\s*(fill|stroke|color|stop-color)\s*:\s*(#[\da-f]{3,8}|rgba?\([\d.,%/\s]+\)|none)\s*(?:!important)?(?=;|$)/gi)];
|
|
21
|
-
for(const selector of rule[1].split(',')){
|
|
22
|
-
// Draw's selectors contain only attributes, descendants and immediate children.
|
|
23
|
-
if(!/^\s*\[[^\[\]{};]+\](?:\s*(?:>\s*)?\[[^\[\]{};]+\])*\s*$/.test(selector))continue;
|
|
24
|
-
const rank=(selector.match(/\[/g)||[]).length;
|
|
25
|
-
for(const element of [...(drawing.matches(selector)?[drawing]:[]),...drawing.querySelectorAll(selector)]){
|
|
26
|
-
let paints=winners.get(element);if(!paints)winners.set(element,paints=new Map());
|
|
27
|
-
for(const [,property,dark] of properties){const light=element.getAttribute(property);
|
|
28
|
-
if(light&&(paints.get(property)?.rank??-1)<=rank)paints.set(property,{light,dark,rank});}
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
}
|
|
32
|
-
style.remove();
|
|
33
|
-
}
|
|
34
|
-
for(const [element,paints] of winners)for(const [property,{light,dark}] of paints)
|
|
35
|
-
element.style.setProperty(property,'light-dark('+light+', '+dark+')','important');
|
|
36
|
-
drawing.classList.add('rapier-native-flowchart');drawing.setAttribute('role','img');
|
|
37
|
-
drawing.setAttribute('aria-roledescription',drawing.getAttribute('data-rapier-diagram-kind')||'flowchart');
|
|
38
|
-
const width=Number(drawing.getAttribute('width')),
|
|
39
|
-
sizes=[...drawing.querySelectorAll('text[font-size]')].map(text=>Number(text.getAttribute('font-size'))).filter(size=>Number.isFinite(size)&&size>0),
|
|
40
|
-
minimum=sizes.reduce((smallest,size)=>Math.min(smallest,size),Infinity);
|
|
41
|
-
if(Number.isFinite(width)&&width>0){
|
|
42
|
-
const readable=Math.ceil(width*14/(Number.isFinite(minimum)?minimum:14)*1000)/1000;
|
|
43
|
-
drawing.style.width='clamp('+readable+'px, 100%, '+width+'px)';
|
|
44
|
-
}
|
|
45
|
-
drawing.style.maxWidth='none';
|
|
46
|
-
}
|
|
47
|
-
|
|
48
11
|
// Set the first view once. Subsequent rendering keeps the person's horizontal position.
|
|
49
12
|
export function positionNativeDiagram(drawing) {
|
|
50
13
|
const surface=drawing?.closest?.('.diagram-block,.diagram-live-preview');
|
|
@@ -67,6 +30,7 @@ export function positionNativeDiagram(drawing) {
|
|
|
67
30
|
surface.scrollLeft=Math.max(0,Math.min(surface.scrollWidth-surface.clientWidth,left));
|
|
68
31
|
}
|
|
69
32
|
|
|
33
|
+
// The document's renderer. DOM, codecs and host state are explicit inputs.
|
|
70
34
|
function createRenderer(runtime) {
|
|
71
35
|
const {RapierLedgerCarried, RapierPageReturnAddress, RapierTextCodec, _rapierArtifactHighlight, _rapierArtifactPreference, _rapierArtifactStyles, _rapierBlobDataUrl, _rapierBuildInterchangeContext, _rapierDocumentNameIsAdmissible, _rapierLedgerParts, _rapierDrawReadSVGRecipe, _rapierDrawShapeProfileFor, _rapierExportFontCss, _rapierFillDiagram, _rapierFinalizeExportCode, _rapierLanguageClass, _rapierLineStartOffsets, _rapierMarkdownEnvironment, _rapierPortableHtml, _rapierPrepareInterchangeContext, _rapierProjectPortableRoot, _rapierProviders, _rapierSourceCharEscaped, _rapierSourceLineSpan, crypto, document, escapeRapierHtmlText, globalThis, sanitizeRapierHtml} = runtime;
|
|
72
36
|
let md = runtime.md;
|
|
@@ -659,11 +623,12 @@ function _rapierSharedResolve(text, ids, images, definitions) {
|
|
|
659
623
|
// Authored # characters were encoded. A raw id after ]( can only be a structural
|
|
660
624
|
// substitution; matching its destination lets nested alt brackets survive unchanged.
|
|
661
625
|
const inline = new RegExp('(\\]\\([ \\t\\r\\n]*(?:<)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
|
|
662
|
-
// A definition may sit in a quote or a list item: its line starts with
|
|
663
|
-
const definition = new RegExp('(^[ \\t>]*\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t>]*)?(?:<)?)#' + id + '(?=>?[ \\t]*$|>?[ \\t]+["\'(])', 'gm');
|
|
626
|
+
// A definition may sit in a quote or a list item, as the writer found it: its line starts with their markers.
|
|
627
|
+
const definition = new RegExp('(^[ \\t>]*(?:(?:[-+*]|\\d{1,9}[.)])[ \\t]+[ \\t>]*)*\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t>]*)?(?:<)?)#' + id + '(?=>?[ \\t]*$|>?[ \\t]+["\'(])', 'gm');
|
|
664
628
|
const swapInline = (match, lead, value) => { const found = src(value); return found == null ? match : lead + found; };
|
|
665
629
|
const swapDefinition = (match, lead, label, value) => {
|
|
666
|
-
|
|
630
|
+
const decoded = _rapierSharedDecode(label), quoted = decoded.replace(/\r?\n[ \t]*(?:>[ \t]*)+/g, ' ');
|
|
631
|
+
if (!definitions.has(md.utils.normalizeReference(decoded)) && !definitions.has(md.utils.normalizeReference(quoted))) return match;
|
|
667
632
|
const found = src(value);
|
|
668
633
|
return found == null ? match : lead + found;
|
|
669
634
|
};
|
|
@@ -172,20 +172,6 @@ export function createParagraphFitter() {
|
|
|
172
172
|
return {lines, work, cost: totalCost, approximate: false};
|
|
173
173
|
}
|
|
174
174
|
|
|
175
|
-
|
|
176
|
-
const work = solveSteps(prepared, width, options);
|
|
177
|
-
try {
|
|
178
|
-
let step = work.next();
|
|
179
|
-
while (!step.done) {
|
|
180
|
-
if (step.value && !observe) return null;
|
|
181
|
-
step = work.next(step.value ? observe(step.value) : undefined);
|
|
182
|
-
}
|
|
183
|
-
return step.value;
|
|
184
|
-
} finally { work.return(); }
|
|
185
|
-
}
|
|
186
|
-
return {prepare, solve, solveSteps};
|
|
175
|
+
return {prepare, solveSteps};
|
|
187
176
|
}
|
|
188
177
|
|
|
189
|
-
const fitter = createParagraphFitter();
|
|
190
|
-
export const prepareParagraph = fitter.prepare;
|
|
191
|
-
export const fitParagraph = fitter.solve;
|
|
@@ -3,7 +3,8 @@ import {createPreparationScope, supportsRichStyle, captureFontAuthority} from '.
|
|
|
3
3
|
import {createParagraphFitter} from './paragraph-fit.mjs';
|
|
4
4
|
|
|
5
5
|
// Layout projections keep the original text in place. The factory also runs in an exported page.
|
|
6
|
-
export function createTypography(
|
|
6
|
+
export function createTypography() {
|
|
7
|
+
const fitter = createParagraphFitter();
|
|
7
8
|
const mark = 'data-rapier-typography', boundary = mark + '-break', hang = mark + '-hang';
|
|
8
9
|
const protectedInitial = mark + '-protected-initial';
|
|
9
10
|
const gapMark = mark + '-gap', fitMark = mark + '-fit';
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {letter, within, prev, next} from './tokens.mjs';
|
|
3
|
+
|
|
4
|
+
// Separator-only pieces join the edit beside them; adjacent blocks each carry their own line break.
|
|
5
|
+
export function separate(c, endA = c.before.length, endB = c.after.length) {
|
|
6
|
+
const {before, after, safe, raw, spend} = c;
|
|
7
|
+
// A separator-only piece beside another edit joins it; equal separators at its ends stay unchanged.
|
|
8
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
9
|
+
const p = c.result[i - 1], q = c.result[i], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1, text = side ? after : before;
|
|
10
|
+
if (side >= 0 && !/\S/.test(text.slice(...p[side]))) {
|
|
11
|
+
let n = 0;
|
|
12
|
+
while (p[0][1] + n < q[0][0] && text[p[side][0] + n] === text[p[side][1] + n] && spend()) n++;
|
|
13
|
+
if (p[0][1] + n === q[0][0] && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + n) && safe(k ? after : before, span[1] + n)))
|
|
14
|
+
for (const span of p.slice(0, 2)) { span[0] += n; span[1] += n; }
|
|
15
|
+
}
|
|
16
|
+
if (p[2] || q[2] || p[0][1] !== q[0][0] || p[1][1] !== q[1][0] ||
|
|
17
|
+
![p, q].some(r => !/\S/.test(before.slice(...r[0]) + after.slice(...r[1])))) continue;
|
|
18
|
+
const x = [p[0][0], q[0][1]], y = [p[1][0], q[1][1]], keep = /\S/.test(before.slice(...q[0]) + after.slice(...q[1])) ? q : p;
|
|
19
|
+
// CRLF steps as one separator.
|
|
20
|
+
for (let k; x[0] < x[1] && y[0] < y[1] && /\s/.test(before[x[1] - 1]) && before[x[1] - 1] === after[y[1] - 1] &&
|
|
21
|
+
(k = before.endsWith('\r\n', x[1]) && after.endsWith('\r\n', y[1]) ? 2 : 1) <= Math.min(x[1] - x[0], y[1] - y[0]) &&
|
|
22
|
+
safe(before, x[1] - k) && safe(after, y[1] - k);) { x[1] -= k; y[1] -= k; }
|
|
23
|
+
keep[3][0] = x[0] === x[1] ? 2 : y[0] === y[1] ? 0 : 1;
|
|
24
|
+
c.result.splice(i - 1, 2, [x, y, false, keep[3]]);
|
|
25
|
+
}
|
|
26
|
+
// Repeated closing lines can attach to the wrong block. Rotate an exact insertion/deletion to the
|
|
27
|
+
// next blank-line boundary, without crossing another edit or splitting a character.
|
|
28
|
+
if (!raw) for (let i = 0; i < c.result.length; i++) {
|
|
29
|
+
const p = c.result[i], q = c.result[i + 1], side = p[0][0] === p[0][1] ? 1 : p[1][0] === p[1][1] ? 0 : -1;
|
|
30
|
+
if (side < 0 || p[2]) continue;
|
|
31
|
+
const text = side ? after : before;
|
|
32
|
+
const room = Math.min((q?.[0][0] ?? endA) - p[0][1], (q?.[1][0] ?? endB) - p[1][1]);
|
|
33
|
+
let n = 0, shift = 0;
|
|
34
|
+
while (n < room && text[p[side][0] + n] === text[p[side][1] + n] && spend()) {
|
|
35
|
+
n++;
|
|
36
|
+
if (text[p[side][0] + n - 1] === '\n' && /^\r?\n/.test(text.slice(p[side][0] + n))) { shift = n; break; }
|
|
37
|
+
}
|
|
38
|
+
if (shift && p.slice(0, 2).every((span, k) => safe(k ? after : before, span[0] + shift) && safe(k ? after : before, span[1] + shift)))
|
|
39
|
+
for (const span of p.slice(0, 2)) { span[0] += shift; span[1] += shift; }
|
|
40
|
+
}
|
|
41
|
+
// Adjacent blocks deleted or inserted together: when the first carries the line break before it, each carries
|
|
42
|
+
// its own, so any one restores apart from its neighbours.
|
|
43
|
+
if (!raw) for (let i = 1; i < c.result.length; i++) {
|
|
44
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
45
|
+
if (kind % 2 || q[3][0] !== kind || p[2] || q[2] || p[side][1] !== q[side][0] || p[1 - side][0] !== q[1 - side][0]) continue;
|
|
46
|
+
const blank = /^\r?\n/.test(text.slice(...p[side])) && !/^\r?\n/.test(text.slice(...q[side])) && /(\r?\n)(?:[ \t]*\r?\n)+$/.exec(text.slice(...p[side]));
|
|
47
|
+
if (blank && blank.index > 0) p[side][1] = q[side][0] -= blank[0].length - blank[1].length;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Refine the outer walk and each move's inner walk before assigning transport ownership.
|
|
52
|
+
export function phrases(c) {
|
|
53
|
+
const {before, after, raw, code} = c;
|
|
54
|
+
// Whole-word replacements separated only by spaces read and restore as one phrase, and one number stays one number.
|
|
55
|
+
if (!raw) for (let i = c.result.length - 1; i > 0; i--) {
|
|
56
|
+
const p = c.result[i - 1], q = c.result[i], gap = before.slice(p[0][1], q[0][0]);
|
|
57
|
+
if (p[3][0] !== 1 || q[3][0] !== 1 || p[3][3] || q[3][3] || p[2] || q[2] || gap !== after.slice(p[1][1], q[1][0])) continue;
|
|
58
|
+
// A time, a date, a version or a decimal is one number.
|
|
59
|
+
const number = /^(?:[.,:/-]\d*)+$/.test(gap) && /\d/.test(before[p[0][1] - 1] + after[p[1][1] - 1]) && /\d/.test(before[q[0][0]] + after[q[1][0]]);
|
|
60
|
+
if (!number && (code || !/^[^\S\r\n]+$/.test(gap) || within(before, p[0][0]) || within(after, p[1][0]) ||
|
|
61
|
+
within(before, q[0][1]) || within(after, q[1][1]))) continue;
|
|
62
|
+
p[0][1] = q[0][1]; p[1][1] = q[1][1]; c.result.splice(i, 1);
|
|
63
|
+
}
|
|
64
|
+
// Paired inline markers added or removed around unchanged text on one line are one concern.
|
|
65
|
+
if (!raw && !code) for (let i = 1; i < c.result.length; i++) {
|
|
66
|
+
const p = c.result[i - 1], q = c.result[i], kind = p[3][0], side = kind ? 1 : 0, text = side ? after : before;
|
|
67
|
+
if (kind % 2 || kind !== q[3][0] || p[2] || q[2] || p[3][3] || q[3][3]) continue;
|
|
68
|
+
const open = text.slice(...p[side]), close = text.slice(...q[side]), inner = text.slice(p[side][1], q[side][0]);
|
|
69
|
+
if (!inner.trim() || inner.includes('\n') || !(open === close && /^(?:[*_]{1,3}|~~|==|`+)$/.test(open) ||
|
|
70
|
+
open === '[' && /^\]\([^\s()]*\)$/.test(close))) continue;
|
|
71
|
+
p[3] = q[3] = [kind, 'word', p[3][2], [[p[0][0], q[0][1]], [p[1][0], q[1][1]]]];
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Readable concerns: phrases, numbers and paired markers, then the public edits and changes with context.
|
|
76
|
+
export function concerns(c) {
|
|
77
|
+
phrases(c);
|
|
78
|
+
const {before, after, spend, safe, budget, raw, code, evidence, structures, initial, options, pa, pb} = c, lb = c.lb;
|
|
79
|
+
const overlaps = (a, b) => a[0] < b[1] && a[1] > b[0];
|
|
80
|
+
// Siblings are ordered and disjoint: the parts holding a range are found by descent, outermost first.
|
|
81
|
+
const containing = (parts, from, to, out = []) => {
|
|
82
|
+
let lo = 0, hi = parts.length;
|
|
83
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (parts[mid].end < to) lo = mid + 1; else hi = mid; }
|
|
84
|
+
for (let k = lo; k < parts.length && parts[k].start <= from && spend(); k++)
|
|
85
|
+
if (parts[k].end >= to) { out.push(parts[k]); containing(parts[k].children, from, to, out); }
|
|
86
|
+
return out;
|
|
87
|
+
};
|
|
88
|
+
// Edits are ordered and disjoint on the after side.
|
|
89
|
+
const touched = (edits, range) => {
|
|
90
|
+
let lo = 0, hi = edits.length;
|
|
91
|
+
while (lo < hi) { const mid = lo + hi >>> 1; if (edits[mid].after[1] <= range[0]) lo = mid + 1; else hi = mid; }
|
|
92
|
+
return lo < edits.length && overlaps(range, edits[lo].after);
|
|
93
|
+
};
|
|
94
|
+
const changes = [], edits = [];
|
|
95
|
+
let normalized;
|
|
96
|
+
const meaning = structures?.[0]?.meaning;
|
|
97
|
+
const format = typeof meaning === 'string' && meaning === structures[1].meaning;
|
|
98
|
+
for (let i = 0; i < c.result.length; i++) {
|
|
99
|
+
const item = c.result[i];
|
|
100
|
+
if (raw === 'char' && !evidence) {
|
|
101
|
+
const a = item[0], b = item[1], next = c.result[i + 1];
|
|
102
|
+
if (item[3][0] !== 1) {
|
|
103
|
+
const room = Math.min((next?.[0][0] ?? before.length) - a[1], (next?.[1][0] ?? after.length) - b[1]);
|
|
104
|
+
let n = 0;
|
|
105
|
+
while (n < room && before[a[0] + n] === after[b[0] + n] && spend()) n++;
|
|
106
|
+
while (n && (!safe(before, a[0] + n) || !safe(before, a[1] + n) || !safe(after, b[0] + n) || !safe(after, b[1] + n))) n--;
|
|
107
|
+
a[0] += n; a[1] += n; b[0] += n; b[1] += n;
|
|
108
|
+
}
|
|
109
|
+
const p = normalized;
|
|
110
|
+
if (p && p[0][0] === p[0][1] && item[3][0] === 2 && p[0][0] === a[0] && p[1][1] === b[0]) {
|
|
111
|
+
p[1][1] = b[1]; if (item[2]) edits.at(-1).coarse = true; continue;
|
|
112
|
+
}
|
|
113
|
+
normalized = item;
|
|
114
|
+
}
|
|
115
|
+
const group = item[3];
|
|
116
|
+
if (!raw && !group[3]) group[3] = [[...item[0]], [...item[1]]];
|
|
117
|
+
if (!group[4]) {
|
|
118
|
+
const context = group[3];
|
|
119
|
+
// Context completes a word only where an edge falls inside it.
|
|
120
|
+
if (!raw) for (const [text, span] of [[before, context[0]], [after, context[1]]]) {
|
|
121
|
+
const start = within(text, span[0]), end = within(text, span[1]);
|
|
122
|
+
for (let c; start && letter(c = prev(text, span[0])) && spend() && safe(text, span[0] - c.length);) span[0] -= c.length;
|
|
123
|
+
for (let c; end && letter(c = next(text, span[1])) && spend() && safe(text, span[1] + c.length);) span[1] += c.length;
|
|
124
|
+
}
|
|
125
|
+
group[4] = []; changes.push(group);
|
|
126
|
+
if (format) group[0] = 5;
|
|
127
|
+
}
|
|
128
|
+
group[4].push(edits.length);
|
|
129
|
+
edits.push({before: item[0], after: item[1], ...(item[2] ? {coarse: true} : {})});
|
|
130
|
+
}
|
|
131
|
+
if (structures && !raw && budget.left >= 0) {
|
|
132
|
+
const oldNames = new Set(pa.filter(p => p.nameAt).map(p => p.name)), newNames = new Set(pb.filter(p => p.nameAt).map(p => p.name));
|
|
133
|
+
const linksTo = new Map();
|
|
134
|
+
if (spend(pa.length + pb.length + lb.length)) lb.forEach((link, k) => linksTo.set(link.to, [...linksTo.get(link.to) || [], k]));
|
|
135
|
+
for (const change of changes) {
|
|
136
|
+
if (change[0] === 5) continue;
|
|
137
|
+
if (budget.left < 0) break;
|
|
138
|
+
const range = change[3];
|
|
139
|
+
const older = containing(structures[0].parts, ...range[0]), newer = containing(structures[1].parts, ...range[1]);
|
|
140
|
+
const part = (change[0] === 0 ? older : newer).at(-1) || older.at(-1);
|
|
141
|
+
if (!change[2] && part?.name) { change[2] = part.name; change[1] = part.kind; }
|
|
142
|
+
const names = new Set([change[2], ...older.map(p => p.name), ...newer.map(p => p.name)]), breaks = [], affects = [];
|
|
143
|
+
for (const k of [...names].flatMap(name => linksTo.get(name) || []).sort((a, b) => a - b)) {
|
|
144
|
+
const {at, to} = lb[k];
|
|
145
|
+
if (!newNames.has(to)) { if (oldNames.has(to)) breaks.push({at, to}); continue; }
|
|
146
|
+
if (!spend() || touched(edits, at)) continue;
|
|
147
|
+
const p = containing(structures[1].parts, ...at).filter(p => /^(paragraph|call|item|task|cell|declaration)$/.test(p.kind)).at(-1);
|
|
148
|
+
affects.push(p && !touched(edits, [p.start, p.end]) ? [p.start, p.end] : at);
|
|
149
|
+
}
|
|
150
|
+
if (breaks.length) change[6] = breaks;
|
|
151
|
+
if (affects.length) change[5] = affects;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (options._stats) Object.assign(options._stats, {work: Math.max(0, initial - Math.max(0, budget.left)), coarse: edits.some(c => c.coarse)});
|
|
155
|
+
// Concern tuples: kind, unit, name, context, edits, affects, breaks. Wording inside a move shares its context.
|
|
156
|
+
const moves = new Map(changes.map((change, i) => change[0] === 3 && [change[3], i + 1]).filter(Boolean));
|
|
157
|
+
return {edits, changes: changes.map(([kind, unit, name, context, owned, affects, breaks], i) => ({
|
|
158
|
+
id: i + 1, kind: ['delete', 'replace', 'insert', 'move', 'rename', 'format'][kind], unit, edits: owned,
|
|
159
|
+
...(name ? {name} : {}), ...(context ? {context: {before: context[0], after: context[1]}} : {}),
|
|
160
|
+
...(kind !== 3 && moves.has(context) ? {move: moves.get(context)} : {}),
|
|
161
|
+
...(affects ? {affects} : {}), ...(breaks ? {breaks} : {})
|
|
162
|
+
}))};
|
|
163
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// One comparison: observe structure, walk the source into exact records, find moves and renames, then read them as
|
|
3
|
+
// concerns. Each stage lives in its own module and shares one comparison context.
|
|
4
|
+
import {observe} from './structure.mjs';
|
|
5
|
+
import {boundaries, tokenizer, wordSegmenter} from './tokens.mjs';
|
|
6
|
+
import {matcher} from './matching.mjs';
|
|
7
|
+
import {walker} from './walk.mjs';
|
|
8
|
+
import {moves} from './moves.mjs';
|
|
9
|
+
import {separate, concerns} from './concerns.mjs';
|
|
10
|
+
|
|
11
|
+
export function diff(before, after, options = {}) {
|
|
12
|
+
if (before === after) return {edits: [], changes: []};
|
|
13
|
+
const budget = options._budget || {left: Number.isFinite(options.limit) ? Math.max(0, options.limit) : 1e7};
|
|
14
|
+
const spend = (n = 1) => (budget.left -= n) >= 0, safe = boundaries(), raw = options._unit;
|
|
15
|
+
// Internal records: [before range, after range, coarse, concern, transported fragments].
|
|
16
|
+
const c = {before, after, options, budget, spend, safe, raw, chars: options.unit === 'char', initial: budget.left,
|
|
17
|
+
segmenter: wordSegmenter(), result: [], code: options.as === 'code'};
|
|
18
|
+
// A Markdown fence holds code inside prose; only code outside fences makes the text code.
|
|
19
|
+
if (options.as !== 'prose' && options.as !== 'code') c.code = /(?:^|\n)\s*(?:[{}]|(?:function|class|const|let|var|import|export|def|fn|func|package)\s+\w[^]{0,80}[({=:;])/
|
|
20
|
+
.test((before.slice(0, 4096) + '\n' + after.slice(0, 4096)).replace(/^ {0,3}(`{3,}|~{3,})[^]*?(?:^ {0,3}\1[`~]*[ \t]*$|(?![^]))/gm, ''));
|
|
21
|
+
let fallback;
|
|
22
|
+
if (options.structure && (!raw || options._structure)) {
|
|
23
|
+
let structures;
|
|
24
|
+
try {
|
|
25
|
+
structures = options._observed || [observe(before, options.structure, spend, safe), observe(after, options.structure, spend, safe)];
|
|
26
|
+
if (structures.some(value => !value || value.invalid)) throw null;
|
|
27
|
+
const [pa, pb] = structures.map(value => value.flat), aliases = new Map();
|
|
28
|
+
const oldByKey = new Map(pa.filter(p => p.key != null).map(p => [p.key, p]));
|
|
29
|
+
const oldNames = new Set(pa.map(p => p.name)), newNames = new Set(pb.map(p => p.name));
|
|
30
|
+
for (const p of pb) {
|
|
31
|
+
const old = oldByKey.get(p.key);
|
|
32
|
+
if (old?.nameAt && p.nameAt && old.kind === p.kind && !oldNames.has(p.name) && !newNames.has(old.name)) aliases.set(p, old);
|
|
33
|
+
}
|
|
34
|
+
Object.assign(c, {structures, pa, pb, la: structures[0].links, lb: structures[1].links, aliases});
|
|
35
|
+
} catch {
|
|
36
|
+
// A failed reader preserves an explicit prose choice and reports why structure was not used.
|
|
37
|
+
const failed = structures?.find(value => !value || value.invalid);
|
|
38
|
+
fallback = {structure: failed?.threw ? 'threw' : budget.left < 0 ? 'limit' : 'invalid', ...(failed?.threw ? {error: failed.error} : {})};
|
|
39
|
+
c.code = options.as !== 'prose';
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
c.tokens = tokenizer(c); c.matches = matcher(spend, budget);
|
|
43
|
+
const {walk, operations} = Object.assign(c, walker(c)), {structures, pa, pb} = c;
|
|
44
|
+
const evidence = c.evidence = options.from && operations(options.from);
|
|
45
|
+
if (options.from && !evidence) (fallback ||= {}).from = budget.left < 0 ? 'limit' : 'invalid';
|
|
46
|
+
if (evidence) for (const span of evidence) walk(...span, structures ? 0 : raw === 'char' ? 3 : 1, pa, pb);
|
|
47
|
+
else walk(0, before.length, 0, after.length, structures ? 0 : raw === 'word' ? 2 : 1,
|
|
48
|
+
structures?.[0]?.parts, structures?.[1]?.parts);
|
|
49
|
+
separate(c);
|
|
50
|
+
moves(c);
|
|
51
|
+
// A heading edit can re-record its children. Compare both walks after transport is known: unchanged moved
|
|
52
|
+
// fragments cost nothing, so a smaller plain walk cannot discard a move just by aligning some of its lines.
|
|
53
|
+
if (structures && !evidence && !raw && c.result.some(r => r[0][0] === r[0][1]) && c.result.some(r => r[1][0] === r[1][1]) &&
|
|
54
|
+
budget.left >= c.initial - budget.left) {
|
|
55
|
+
const size = (rows, transport = false) => rows.reduce((n, r) => n + (!transport && r[3][0] === 3 ? 0 : r[0][1] - r[0][0] + r[1][1] - r[1][0]), 0);
|
|
56
|
+
const walked = c.result, links = c.lb, cost = size(walked);
|
|
57
|
+
if (cost) {
|
|
58
|
+
c.result = []; c.lb = structures[1].links;
|
|
59
|
+
walk(0, before.length, 0, after.length, 1);
|
|
60
|
+
separate(c); moves(c);
|
|
61
|
+
const plain = size(c.result);
|
|
62
|
+
if (budget.left < 0 || c.result.some(r => r[2]) || plain > cost ||
|
|
63
|
+
plain === cost && size(c.result, true) >= size(walked, true)) { c.result = walked; c.lb = links; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
const {edits, changes} = concerns(c);
|
|
67
|
+
return {edits, ...(fallback && !raw ? {fallback} : {}), changes};
|
|
68
|
+
}
|