Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions scripts/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
27 changes: 27 additions & 0 deletions scripts/vendor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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,
Expand All @@ -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",),
Expand Down
89 changes: 21 additions & 68 deletions skills/leaf/assets/runtime/text-alignment.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
1 change: 1 addition & 0 deletions skills/leaf/assets/vendor/jsdiff.esm.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions tests/test_interact_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading