diff --git a/scripts/CLAUDE.md b/scripts/CLAUDE.md index 78540e750..df71b461b 100644 --- a/scripts/CLAUDE.md +++ b/scripts/CLAUDE.md @@ -59,10 +59,10 @@ imports it, except `mcp-app`, which no widget imports and which lands in `skills/leaf/mcp-app/` for an MCP host to read from the install. A bundle reproduces its tracked bytes exactly when every input it fetches is pinned, -which holds for `marked`, `sortable`, `beautiful-mermaid`, and `highlight`, so a clean -`git status` after a run is the check that the bundle still matches the script. `plot` -and `pierre` reach npm's resolver for transitive dependencies and inherit its ranges, -so a diff from either can be an upstream patch rather than drift. +which holds for `marked`, `sortable`, `beautiful-mermaid`, `highlight`, and `jsdiff`, +so a clean `git status` after a run is the check that the bundle still matches the +script. `plot` and `pierre` reach npm's resolver for transitive dependencies and inherit +its ranges, so a diff from either can be an upstream patch rather than drift. Rerun a bundle after changing its pin or the registry input it reads; do not patch a generated bundle or `examples/corpus.html` directly. diff --git a/scripts/vendor.py b/scripts/vendor.py index dfb3c3825..07070e734 100755 --- a/scripts/vendor.py +++ b/scripts/vendor.py @@ -52,6 +52,7 @@ def package_vendor(package: str) -> Path: PINS = { "highlight.js": "11.12.0", "marked": "18.0.11", + "diff": "9.0.0", "beautiful-mermaid": "1.1.3", "elkjs": "0.11.1", "entities": "7.0.1", @@ -180,6 +181,30 @@ def build_highlight(work: Path) -> list[Path]: return [out] +def build_jsdiff(work: Path) -> list[Path]: + """Bundle only jsdiff's array comparison for the core browser runtime.""" + out = ASSETS / "vendor/jsdiff.esm.js" + unpack("diff", work) + (work / "entry.mjs").write_text( + ( + f"/*! jsdiff {PINS['diff']} — BSD-3-Clause" + " — https://github.com/kpdecker/jsdiff */\n" + 'export { diffArrays } from "./package/libesm/diff/array.js";\n' + ), + encoding="utf-8", + ) + esbuild( + "entry.mjs", + "--bundle", + "--format=esm", + "--minify", + "--legal-comments=inline", + f"--outfile={out}", + cwd=work, + ) + return [out] + + def refuse_if_csp_forbids(out: Path) -> None: """Delete the bundle and stop, if it carries something the page cannot run. @@ -564,6 +589,7 @@ def build_mcp_app(work: Path) -> list[Path]: BUILDS: dict[str, Callable[[Path], list[Path]]] = { "beautiful-mermaid": build_beautiful_mermaid, "highlight": build_highlight, + "jsdiff": build_jsdiff, "mcp-app": build_mcp_app, "plot": build_plot, "pierre": build_pierre, @@ -585,6 +611,7 @@ def vendor(name: str) -> list[Path]: REBUILDS = { **{copy.package: (name,) for name, copy in COPIES.items()}, "highlight.js": ("highlight",), + "diff": ("jsdiff",), "@observablehq/plot": ("plot",), "@pierre/diffs": ("pierre",), "@modelcontextprotocol/ext-apps": ("mcp-app",), diff --git a/skills/leaf/assets/runtime/text-alignment.js b/skills/leaf/assets/runtime/text-alignment.js index 9fc09ebf8..66e83d9be 100644 --- a/skills/leaf/assets/runtime/text-alignment.js +++ b/skills/leaf/assets/runtime/text-alignment.js @@ -9,13 +9,15 @@ * package's theme and a shadow slice, the comparison's by the comment layer's own * stylesheet. */ +import { diffArrays } from "/vendor/jsdiff.esm.js"; + // One lossless text alignment for every widget that needs to explain a sequence of // whole-text states. Segmenter keeps the language-aware units this runtime already -// assumes; a linear-space Hirschberg walk supplies the ordered shared spine. Its -// quadratic *time* is capped: after stripping a common prefix and suffix, a very large -// divergent middle is one replacement instead of a page-freezing attempt at fine-grained -// alignment. Joining same+delete reconstructs `before`, and joining same+insert -// reconstructs `after`, exactly. +// assumes; jsdiff supplies the ordered shared spine. Its edit walk is capped after +// stripping a common prefix and suffix: a very large divergent middle is one +// replacement instead of a page-freezing attempt at fine-grained alignment. Joining +// same+delete reconstructs `before`, and joining same+insert reconstructs `after`, +// exactly. // // The unit is the caller's, because the two texts it holds decide what a difference // between them can mean. Successive edits of one draft differ by words, and words are @@ -29,55 +31,7 @@ export const textUnits = new Intl.Segmenter(undefined, { granularity: "word" }); export const sentenceUnits = new Intl.Segmenter(undefined, { granularity: "sentence", }); -const ALIGN_CELLS = 1_000_000; - -function lcsRow(left, lo, hi, right, rlo, rhi, reverse) { - const width = rhi - rlo; - let previous = new Uint32Array(width + 1); - for (let at = 0; at < hi - lo; at++) { - const current = new Uint32Array(width + 1); - const word = reverse ? left[hi - at - 1] : left[lo + at]; - for (let across = 1; across <= width; across++) { - const other = reverse ? right[rhi - across] : right[rlo + across - 1]; - current[across] = - word === other - ? previous[across - 1] + 1 - : Math.max(previous[across], current[across - 1]); - } - previous = current; - } - return previous; -} - -function lcsMatches(left, lo, hi, right, rlo, rhi, matches) { - if (lo === hi || rlo === rhi) return; - if (hi - lo === 1) { - for (let at = rlo; at < rhi; at++) - if (left[lo] === right[at]) { - matches.push([lo, at]); - break; - } - return; - } - - const middle = lo + Math.floor((hi - lo) / 2); - let split = 0; - { - const forward = lcsRow(left, lo, middle, right, rlo, rhi, false); - const backward = lcsRow(left, middle, hi, right, rlo, rhi, true); - let best = -1; - const width = rhi - rlo; - for (let at = 0; at <= width; at++) { - const score = forward[at] + backward[width - at]; - if (score > best) { - best = score; - split = at; - } - } - } - lcsMatches(left, lo, middle, right, rlo, rlo + split, matches); - lcsMatches(left, middle, hi, right, rlo + split, rhi, matches); -} +const MAX_EDIT_LENGTH = 1_000; export function alignText(before, after, units = textUnits) { const left = [...units.segment(before)].map((part) => part.segment); @@ -108,21 +62,20 @@ export function alignText(before, after, units = textUnits) { push("same", left.slice(0, prefix).join("")); const leftEnd = left.length - suffix; const rightEnd = right.length - suffix; - const matches = []; - if ((leftEnd - prefix) * (rightEnd - prefix) <= ALIGN_CELLS) - lcsMatches(left, prefix, leftEnd, right, prefix, rightEnd, matches); - - let i = prefix; - let j = prefix; - for (const [li, rj] of matches) { - push("delete", left.slice(i, li).join("")); - push("insert", right.slice(j, rj).join("")); - push("same", left[li]); - i = li + 1; - j = rj + 1; + const changes = diffArrays( + left.slice(prefix, leftEnd), + right.slice(prefix, rightEnd), + { maxEditLength: MAX_EDIT_LENGTH }, + ); + if (changes) { + for (const change of changes) { + const kind = change.added ? "insert" : change.removed ? "delete" : "same"; + push(kind, change.value.join("")); + } + } else { + push("delete", left.slice(prefix, leftEnd).join("")); + push("insert", right.slice(prefix, rightEnd).join("")); } - push("delete", left.slice(i, leftEnd).join("")); - push("insert", right.slice(j, rightEnd).join("")); push("same", left.slice(leftEnd).join("")); return runs; } diff --git a/skills/leaf/assets/vendor/jsdiff.esm.js b/skills/leaf/assets/vendor/jsdiff.esm.js new file mode 100644 index 000000000..4889adbb1 --- /dev/null +++ b/skills/leaf/assets/vendor/jsdiff.esm.js @@ -0,0 +1 @@ +var g=class{diff(e,n,t={}){let s;typeof t=="function"?(s=t,t={}):"callback"in t&&(s=t.callback);let d=this.castInput(e,t),u=this.castInput(n,t),a=this.removeEmpty(this.tokenize(d,t)),r=this.removeEmpty(this.tokenize(u,t));return this.diffWithOptionsObj(a,r,t,s)}diffWithOptionsObj(e,n,t,s){var d;let u=i=>{if(i=this.postProcess(i,t),s){setTimeout(function(){s(i)},0);return}else return i},a=n.length,r=e.length,l=1,o=a+r;t.maxEditLength!=null&&(o=Math.min(o,t.maxEditLength));let h=(d=t.timeout)!==null&&d!==void 0?d:1/0,p=Date.now()+h,c=[{oldPos:-1,lastComponent:void 0}],f=this.extractCommon(c[0],n,e,0,t);if(c[0].oldPos+1>=r&&f+1>=a)return u(this.buildValues(c[0].lastComponent,n,e));let x=-1/0,L=1/0,E=()=>{for(let i=Math.max(x,-l);i<=Math.min(L,l);i+=2){let m,P=c[i-1],C=c[i+1];P&&(c[i-1]=void 0);let w=!1;if(C){let D=C.oldPos-i;w=C&&0<=D&&D=r&&f+1>=a)return u(this.buildValues(m.lastComponent,n,e))||!0;c[i]=m,m.oldPos+1>=r&&(L=Math.min(L,i-1)),f+1>=a&&(x=Math.max(x,i+1))}l++};if(s)(function i(){setTimeout(function(){if(l>o||Date.now()>p)return s(void 0);E()||i()},0)})();else for(;l<=o&&Date.now()<=p;){let i=E();if(i)return i}}addToPath(e,n,t,s,d){let u=e.lastComponent;return u&&!d.oneChangePerToken&&u.added===n&&u.removed===t?{oldPos:e.oldPos+s,lastComponent:{count:u.count+1,added:n,removed:t,previousComponent:u.previousComponent}}:{oldPos:e.oldPos+s,lastComponent:{count:1,added:n,removed:t,previousComponent:u}}}extractCommon(e,n,t,s,d){let u=n.length,a=t.length,r=e.oldPos,l=r-s,o=0;for(;l+1p.length?f:p}),o.value=this.join(h)}else o.value=this.join(n.slice(r,r+o.count));r+=o.count,o.added||(l+=o.count)}}return s}};var y=class extends g{tokenize(e){return e.slice()}join(e){return e}removeEmpty(e){return e}},I=new y;function M(v,e,n){return I.diff(v,e,n)}/*! jsdiff 9.0.0 — BSD-3-Clause — https://github.com/kpdecker/jsdiff */export{M as diffArrays}; diff --git a/tests/test_interact_layer.py b/tests/test_interact_layer.py index 903b830b8..60fc2cfff 100644 --- a/tests/test_interact_layer.py +++ b/tests/test_interact_layer.py @@ -853,6 +853,7 @@ def test_init_vendors_the_layer(page_dir): for name in ["leaf.js", "theme.css", "registry.json"]: assert (page_dir / name).is_file() assert (page_dir / "runtime" / "widget-api.js").is_file() + assert (page_dir / "vendor" / "jsdiff.esm.js").is_file() assert (page_dir / "widgets" / "lf-tabs.js").is_file() assert (page_dir / "widgets" / "lf-chart.js").is_file() assert (page_dir / "vendor" / "plot.esm.js").is_file()