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.
Files changed (39) hide show
  1. package/LICENSE +3 -2
  2. package/README.md +16 -4
  3. package/dist/images/gif.mjs +3 -1
  4. package/dist/images/playback.mjs +6 -4
  5. package/dist/kit/conformance/run.mjs +1 -1
  6. package/dist/kit/ledger/authored-origin.mjs +49 -0
  7. package/dist/kit/ledger/authorship.mjs +18 -9
  8. package/dist/kit/ledger/data.mjs +7 -4
  9. package/dist/kit/ledger/format.mjs +25 -8
  10. package/dist/kit/ledger/history.mjs +31 -5
  11. package/dist/kit/ledger/inverse-source.mjs +111 -49
  12. package/dist/kit/ledger/journal-records.mjs +24 -29
  13. package/dist/kit/ledger/merge.mjs +48 -13
  14. package/dist/kit/ledger/text-merge.mjs +75 -29
  15. package/dist/kit/ledger/transport.mjs +30 -45
  16. package/dist/kit/pdf-vectors.mjs +2 -1
  17. package/dist/kit/print-capitals.mjs +0 -1
  18. package/dist/kit/rapier-ledger.mjs +2 -0
  19. package/dist/kit/render-markdown.mjs +10 -1
  20. package/dist/kit/render-print.mjs +43 -7
  21. package/dist/kit/render-sanitize.mjs +19 -12
  22. package/dist/kit/render-styles.mjs +3 -3
  23. package/dist/kit/render.mjs +5 -40
  24. package/dist/layout/paragraph-fit.mjs +1 -15
  25. package/dist/layout/typography.mjs +2 -1
  26. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  27. package/dist/packages/rapier-diff/diff.mjs +68 -0
  28. package/dist/packages/rapier-diff/index.mjs +4 -744
  29. package/dist/packages/rapier-diff/matching.mjs +157 -0
  30. package/dist/packages/rapier-diff/merge.mjs +226 -0
  31. package/dist/packages/rapier-diff/moves.mjs +130 -0
  32. package/dist/packages/rapier-diff/structure.mjs +50 -0
  33. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  34. package/dist/packages/rapier-diff/walk.mjs +219 -0
  35. package/dist/spec/html-blocks.mjs +3 -3
  36. package/dist/spec/md-layout.mjs +1 -1
  37. package/dist/spec/md-marks.mjs +1 -1
  38. package/dist/spec/md-source.mjs +15 -7
  39. 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
- let options = selected === 'raw' ? { ...RAPIER_SANITIZE_OPTIONS.render, ALLOW_DATA_ATTR: false } : RAPIER_SANITIZE_OPTIONS[selected] || RAPIER_SANITIZE_OPTIONS.render;
396
- if (selected === 'source') options = {...options, ADD_DATA_URI_TAGS: ['image']};
397
- const labels = selected === 'diagram' || selected === 'export';
398
- if (labels) {
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
- if (fragment) options = {...options, RETURN_DOM_FRAGMENT: true};
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), options);
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 it takes the Standard palette's light values from the sheet's own body.light rule.
107
- const light = theme === 'system' && /html\[data-highlights="standard"\] body\.light\s*\{([^}]*)\}/.exec(highlightCss);
108
- if (light) parts.push('@media (prefers-color-scheme:light){html[data-rapier-theme="system"][data-highlights="standard"] body{' + light[1].trim() + '}}');
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
  }
@@ -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]*(?:&lt;)?)#' + id + '(?=>?[ \\t\\r\\n]*\\)|>?[ \\t\\r\\n]+["\'(])', 'g');
662
- // A definition may sit in a quote or a list item: its line starts with that container's markers.
663
- const definition = new RegExp('(^[ \\t>]*\\[((?:\\\\.|[^\\]\\\\])+)\\]:[ \\t]*(?:(?:\\r\\n|\\r|\\n)[ \\t>]*)?(?:&lt;)?)#' + 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>]*)?(?:&lt;)?)#' + 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
- if (!definitions.has(md.utils.normalizeReference(_rapierSharedDecode(label)))) return match;
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
- function solve(prepared, width, options, observe) {
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(fitter = createParagraphFitter()) {
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
+ }