Release Diff Tools 0.1.0

This commit is contained in:
2026-09-01 02:45:20 +02:00
commit a1380cfba2
65 changed files with 11442 additions and 0 deletions
+35
View File
@@ -0,0 +1,35 @@
import { lazy, Suspense, useState } from "react";
import { AppShell } from "@add-ideas/toolbox-shell-react";
import "@add-ideas/toolbox-shell-react/styles.css";
import "./styles.css";
import { ErrorBoundary } from "./components/ErrorBoundary";
import { HelpDialog } from "./components/HelpDialog";
import { manifest } from "./toolbox/manifest";
const Workbench = lazy(async () => ({
default: (await import("./components/Workbench")).Workbench,
}));
export function App() {
const [helpOpen, setHelpOpen] = useState(false);
return (
<ErrorBoundary>
<AppShell
app={manifest}
manifestUrl="./toolbox-app.json"
helpAction={{ onClick: () => setHelpOpen(true) }}
>
<Suspense
fallback={
<p className="loading" role="status">
Preparing Diff Tools
</p>
}
>
<Workbench />
</Suspense>
</AppShell>
<HelpDialog open={helpOpen} onClose={() => setHelpOpen(false)} />
</ErrorBoundary>
);
}
+27
View File
@@ -0,0 +1,27 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
export class ErrorBoundary extends Component<
{ children: ReactNode },
{ error?: Error }
> {
state: { error?: Error } = {};
static getDerivedStateFromError(error: Error) {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Application failure", error, info);
}
render() {
if (this.state.error)
return (
<main className="fatal">
<h1>Diff Tools could not continue</h1>
<p>{this.state.error.message}</p>
<button type="button" onClick={() => location.reload()}>
Reload
</button>
</main>
);
return this.props.children;
}
}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useRef } from "react";
export function HelpDialog({
open,
onClose,
}: {
open: boolean;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
useEffect(() => {
const node = dialog.current;
if (!node) return;
if (open && !node.open) node.showModal();
if (!open && node.open) node.close();
}, [open]);
return (
<dialog
ref={dialog}
className="help-dialog"
onClose={onClose}
onCancel={onClose}
aria-labelledby="help-title"
>
<div className="dialog-heading">
<div>
<p className="eyebrow">Local-first help</p>
<h2 id="help-title">About Diff Tools</h2>
</div>
<button type="button" onClick={onClose} aria-label="Close help">
×
</button>
</div>
<p>
Compare exact text or the structure of JSON, XML, CSV and TSV locally.
</p>
<ul>
<li>Text keeps CRLF, LF, CR and final-newline state exact.</li>
<li>JSON uses exact decimals and can export an RFC 6902 patch.</li>
<li>XML rejects DOCTYPE, entities and XInclude before parsing.</li>
<li>
CSV rows are matched by unique key columns and fields remain strings.
</li>
</ul>
<p>
Ignored and normalized differences remain visible. All processing is
performed in this browser, within explicit size, depth and output
limits.
</p>
</dialog>
);
}
+999
View File
@@ -0,0 +1,999 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type ChangeEvent,
} from "react";
import { DIFF_LIMITS } from "../core/limits";
import { createCompareTask, type CompareTask } from "../core/worker-client";
import {
DEFAULT_OPTIONS,
DiffToolsError,
type CompareOptions,
type CompareRequest,
type Diagnostic,
type DiffMode,
type DiffResult,
type DisplayRow,
type TextGranularity,
type UnicodeNormalization,
} from "../core/types";
type ResultView = "unified" | "side-by-side" | "report" | "patch";
type Side = "left" | "right";
interface InputPair {
left: string;
right: string;
leftName?: string;
rightName?: string;
}
const MODES: Array<{ id: DiffMode; label: string; hint: string }> = [
{ id: "text", label: "Text", hint: "Lines to graphemes" },
{ id: "json", label: "JSON", hint: "Semantic & exact" },
{ id: "xml", label: "XML", hint: "Namespace-aware" },
{ id: "csv", label: "CSV / TSV", hint: "Keyed rows" },
];
const RESULT_VIEWS: Array<{ id: ResultView; label: string }> = [
{ id: "unified", label: "Unified" },
{ id: "side-by-side", label: "Side by side" },
{ id: "report", label: "JSON report" },
{ id: "patch", label: "Patches" },
];
const SAMPLE_INPUTS: Record<DiffMode, InputPair> = {
text: {
left: "Heading\r\nThe quick brown fox.\r\nNo final newline",
right: "Heading\nThe quick copper fox.\nA new line.\n",
},
json: {
left: '{\n "amount": 1.00,\n "name": "Ada",\n "active": true\n}',
right:
'{\n "name": "Ada Lovelace",\n "amount": 1e0,\n "active": true,\n "role": "mathematics"\n}',
},
xml: {
left: '<a:catalog xmlns:a="urn:books" id="7"><a:item price="10">First</a:item><!--draft--></a:catalog>',
right:
'<b:catalog id="7" xmlns:b="urn:books"><b:item price="12"><![CDATA[First]]></b:item><!--final--></b:catalog>',
},
csv: {
left: "id,name,value\n2,Grace,001\n1,Ada,10",
right: "value,id,name\n10,1,Ada\n002,2,Grace\n20,3,Linus",
},
};
function initialRoute(): { mode: DiffMode; view: ResultView } {
const [modeValue, viewValue] = (globalThis.location?.hash ?? "")
.replace(/^#/, "")
.split("/");
const mode = MODES.some((item) => item.id === modeValue)
? (modeValue as DiffMode)
: "text";
const view = RESULT_VIEWS.some((item) => item.id === viewValue)
? (viewValue as ResultView)
: "unified";
return { mode, view };
}
function visibleText(value: string | undefined): string {
if (value === undefined) return "";
return value.replace(/\r\n|\r|\n|\t/gu, (token) => {
if (token === "\r\n") return "␍␊\n";
if (token === "\r") return "␍\n";
if (token === "\n") return "␊\n";
return "⇥";
});
}
function bytes(value: number): string {
return value < 1_024
? `${value} B`
: value < 1_048_576
? `${(value / 1_024).toFixed(1)} KiB`
: `${(value / 1_048_576).toFixed(1)} MiB`;
}
function fileExtension(mode: DiffMode, artifact: "report" | "patch"): string {
if (artifact === "report") return "json";
return mode === "json" ? "patch" : "diff";
}
function download(value: string, name: string): void {
const blob = new Blob([value], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = name;
anchor.hidden = true;
anchor.rel = "noopener";
document.body.append(anchor);
try {
anchor.click();
} finally {
anchor.remove();
queueMicrotask(() => URL.revokeObjectURL(url));
}
}
function Toggle({
checked,
disabled,
label,
hint,
onChange,
}: {
checked: boolean;
disabled?: boolean;
label: string;
hint?: string;
onChange: (checked: boolean) => void;
}) {
return (
<label className="toggle">
<input
type="checkbox"
checked={checked}
disabled={disabled}
onChange={(event) => onChange(event.target.checked)}
/>
<span>
<strong>{label}</strong>
{hint ? <small>{hint}</small> : null}
</span>
</label>
);
}
function Options({
mode,
options,
setOptions,
}: {
mode: DiffMode;
options: CompareOptions;
setOptions: React.Dispatch<React.SetStateAction<CompareOptions>>;
}) {
if (mode === "text")
return (
<div className="option-grid" aria-label="Text comparison options">
<label className="field">
<span>Granularity</span>
<select
value={options.text.granularity}
onChange={(event) =>
setOptions((current) => ({
...current,
text: {
...current.text,
granularity: event.target.value as TextGranularity,
},
}))
}
>
<option value="line">Lines</option>
<option value="word">Words</option>
<option value="code-point">Unicode code points</option>
<option value="grapheme">Grapheme clusters</option>
</select>
</label>
<label className="field">
<span>Unicode normalization</span>
<select
value={options.text.unicodeNormalization}
onChange={(event) =>
setOptions((current) => ({
...current,
text: {
...current.text,
unicodeNormalization: event.target
.value as UnicodeNormalization,
},
}))
}
>
<option value="none">None</option>
<option value="NFC">NFC</option>
<option value="NFD">NFD</option>
<option value="NFKC">NFKC</option>
<option value="NFKD">NFKD</option>
</select>
</label>
<Toggle
checked={options.text.ignoreCase}
label="Ignore case"
onChange={(checked) =>
setOptions((current) => ({
...current,
text: { ...current.text, ignoreCase: checked },
}))
}
/>
<Toggle
checked={options.text.ignoreWhitespace}
label="Normalize whitespace"
hint="Differences remain visible as normalized rows"
onChange={(checked) =>
setOptions((current) => ({
...current,
text: { ...current.text, ignoreWhitespace: checked },
}))
}
/>
<Toggle
checked={options.text.ignoreLineEndingStyle}
label="Ignore CRLF / LF / CR style"
onChange={(checked) =>
setOptions((current) => ({
...current,
text: { ...current.text, ignoreLineEndingStyle: checked },
}))
}
/>
<Toggle
checked={options.text.ignoreFinalNewline}
label="Ignore final newline"
onChange={(checked) =>
setOptions((current) => ({
...current,
text: { ...current.text, ignoreFinalNewline: checked },
}))
}
/>
</div>
);
if (mode === "json")
return (
<div className="option-grid" aria-label="JSON comparison options">
<label className="field">
<span>Number comparison</span>
<select
value={options.json.numberComparison}
onChange={(event) =>
setOptions((current) => ({
...current,
json: {
...current.json,
numberComparison: event.target.value as "numeric" | "lexical",
},
}))
}
>
<option value="numeric">Exact decimal value</option>
<option value="lexical">Exact source lexeme</option>
</select>
</label>
<Toggle
checked
disabled
label="Object member order ignored"
hint="Order differences are retained as normalized rows"
onChange={() => undefined}
/>
<p className="option-note">
Duplicate and prototype-affecting keys are rejected. RFC 6902 output
retains number lexemes without binary rounding.
</p>
</div>
);
if (mode === "xml")
return (
<div className="option-grid" aria-label="XML comparison options">
<Toggle
checked={options.xml.ignoreNamespacePrefixes}
label="Compare expanded names"
hint="Ignore prefixes, keep URI + local name"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, ignoreNamespacePrefixes: checked },
}))
}
/>
<Toggle
checked={options.xml.ignoreAttributeOrder}
label="Ignore attribute order"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, ignoreAttributeOrder: checked },
}))
}
/>
<Toggle
checked={options.xml.normalizeCdata}
label="Compare CDATA as text"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, normalizeCdata: checked },
}))
}
/>
<Toggle
checked={options.xml.ignoreComments}
label="Ignore comments"
hint="Ignored comment changes remain visible"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, ignoreComments: checked },
}))
}
/>
<Toggle
checked={options.xml.trimText}
label="Trim text edges"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, trimText: checked },
}))
}
/>
<Toggle
checked={options.xml.collapseWhitespace}
label="Collapse text whitespace"
onChange={(checked) =>
setOptions((current) => ({
...current,
xml: { ...current.xml, collapseWhitespace: checked },
}))
}
/>
</div>
);
return (
<div
className="option-grid csv-options"
aria-label="CSV comparison options"
>
<label className="field">
<span>Delimiter</span>
<select
value={options.csv.delimiter}
onChange={(event) =>
setOptions((current) => ({
...current,
csv: {
...current.csv,
delimiter: event.target.value as "auto" | "comma" | "tab",
},
}))
}
>
<option value="auto">Detect comma or tab</option>
<option value="comma">Comma</option>
<option value="tab">Tab</option>
</select>
</label>
<label className="field">
<span>Key column names</span>
<input
value={options.csv.keyColumns}
placeholder="id (first column when empty)"
onChange={(event) =>
setOptions((current) => ({
...current,
csv: { ...current.csv, keyColumns: event.target.value },
}))
}
/>
</label>
<Toggle
checked
disabled
label="Match rows by key"
hint="Row order remains visible when it differs"
onChange={() => undefined}
/>
</div>
);
}
function Diagnostics({ diagnostics }: { diagnostics: Diagnostic[] }) {
if (!diagnostics.length) return null;
return (
<ul className="diagnostics" aria-label="Comparison diagnostics">
{diagnostics.map((diagnostic, index) => (
<li
key={`${diagnostic.code}-${index}`}
className={`diagnostic diagnostic--${diagnostic.severity}`}
>
<strong>{diagnostic.code}</strong>
<span>{diagnostic.message}</span>
{diagnostic.line ? <small>Line {diagnostic.line}</small> : null}
</li>
))}
</ul>
);
}
function RowText({ value, omitted }: { value?: string; omitted?: number }) {
return (
<pre>
{visibleText(value)}
{omitted ? `\n[${omitted.toLocaleString()} characters omitted]` : ""}
</pre>
);
}
function UnifiedRows({ rows }: { rows: DisplayRow[] }) {
if (!rows.length)
return <p className="empty-result">No semantic differences.</p>;
return (
<div className="unified-diff" role="list" aria-label="Unified differences">
{rows.map((row, index) => (
<article
role="listitem"
className={`diff-row diff-row--${row.kind}`}
key={`${row.path ?? "text"}-${index}`}
>
<div className="diff-row__meta">
<span className="kind-badge">{row.kind}</span>
<code>
{row.path ?? `L${row.leftLine ?? ""} → L${row.rightLine ?? ""}`}
</code>
{row.detail ? <span>{row.detail}</span> : null}
</div>
{row.kind === "modified" || row.kind === "normalized" ? (
<div className="paired-lines">
<div data-prefix="">
<RowText value={row.left} omitted={row.omittedLeft} />
</div>
<div data-prefix="+">
<RowText value={row.right} omitted={row.omittedRight} />
</div>
</div>
) : (
<div
className="single-line"
data-prefix={
row.kind === "added" ? "+" : row.kind === "removed" ? "" : " "
}
>
<RowText
value={row.right ?? row.left}
omitted={row.omittedRight ?? row.omittedLeft}
/>
</div>
)}
</article>
))}
</div>
);
}
function SideBySideRows({ rows }: { rows: DisplayRow[] }) {
if (!rows.length)
return <p className="empty-result">No semantic differences.</p>;
return (
<div
className="side-diff"
role="table"
aria-label="Side-by-side differences"
>
<div className="side-diff__header" role="row">
<strong role="columnheader">Before</strong>
<strong role="columnheader">After</strong>
</div>
{rows.map((row, index) => (
<div
className={`side-row side-row--${row.kind}`}
role="row"
key={`${row.path ?? "text"}-${index}`}
>
<div role="cell">
<small>{row.path ?? row.leftLine ?? ""}</small>
<RowText value={row.left} omitted={row.omittedLeft} />
</div>
<div role="cell">
<small>{row.path ?? row.rightLine ?? ""}</small>
<RowText value={row.right} omitted={row.omittedRight} />
</div>
</div>
))}
</div>
);
}
export function Workbench() {
const route = initialRoute();
const [mode, setMode] = useState<DiffMode>(route.mode);
const [view, setView] = useState<ResultView>(route.view);
const [inputs, setInputs] =
useState<Record<DiffMode, InputPair>>(SAMPLE_INPUTS);
const [options, setOptions] = useState<CompareOptions>(DEFAULT_OPTIONS);
const [results, setResults] = useState<Partial<Record<DiffMode, DiffResult>>>(
{},
);
const [failure, setFailure] = useState<Diagnostic[]>([]);
const [status, setStatus] = useState<"idle" | "pending" | "ready" | "error">(
"idle",
);
const [statusText, setStatusText] = useState("Ready to compare locally.");
const [notice, setNotice] = useState("");
const task = useRef<CompareTask | undefined>(undefined);
const sequence = useRef(0);
const input = inputs[mode];
const result = results[mode];
const compare = useCallback((request: CompareRequest) => {
task.current?.cancel();
const currentSequence = ++sequence.current;
const nextTask = createCompareTask(request);
task.current = nextTask;
setStatus("pending");
setStatusText("Comparing in an isolated worker…");
void nextTask.promise
.then((nextResult) => {
if (currentSequence !== sequence.current) return;
setResults((current) => ({ ...current, [request.mode]: nextResult }));
setFailure([]);
setStatus("ready");
setStatusText(
nextResult.semanticallyEqual
? "Semantically equal under the visible options."
: `${nextResult.stats.added + nextResult.stats.removed + nextResult.stats.modified} substantive change row(s).`,
);
})
.catch((error: unknown) => {
if (
currentSequence !== sequence.current ||
(error instanceof DOMException && error.name === "AbortError")
)
return;
setStatus("error");
setStatusText(
error instanceof Error ? error.message : "Comparison failed.",
);
setFailure(
error instanceof DiffToolsError
? error.diagnostics
: [
{
code: "compare.failure",
message:
error instanceof Error
? error.message
: "Comparison failed.",
severity: "error",
side: "both",
},
],
);
});
}, []);
const request = useMemo<CompareRequest>(
() => ({
mode,
left: input.left,
right: input.right,
leftName: input.leftName,
rightName: input.rightName,
options,
}),
[input, mode, options],
);
useEffect(() => {
const timer = window.setTimeout(() => compare(request), 480);
return () => window.clearTimeout(timer);
}, [compare, request]);
useEffect(() => () => task.current?.cancel(), []);
useEffect(() => {
const onHash = () => {
const next = initialRoute();
setMode(next.mode);
setView(next.view);
};
globalThis.addEventListener("hashchange", onHash);
return () => globalThis.removeEventListener("hashchange", onHash);
}, []);
function setRoute(nextMode: DiffMode, nextView: ResultView): void {
setMode(nextMode);
setView(nextView);
history.replaceState(
null,
"",
`${location.pathname}${location.search}#${nextMode}/${nextView}`,
);
}
function updateInput(side: Side, value: string, name?: string): void {
setInputs((current) => ({
...current,
[mode]: {
...current[mode],
[side]: value,
[`${side}Name`]: name,
},
}));
}
async function openFile(side: Side, file: File): Promise<void> {
try {
if (file.size > DIFF_LIMITS.maxInputBytesPerSide)
throw new RangeError(
`File size exceeds ${bytes(DIFF_LIMITS.maxInputBytesPerSide)}.`,
);
const text = new TextDecoder("utf-8", { fatal: true }).decode(
await file.arrayBuffer(),
);
updateInput(side, text, file.name);
} catch (error) {
const message =
error instanceof Error ? error.message : "The file could not be read.";
setStatus("error");
setStatusText(message);
setFailure([
{
code:
error instanceof RangeError
? "file.size-limit"
: "file.invalid-utf8",
message,
severity: "error",
side,
},
]);
}
}
function fileChanged(side: Side, event: ChangeEvent<HTMLInputElement>): void {
const file = event.target.files?.[0];
if (file) void openFile(side, file);
event.target.value = "";
}
async function copy(value: string | undefined, label: string): Promise<void> {
if (!value) return;
try {
await navigator.clipboard.writeText(value);
setNotice(`${label} copied.`);
} catch {
setNotice("Clipboard access was denied; select the text manually.");
}
}
const diagnostics =
status === "error" ? failure : (result?.diagnostics ?? []);
return (
<main className="workbench">
<header className="hero">
<div>
<p className="eyebrow">Private comparison workbench</p>
<h1>Diff Tools</h1>
<p>
Compare text and structured data with every ignored or normalized
difference kept visible.
</p>
</div>
<span className="privacy-pill">Browser-local</span>
</header>
<nav className="mode-tabs" aria-label="Comparison modes">
<div role="tablist" aria-label="Comparison modes">
{MODES.map((item) => (
<button
key={item.id}
type="button"
role="tab"
aria-label={`${item.label} ${item.hint}`}
aria-selected={mode === item.id}
onClick={() => setRoute(item.id, view)}
>
<span>{item.label}</span>
<small>{item.hint}</small>
</button>
))}
</div>
</nav>
<section className="panel options-panel" aria-labelledby="options-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Visible semantics</p>
<h2 id="options-title">Comparison options</h2>
</div>
</div>
<Options mode={mode} options={options} setOptions={setOptions} />
</section>
<section className="input-grid" aria-label="Comparison inputs">
{(["left", "right"] as const).map((side) => (
<article className="panel input-panel" key={side}>
<div className="panel-heading">
<div>
<p className="eyebrow">
{side === "left" ? "Before" : "After"}
</p>
<h2>
{input[`${side}Name`] ??
(side === "left" ? "Original" : "Changed")}
</h2>
</div>
<label className="button file-button">
Open file
<input
type="file"
onChange={(event) => fileChanged(side, event)}
data-testid={`${side}-file-input`}
/>
</label>
</div>
<label className="field">
<span>{side === "left" ? "Before text" : "After text"}</span>
<textarea
value={input[side]}
onChange={(event) => updateInput(side, event.target.value)}
spellCheck={false}
data-testid={`${side}-editor`}
/>
</label>
</article>
))}
</section>
<div className="compare-bar panel">
<button
type="button"
onClick={() =>
setInputs((current) => ({
...current,
[mode]: {
left: current[mode].right,
right: current[mode].left,
leftName: current[mode].rightName,
rightName: current[mode].leftName,
},
}))
}
>
Swap sides
</button>
<button
type="button"
className="primary-button"
onClick={() => compare(request)}
>
Compare now
</button>
<p role="status" aria-live="polite" data-status={status}>
{statusText}
</p>
</div>
<Diagnostics diagnostics={diagnostics} />
{result ? (
<section className="results" aria-label="Comparison result">
<div className="summary-grid">
<article className="summary-card summary-card--verdict">
<span>Verdict</span>
<strong>
{result.exactlyEqual
? "Exactly equal"
: result.semanticallyEqual
? "Semantically equal"
: "Different"}
</strong>
<small>
{result.exactlyEqual
? "Source text matches"
: "Exact source differs"}
</small>
</article>
{(["added", "removed", "modified", "normalized"] as const).map(
(kind) => (
<article
className={`summary-card summary-card--${kind}`}
key={kind}
>
<span>{kind}</span>
<strong>{result.stats[kind]}</strong>
<small>
display row{result.stats[kind] === 1 ? "" : "s"}
</small>
</article>
),
)}
</div>
<section className="metadata panel" aria-labelledby="metadata-title">
<div className="panel-heading">
<div>
<p className="eyebrow">Exact source state</p>
<h2 id="metadata-title">Newlines and size</h2>
</div>
</div>
<div className="metadata-grid">
{[result.left, result.right].map((item, index) => (
<dl key={index}>
<div>
<dt>Side</dt>
<dd>{index === 0 ? "Before" : "After"}</dd>
</div>
<div>
<dt>Size</dt>
<dd>{bytes(item.bytes)}</dd>
</div>
<div>
<dt>Lines</dt>
<dd>{item.lines}</dd>
</div>
<div>
<dt>LF</dt>
<dd>{item.newlines.lf}</dd>
</div>
<div>
<dt>CRLF</dt>
<dd>{item.newlines.crlf}</dd>
</div>
<div>
<dt>CR</dt>
<dd>{item.newlines.cr}</dd>
</div>
<div>
<dt>Final newline</dt>
<dd>{item.newlines.final}</dd>
</div>
</dl>
))}
</div>
<div className="normalization-list">
<strong>Applied comparison rules</strong>
{result.appliedNormalizations.length ? (
result.appliedNormalizations.map((item) => (
<span key={item}>{item}</span>
))
) : (
<span>Exact source comparison</span>
)}
</div>
</section>
<nav className="result-tabs" aria-label="Result views">
<div role="tablist" aria-label="Result views">
{RESULT_VIEWS.map((item) => (
<button
type="button"
role="tab"
aria-selected={view === item.id}
key={item.id}
onClick={() => setRoute(mode, item.id)}
>
{item.label}
</button>
))}
</div>
</nav>
<section className="panel result-panel" role="tabpanel">
{view === "unified" ? <UnifiedRows rows={result.rows} /> : null}
{view === "side-by-side" ? (
<SideBySideRows rows={result.rows} />
) : null}
{view === "report" ? (
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Portable artifact</p>
<h2>JSON report</h2>
</div>
<div>
<button
type="button"
onClick={() => void copy(result.report, "JSON report")}
>
Copy
</button>
<button
type="button"
className="primary-button"
onClick={() =>
download(
result.report,
`diff-report.${fileExtension(mode, "report")}`,
)
}
>
Download
</button>
</div>
</div>
<textarea
readOnly
value={result.report}
data-testid="json-report"
/>
</div>
) : null}
{view === "patch" ? (
<div className="patch-grid">
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Exact source transform</p>
<h2>Unified patch</h2>
</div>
<div>
<button
type="button"
onClick={() =>
void copy(result.unifiedPatch, "Unified patch")
}
disabled={!result.unifiedPatch}
>
Copy
</button>
<button
type="button"
className="primary-button"
disabled={!result.unifiedPatch}
onClick={() =>
result.unifiedPatch &&
download(
result.unifiedPatch,
`changes.${fileExtension(mode, "patch")}`,
)
}
>
Download
</button>
</div>
</div>
{result.unifiedPatch ? (
<textarea
readOnly
value={result.unifiedPatch}
data-testid="unified-patch"
/>
) : (
<p className="empty-result">
Patch unavailable at the configured safety limit.
</p>
)}
</div>
{mode === "json" ? (
<div className="artifact">
<div className="artifact-heading">
<div>
<p className="eyebrow">Semantic transform</p>
<h2>RFC 6902 JSON Patch</h2>
</div>
<button
type="button"
onClick={() =>
void copy(result.jsonPatch, "JSON Patch")
}
>
Copy
</button>
</div>
<textarea
readOnly
value={result.jsonPatch ?? ""}
data-testid="json-patch"
/>
</div>
) : null}
</div>
) : null}
</section>
</section>
) : null}
<p className="action-notice" role="status" aria-live="polite">
{notice}
</p>
</main>
);
}
+222
View File
@@ -0,0 +1,222 @@
import { createTwoFilesPatch } from "diff";
import { compareCsv } from "./csv-diff";
import { compareJson } from "./json-diff";
import {
assertInput,
assertOutput,
DIFF_LIMITS,
inputMetadata,
} from "./limits";
import { compareText } from "./text-diff";
import {
DiffToolsError,
type CompareRequest,
type Diagnostic,
type DiffResult,
type DiffStats,
type DisplayRow,
} from "./types";
import { compareXml } from "./xml-diff";
function safePatchName(value: string | undefined, fallback: string): string {
return (value || fallback)
.replaceAll(String.fromCodePoint(0), "_")
.replace(/[\r\n]/gu, "_")
.replace(/^\/+|\.\./gu, "_")
.slice(0, 180);
}
function makePatch(
request: CompareRequest,
diagnostics: Diagnostic[],
): string | undefined {
const patch = createTwoFilesPatch(
safePatchName(request.leftName, "before.txt"),
safePatchName(request.rightName, "after.txt"),
request.left,
request.right,
"before",
"after",
{
context: 3,
timeout: DIFF_LIMITS.diffTimeoutMilliseconds,
maxEditLength: DIFF_LIMITS.maxEditLength,
},
);
if (patch === undefined) {
diagnostics.push({
code: "patch.complexity-limit",
message:
"The exact unified patch exceeded the edit-distance or time limit.",
severity: "warning",
side: "both",
});
return undefined;
}
try {
return assertOutput(patch, "Unified patch length");
} catch (error) {
diagnostics.push({
code: "patch.output-limit",
message:
error instanceof Error ? error.message : "Patch output limit reached.",
severity: "warning",
side: "both",
});
return undefined;
}
}
function stats(rows: DisplayRow[]): DiffStats {
const result: DiffStats = {
equal: 0,
added: 0,
removed: 0,
modified: 0,
normalized: 0,
};
for (const row of rows) result[row.kind] += 1;
return result;
}
function reportText(
request: CompareRequest,
result: Omit<DiffResult, "report">,
): string {
const create = (rows: DisplayRow[], omittedRows: number) =>
JSON.stringify(
{
schema: "de.add-ideas.diff-tools.report.v1",
schemaVersion: 1,
generatedLocally: true,
mode: result.mode,
exactlyEqual: result.exactlyEqual,
semanticallyEqual: result.semanticallyEqual,
inputs: { left: result.left, right: result.right },
options: request.options[result.mode],
appliedNormalizations: result.appliedNormalizations,
statistics: result.stats,
diagnostics: result.diagnostics,
changes: rows,
omittedChanges: omittedRows,
artifacts: {
unifiedPatchAvailable: Boolean(result.unifiedPatch),
jsonPatchAvailable: Boolean(result.jsonPatch),
},
},
null,
2,
) + "\n";
let rows = result.rows;
let output = create(rows, 0);
if (output.length > DIFF_LIMITS.maxOutputCharacters) {
rows = result.rows.slice(0, 1_000);
output = create(rows, result.rows.length - rows.length);
}
return assertOutput(output, "Portable report length");
}
export function compareInputs(request: CompareRequest): DiffResult {
const leftBytes = assertInput(request.left, "left");
const rightBytes = assertInput(request.right, "right");
let rows: DisplayRow[];
let diagnostics: Diagnostic[] = [];
let appliedNormalizations: string[] = [];
let jsonPatch: string | undefined;
switch (request.mode) {
case "text": {
const output = compareText(
request.left,
request.right,
request.options.text,
);
rows = output.rows;
appliedNormalizations = output.appliedNormalizations;
break;
}
case "json": {
const output = compareJson(
request.left,
request.right,
request.options.json,
);
rows = output.rows;
diagnostics = output.diagnostics;
appliedNormalizations = output.appliedNormalizations;
jsonPatch = assertOutput(output.jsonPatch, "JSON Patch length");
break;
}
case "xml": {
const output = compareXml(
request.left,
request.right,
request.options.xml,
);
rows = output.rows;
diagnostics = output.diagnostics;
appliedNormalizations = output.appliedNormalizations;
break;
}
case "csv": {
const output = compareCsv(
request.left,
request.right,
request.options.csv,
);
rows = output.rows;
diagnostics = output.diagnostics;
appliedNormalizations = output.appliedNormalizations;
break;
}
}
const calculatedStats = stats(rows);
const unifiedPatch = makePatch(request, diagnostics);
const semanticallyEqual =
calculatedStats.added === 0 &&
calculatedStats.removed === 0 &&
calculatedStats.modified === 0;
const partial: Omit<DiffResult, "report"> = {
schemaVersion: 1,
mode: request.mode,
exactlyEqual: request.left === request.right,
semanticallyEqual,
rows,
stats: calculatedStats,
diagnostics,
appliedNormalizations,
left: inputMetadata(
request.left,
leftBytes,
safePatchName(request.leftName, "Before"),
),
right: inputMetadata(
request.right,
rightBytes,
safePatchName(request.rightName, "After"),
),
unifiedPatch,
jsonPatch,
};
return { ...partial, report: reportText(request, partial) };
}
export function serializeFailure(error: unknown): {
message: string;
diagnostics: Diagnostic[];
} {
if (error instanceof DiffToolsError)
return { message: error.message, diagnostics: error.diagnostics };
return {
message: error instanceof Error ? error.message : "Comparison failed.",
diagnostics: [
{
code: "compare.failure",
message: error instanceof Error ? error.message : "Comparison failed.",
severity: "error",
side: "both",
},
],
};
}
+318
View File
@@ -0,0 +1,318 @@
import Papa from "papaparse";
import { addRow, DIFF_LIMITS, DiffLimitError } from "./limits";
import {
DiffToolsError,
type CsvOptions,
type Diagnostic,
type DisplayRow,
} from "./types";
interface CsvTable {
headers: string[];
rows: string[][];
delimiter: string;
}
function parseCsv(
source: string,
side: "left" | "right",
options: CsvOptions,
diagnostics: Diagnostic[],
): CsvTable {
const delimiter =
options.delimiter === "comma"
? ","
: options.delimiter === "tab"
? "\t"
: "";
const result = Papa.parse<string[]>(source, {
delimiter,
dynamicTyping: false,
header: false,
skipEmptyLines: false,
worker: false,
});
const fatal = result.errors.filter((error) => error.type === "Quotes");
if (fatal.length)
throw new DiffToolsError(
`Invalid ${side} delimited data.`,
fatal.map((error) => ({
code: `csv.${error.code.toLowerCase()}`,
message: error.message,
severity: "error" as const,
side,
line: error.row === undefined ? undefined : error.row + 1,
})),
);
diagnostics.push(
...result.errors.slice(0, 20).map((error) => ({
code: `csv.${error.code.toLowerCase()}`,
message: error.message,
severity: "warning" as const,
side,
line: error.row === undefined ? undefined : error.row + 1,
})),
);
if (!result.data.length)
return {
headers: [],
rows: [],
delimiter: result.meta.delimiter || delimiter || ",",
};
const headers = result.data[0] ?? [];
const duplicateHeaders = headers.filter(
(header, index) => headers.indexOf(header) !== index,
);
if (duplicateHeaders.length)
throw new DiffToolsError(`The ${side} header contains duplicate names.`, [
{
code: "csv.duplicate-header",
message: `Duplicate ${side} header name(s): ${[...new Set(duplicateHeaders)].join(", ")}.`,
severity: "error",
side,
line: 1,
},
]);
const rows = result.data.slice(1);
if (rows.length > DIFF_LIMITS.maxCsvRows)
throw new DiffLimitError(
`${side} CSV row count`,
rows.length,
DIFF_LIMITS.maxCsvRows,
);
if (headers.length > DIFF_LIMITS.maxCsvColumns)
throw new DiffLimitError(
`${side} CSV column count`,
headers.length,
DIFF_LIMITS.maxCsvColumns,
);
let cells = headers.length;
for (const row of rows) {
cells += row.length;
if (cells > DIFF_LIMITS.maxCsvCells)
throw new DiffLimitError(
`${side} CSV cell count`,
cells,
DIFF_LIMITS.maxCsvCells,
);
if (row.length > DIFF_LIMITS.maxCsvColumns)
throw new DiffLimitError(
`${side} CSV column count`,
row.length,
DIFF_LIMITS.maxCsvColumns,
);
for (const value of row)
if (value.length > DIFF_LIMITS.maxFieldCharacters)
throw new DiffLimitError(
`${side} CSV field length`,
value.length,
DIFF_LIMITS.maxFieldCharacters,
);
}
return {
headers,
rows,
delimiter: result.meta.delimiter || delimiter || ",",
};
}
function resolveKeyColumns(
before: CsvTable,
after: CsvTable,
setting: string,
): string[] {
const requested = setting
.split(",")
.map((value) => value.trim())
.filter(Boolean);
const keys = requested.length
? requested
: [before.headers[0] ?? after.headers[0] ?? ""];
if (!keys[0])
throw new DiffToolsError("A key column is required.", [
{
code: "csv.key-required",
message:
"Both tables are empty or have no header from which to choose a key.",
severity: "error",
side: "both",
},
]);
for (const key of keys)
if (!before.headers.includes(key) || !after.headers.includes(key))
throw new DiffToolsError(`Key column “${key}” is missing.`, [
{
code: "csv.key-missing",
message: `Key column “${key}” must exist in both headers.`,
severity: "error",
side: "both",
},
]);
return keys;
}
function rowRecord(table: CsvTable, row: string[]): Record<string, string> {
const record: Record<string, string> = Object.create(null) as Record<
string,
string
>;
table.headers.forEach((header, index) => {
record[header] = row[index] ?? "";
});
return record;
}
function keyFor(table: CsvTable, row: string[], keys: string[]): string {
return JSON.stringify(
keys.map((key) => row[table.headers.indexOf(key)] ?? ""),
);
}
function keyedRows(
table: CsvTable,
keys: string[],
side: "left" | "right",
): { order: string[]; records: Map<string, Record<string, string>> } {
const order: string[] = [];
const records = new Map<string, Record<string, string>>();
for (const [index, row] of table.rows.entries()) {
const key = keyFor(table, row, keys);
if (records.has(key))
throw new DiffToolsError(`Duplicate key in ${side} CSV.`, [
{
code: "csv.duplicate-key",
message: `The key ${key} occurs more than once in the ${side} table.`,
severity: "error",
side,
line: index + 2,
},
]);
order.push(key);
records.set(key, rowRecord(table, row));
}
return { order, records };
}
function recordText(record: Record<string, string>): string {
return JSON.stringify(record);
}
export interface CsvDiffOutput {
rows: DisplayRow[];
diagnostics: Diagnostic[];
appliedNormalizations: string[];
}
export function compareCsv(
left: string,
right: string,
options: CsvOptions,
): CsvDiffOutput {
const diagnostics: Diagnostic[] = [];
const before = parseCsv(left, "left", options, diagnostics);
const after = parseCsv(right, "right", options, diagnostics);
const keys = resolveKeyColumns(before, after, options.keyColumns);
const leftRows = keyedRows(before, keys, "left");
const rightRows = keyedRows(after, keys, "right");
const rows: DisplayRow[] = [];
const beforeHeaderSet = new Set(before.headers);
const afterHeaderSet = new Set(after.headers);
const commonBeforeHeaders = before.headers.filter((header) =>
afterHeaderSet.has(header),
);
const commonAfterHeaders = after.headers.filter((header) =>
beforeHeaderSet.has(header),
);
if (commonBeforeHeaders.join("\u0000") !== commonAfterHeaders.join("\u0000"))
addRow(rows, {
kind: "normalized",
path: "$columns",
left: before.headers.join(", "),
right: after.headers.join(", "),
detail: "Column order is ignored during keyed comparison.",
});
for (const header of before.headers)
if (!after.headers.includes(header))
addRow(rows, {
kind: "removed",
path: `$columns/${header}`,
left: header,
detail: "Column removed.",
});
for (const header of after.headers)
if (!before.headers.includes(header))
addRow(rows, {
kind: "added",
path: `$columns/${header}`,
right: header,
detail: "Column added.",
});
const leftKeySet = new Set(leftRows.order);
const rightKeySet = new Set(rightRows.order);
const commonLeftKeys = leftRows.order.filter((key) => rightKeySet.has(key));
const commonRightKeys = rightRows.order.filter((key) => leftKeySet.has(key));
if (commonLeftKeys.join("\u0000") !== commonRightKeys.join("\u0000"))
addRow(rows, {
kind: "normalized",
path: "$rows",
left: leftRows.order.join(", "),
right: rightRows.order.join(", "),
detail: "Row order is ignored because rows are matched by key.",
});
for (const key of leftRows.order) {
const beforeRecord = leftRows.records.get(key);
const afterRecord = rightRows.records.get(key);
if (!beforeRecord) continue;
if (!afterRecord) {
addRow(rows, {
kind: "removed",
path: `$rows/${key}`,
left: recordText(beforeRecord),
detail: "Keyed row removed.",
});
continue;
}
const commonHeaders = before.headers.filter((header) =>
after.headers.includes(header),
);
for (const header of commonHeaders)
if (beforeRecord[header] !== afterRecord[header])
addRow(rows, {
kind: "modified",
path: `$rows/${key}/${header}`,
left: beforeRecord[header],
right: afterRecord[header],
detail: `Cell changed in keyed row ${key}.`,
});
}
for (const key of rightRows.order) {
const record = rightRows.records.get(key);
if (record && !leftRows.records.has(key))
addRow(rows, {
kind: "added",
path: `$rows/${key}`,
right: recordText(record),
detail: "Keyed row added.",
});
}
diagnostics.push({
code: "csv.string-semantics",
message: `Fields were compared as strings using key column${keys.length === 1 ? "" : "s"}: ${keys.join(", ")}.`,
severity: "info",
side: "both",
});
return {
rows,
diagnostics,
appliedNormalizations: [
`Rows matched by key: ${keys.join(", ")}`,
"Row order ignored but reported",
"Fields compared as strings",
`Delimiter: ${before.delimiter === "\t" ? "tab" : before.delimiter}`,
],
};
}
+415
View File
@@ -0,0 +1,415 @@
import {
compareNumber,
LosslessNumber,
parse as parseLosslessJson,
stringify as stringifyLosslessJson,
} from "lossless-json";
import { addRow, DIFF_LIMITS, DiffLimitError } from "./limits";
import {
DiffToolsError,
type Diagnostic,
type DisplayRow,
type JsonOptions,
} from "./types";
type JsonNode =
| { type: "null" }
| { type: "boolean"; value: boolean }
| { type: "string"; value: string }
| { type: "number"; raw: string }
| { type: "array"; items: JsonNode[] }
| { type: "object"; entries: Array<{ key: string; value: JsonNode }> };
interface PatchOperation {
op: "add" | "remove" | "replace";
path: string;
value?: JsonNode;
}
const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]);
function escapePointer(value: string): string {
return value.replace(/~/gu, "~0").replace(/\//gu, "~1");
}
function rejectDangerousKeys(source: string, side: "left" | "right"): void {
for (let index = 0; index < source.length; index += 1) {
if (source[index] !== '"') continue;
const start = index;
let escaped = false;
index += 1;
while (index < source.length) {
const character = source[index];
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') break;
index += 1;
}
if (index >= source.length) return;
let next = index + 1;
while (/\s/u.test(source[next] ?? "")) next += 1;
if (source[next] !== ":") continue;
let key: unknown;
try {
key = JSON.parse(source.slice(start, index + 1)) as unknown;
} catch {
continue;
}
if (typeof key === "string" && DANGEROUS_KEYS.has(key))
throw new DiffToolsError("A prototype-affecting JSON key was rejected.", [
{
code: "json.dangerous-key",
message: `The ${side} JSON contains the prohibited key “${key}”.`,
severity: "error",
side,
},
]);
}
}
function scanDepth(source: string): void {
let depth = 0;
let string = false;
let escaped = false;
for (const character of source) {
if (string) {
if (escaped) escaped = false;
else if (character === "\\") escaped = true;
else if (character === '"') string = false;
} else if (character === '"') string = true;
else if (character === "[" || character === "{") {
depth += 1;
if (depth > DIFF_LIMITS.maxDepth)
throw new DiffLimitError("JSON depth", depth, DIFF_LIMITS.maxDepth);
} else if (character === "]" || character === "}")
depth = Math.max(0, depth - 1);
}
}
function normalize(
value: unknown,
state: { nodes: number },
depth = 0,
): JsonNode {
state.nodes += 1;
if (state.nodes > DIFF_LIMITS.maxNodes)
throw new DiffLimitError(
"JSON node count",
state.nodes,
DIFF_LIMITS.maxNodes,
);
if (depth > DIFF_LIMITS.maxDepth)
throw new DiffLimitError("JSON depth", depth, DIFF_LIMITS.maxDepth);
if (value === null) return { type: "null" };
if (typeof value === "string") return { type: "string", value };
if (typeof value === "boolean") return { type: "boolean", value };
if (value instanceof LosslessNumber)
return { type: "number", raw: value.value };
if (Array.isArray(value))
return {
type: "array",
items: value.map((item) => normalize(item, state, depth + 1)),
};
if (typeof value !== "object") throw new TypeError("Unsupported JSON value.");
return {
type: "object",
entries: Object.entries(value).map(([key, item]) => {
if (DANGEROUS_KEYS.has(key))
throw new TypeError(`Prohibited JSON key “${key}”.`);
return { key, value: normalize(item, state, depth + 1) };
}),
};
}
function parseJson(source: string, side: "left" | "right"): JsonNode {
scanDepth(source);
rejectDangerousKeys(source, side);
try {
const value = parseLosslessJson(source, null, {
parseNumber: (raw) => new LosslessNumber(raw),
onDuplicateKey: ({ key, position }) => {
throw new DiffToolsError("Duplicate JSON key.", [
{
code: "json.duplicate-key",
message: `The ${side} JSON repeats key “${key}” at offset ${position}.`,
severity: "error",
side,
},
]);
},
});
return normalize(value, { nodes: 0 });
} catch (error) {
if (error instanceof DiffToolsError || error instanceof DiffLimitError)
throw error;
throw new DiffToolsError(`Invalid ${side} JSON.`, [
{
code: "json.syntax",
message:
error instanceof Error ? error.message : `Invalid ${side} JSON.`,
severity: "error",
side,
},
]);
}
}
function toLosslessValue(node: JsonNode): unknown {
switch (node.type) {
case "null":
return null;
case "boolean":
return node.value;
case "string":
return node.value;
case "number":
return new LosslessNumber(node.raw);
case "array":
return node.items.map(toLosslessValue);
case "object": {
const value: Record<string, unknown> = Object.create(null) as Record<
string,
unknown
>;
for (const entry of node.entries)
value[entry.key] = toLosslessValue(entry.value);
return value;
}
}
}
function nodeText(node: JsonNode): string {
return stringifyLosslessJson(toLosslessValue(node), null, 0) ?? "";
}
function valueAt(
node: Extract<JsonNode, { type: "object" }>,
key: string,
): JsonNode | undefined {
return node.entries.find((entry) => entry.key === key)?.value;
}
function row(
rows: DisplayRow[],
kind: DisplayRow["kind"],
path: string,
left: JsonNode | string | undefined,
right: JsonNode | string | undefined,
detail: string,
): void {
addRow(rows, {
kind,
path,
left: typeof left === "string" ? left : left ? nodeText(left) : undefined,
right:
typeof right === "string" ? right : right ? nodeText(right) : undefined,
detail,
});
}
function compareNodes(
before: JsonNode,
after: JsonNode,
path: string,
options: JsonOptions,
rows: DisplayRow[],
operations: PatchOperation[],
): void {
if (before.type !== after.type) {
row(rows, "modified", path, before, after, "Value type changed.");
operations.push({ op: "replace", path, value: after });
return;
}
if (before.type === "number" && after.type === "number") {
const equal =
options.numberComparison === "numeric"
? compareNumber(before.raw, after.raw) === 0
: before.raw === after.raw;
if (!equal) {
row(
rows,
"modified",
path,
before.raw,
after.raw,
"Numeric value changed.",
);
operations.push({ op: "replace", path, value: after });
} else if (before.raw !== after.raw) {
row(
rows,
"normalized",
path,
before.raw,
after.raw,
"Different number lexemes have the same exact decimal value.",
);
}
return;
}
if (before.type === "string" && after.type === "string") {
if (before.value !== after.value) {
row(rows, "modified", path, before, after, "String value changed.");
operations.push({ op: "replace", path, value: after });
}
return;
}
if (before.type === "boolean" && after.type === "boolean") {
if (before.value !== after.value) {
row(rows, "modified", path, before, after, "Boolean value changed.");
operations.push({ op: "replace", path, value: after });
}
return;
}
if (before.type === "null" && after.type === "null") return;
if (before.type === "array" && after.type === "array") {
const common = Math.min(before.items.length, after.items.length);
for (let index = 0; index < common; index += 1) {
const left = before.items[index];
const right = after.items[index];
if (left && right)
compareNodes(
left,
right,
`${path}/${index}`,
options,
rows,
operations,
);
}
for (
let index = before.items.length - 1;
index >= after.items.length;
index -= 1
) {
const left = before.items[index];
if (!left) continue;
row(
rows,
"removed",
`${path}/${index}`,
left,
undefined,
"Array item removed.",
);
operations.push({ op: "remove", path: `${path}/${index}` });
}
for (
let index = before.items.length;
index < after.items.length;
index += 1
) {
const right = after.items[index];
if (!right) continue;
row(
rows,
"added",
`${path}/${index}`,
undefined,
right,
"Array item added.",
);
operations.push({ op: "add", path: `${path}/${index}`, value: right });
}
return;
}
if (before.type === "object" && after.type === "object") {
const beforeKeys = before.entries.map((entry) => entry.key);
const afterKeys = after.entries.map((entry) => entry.key);
const beforeSet = new Set(beforeKeys);
const afterSet = new Set(afterKeys);
const commonBefore = beforeKeys.filter((key) => afterSet.has(key));
const commonAfter = afterKeys.filter((key) => beforeSet.has(key));
if (commonBefore.join("\u0000") !== commonAfter.join("\u0000")) {
row(
rows,
"normalized",
path,
beforeKeys.join(", "),
afterKeys.join(", "),
"JSON object member order is ignored.",
);
}
for (const entry of before.entries)
if (!afterSet.has(entry.key)) {
const childPath = `${path}/${escapePointer(entry.key)}`;
row(
rows,
"removed",
childPath,
entry.value,
undefined,
"Object member removed.",
);
operations.push({ op: "remove", path: childPath });
}
for (const entry of after.entries)
if (!beforeSet.has(entry.key)) {
const childPath = `${path}/${escapePointer(entry.key)}`;
row(
rows,
"added",
childPath,
undefined,
entry.value,
"Object member added.",
);
operations.push({ op: "add", path: childPath, value: entry.value });
}
for (const key of beforeKeys) {
const left = valueAt(before, key);
const right = valueAt(after, key);
if (left && right)
compareNodes(
left,
right,
`${path}/${escapePointer(key)}`,
options,
rows,
operations,
);
}
}
}
function patchText(operations: PatchOperation[]): string {
const encoded = operations.map((operation) => {
const result: Record<string, unknown> = {
op: operation.op,
path: operation.path,
};
if (operation.value) result.value = toLosslessValue(operation.value);
return result;
});
return `${stringifyLosslessJson(encoded, null, 2) ?? "[]"}\n`;
}
export interface JsonDiffOutput {
rows: DisplayRow[];
diagnostics: Diagnostic[];
appliedNormalizations: string[];
jsonPatch: string;
}
export function compareJson(
left: string,
right: string,
options: JsonOptions,
): JsonDiffOutput {
const before = parseJson(left, "left");
const after = parseJson(right, "right");
const rows: DisplayRow[] = [];
const operations: PatchOperation[] = [];
compareNodes(before, after, "", options, rows, operations);
return {
rows,
diagnostics: [],
appliedNormalizations: [
"JSON objects are compared by member name, not source order",
options.numberComparison === "numeric"
? "Number lexemes are compared by exact decimal value"
: "Number lexemes are compared exactly",
],
jsonPatch: patchText(operations),
};
}
+126
View File
@@ -0,0 +1,126 @@
import type { DisplayRow, InputMetadata, NewlineState } from "./types";
export const DIFF_LIMITS = Object.freeze({
maxInputBytesPerSide: 8 * 1024 * 1024,
maxCharactersPerSide: 4_000_000,
maxTokensPerSide: 200_000,
maxRows: 50_000,
maxNodes: 100_000,
maxDepth: 128,
maxCsvRows: 50_000,
maxCsvColumns: 2_000,
maxCsvCells: 250_000,
maxFieldCharacters: 1_000_000,
maxOutputCharacters: 16_000_000,
maxRowSnippet: 12_000,
diffTimeoutMilliseconds: 2_000,
maxEditLength: 10_000,
});
export class DiffLimitError extends RangeError {
constructor(label: string, actual: number, limit: number) {
super(
`${label} is ${actual.toLocaleString()}; the limit is ${limit.toLocaleString()}.`,
);
this.name = "DiffLimitError";
}
}
export function assertInput(value: string, side: "left" | "right"): number {
if (value.length > DIFF_LIMITS.maxCharactersPerSide)
throw new DiffLimitError(
`${side} character count`,
value.length,
DIFF_LIMITS.maxCharactersPerSide,
);
const bytes = new TextEncoder().encode(value).byteLength;
if (bytes > DIFF_LIMITS.maxInputBytesPerSide)
throw new DiffLimitError(
`${side} UTF-8 byte count`,
bytes,
DIFF_LIMITS.maxInputBytesPerSide,
);
return bytes;
}
export function newlineState(value: string): NewlineState {
let lf = 0;
let crlf = 0;
let cr = 0;
for (let index = 0; index < value.length; index += 1) {
if (value[index] === "\r") {
if (value[index + 1] === "\n") {
crlf += 1;
index += 1;
} else cr += 1;
} else if (value[index] === "\n") lf += 1;
}
const final = value.endsWith("\r\n")
? "crlf"
: value.endsWith("\n")
? "lf"
: value.endsWith("\r")
? "cr"
: "none";
return { lf, crlf, cr, final };
}
export function inputMetadata(
value: string,
bytes: number,
name: string,
): InputMetadata {
const newlines = newlineState(value);
return {
name,
bytes,
characters: value.length,
lines:
value.length === 0
? 0
: newlines.lf +
newlines.crlf +
newlines.cr +
(newlines.final === "none" ? 1 : 0),
newlines,
};
}
export function boundedSnippet(value: string): {
text: string;
omitted?: number;
} {
if (value.length <= DIFF_LIMITS.maxRowSnippet) return { text: value };
return {
text: `${value.slice(0, DIFF_LIMITS.maxRowSnippet)}\n…`,
omitted: value.length - DIFF_LIMITS.maxRowSnippet,
};
}
export function addRow(rows: DisplayRow[], row: DisplayRow): void {
if (rows.length >= DIFF_LIMITS.maxRows)
throw new DiffLimitError(
"Rendered change row count",
rows.length + 1,
DIFF_LIMITS.maxRows,
);
const left = row.left === undefined ? undefined : boundedSnippet(row.left);
const right = row.right === undefined ? undefined : boundedSnippet(row.right);
rows.push({
...row,
left: left?.text,
right: right?.text,
omittedLeft: left?.omitted,
omittedRight: right?.omitted,
});
}
export function assertOutput(value: string, label: string): string {
if (value.length > DIFF_LIMITS.maxOutputCharacters)
throw new DiffLimitError(
label,
value.length,
DIFF_LIMITS.maxOutputCharacters,
);
return value;
}
+208
View File
@@ -0,0 +1,208 @@
import { diffArrays, type ArrayChange } from "diff";
import { addRow, DIFF_LIMITS, DiffLimitError } from "./limits";
import type { DisplayRow, TextOptions } from "./types";
interface TextToken {
raw: string;
compare: string;
}
function lineTokens(value: string): string[] {
return value.match(/[^\r\n]*(?:\r\n|\r|\n)|[^\r\n]+$/gu) ?? [];
}
function wordTokens(value: string): string[] {
if (typeof Intl.Segmenter === "function") {
const segmenter = new Intl.Segmenter(undefined, { granularity: "word" });
return Array.from(segmenter.segment(value), (part) => part.segment);
}
return value.match(/\s+|[\p{L}\p{N}_]+|[^\s]/gu) ?? [];
}
function rawTokens(value: string, options: TextOptions): string[] {
switch (options.granularity) {
case "line":
return lineTokens(value);
case "word":
return wordTokens(value);
case "code-point":
return options.ignoreLineEndingStyle
? (value.match(/\r\n|[\s\S]/gu) ?? [])
: Array.from(value);
case "grapheme": {
if (typeof Intl.Segmenter !== "function") return Array.from(value);
const segmenter = new Intl.Segmenter(undefined, {
granularity: "grapheme",
});
return Array.from(segmenter.segment(value), (part) => part.segment);
}
}
}
function normalizeToken(
raw: string,
options: TextOptions,
finalToken: boolean,
): string {
let value = raw;
if (options.unicodeNormalization !== "none")
value = value.normalize(options.unicodeNormalization);
if (options.ignoreLineEndingStyle) value = value.replace(/\r\n|\r/gu, "\n");
if (options.ignoreFinalNewline && finalToken)
value = value.replace(/(?:\r\n|\r|\n)$/u, "");
if (options.ignoreWhitespace) value = value.replace(/\s+/gu, " ").trim();
if (options.ignoreCase) value = value.toLowerCase();
return value;
}
function tokenize(value: string, options: TextOptions): TextToken[] {
const raw = rawTokens(value, options);
if (raw.length > DIFF_LIMITS.maxTokensPerSide)
throw new DiffLimitError(
"Text token count",
raw.length,
DIFF_LIMITS.maxTokensPerSide,
);
return raw.map((token, index) => ({
raw: token,
compare: normalizeToken(token, options, index === raw.length - 1),
}));
}
function lineAdvance(value: string): number {
return value.match(/\r\n|\r|\n/gu)?.length ?? 0;
}
function join(change: ArrayChange<TextToken>): string {
return change.value.map((token) => token.raw).join("");
}
function addTextRow(
rows: DisplayRow[],
kind: DisplayRow["kind"],
left: string | undefined,
right: string | undefined,
leftLine: number,
rightLine: number,
detail?: string,
): void {
addRow(rows, { kind, left, right, leftLine, rightLine, detail });
}
export interface TextDiffOutput {
rows: DisplayRow[];
appliedNormalizations: string[];
}
export function compareText(
left: string,
right: string,
options: TextOptions,
): TextDiffOutput {
const leftTokens = tokenize(left, options);
const rightTokens = tokenize(right, options);
const changes = diffArrays(leftTokens, rightTokens, {
comparator: (before, after) => before.compare === after.compare,
timeout: DIFF_LIMITS.diffTimeoutMilliseconds,
maxEditLength: DIFF_LIMITS.maxEditLength,
});
if (!changes)
throw new DiffLimitError(
"Text edit distance",
DIFF_LIMITS.maxEditLength + 1,
DIFF_LIMITS.maxEditLength,
);
const rows: DisplayRow[] = [];
let leftLine = 1;
let rightLine = 1;
let leftCursor = 0;
let rightCursor = 0;
for (let index = 0; index < changes.length; index += 1) {
const change = changes[index];
if (!change) continue;
const next = changes[index + 1];
const replacement =
(change.removed && next?.added) || (change.added && next?.removed);
if (replacement && next) {
const removed = change.removed ? change : next;
const added = change.added ? change : next;
const before = join(removed);
const after = join(added);
addTextRow(rows, "modified", before, after, leftLine, rightLine);
leftLine += lineAdvance(before);
rightLine += lineAdvance(after);
leftCursor += removed.count;
rightCursor += added.count;
index += 1;
continue;
}
if (change.removed) {
const before = join(change);
addTextRow(rows, "removed", before, undefined, leftLine, rightLine);
leftLine += lineAdvance(before);
leftCursor += change.count;
continue;
}
if (change.added) {
const after = join(change);
addTextRow(rows, "added", undefined, after, leftLine, rightLine);
rightLine += lineAdvance(after);
rightCursor += change.count;
continue;
}
let groupKind: "equal" | "normalized" | undefined;
let before = "";
let after = "";
const flush = () => {
if (!groupKind) return;
addTextRow(
rows,
groupKind,
before,
after,
leftLine,
rightLine,
groupKind === "normalized"
? "Different source text compared equal under the selected normalization."
: undefined,
);
leftLine += lineAdvance(before);
rightLine += lineAdvance(after);
before = "";
after = "";
};
for (
let tokenIndex = 0;
tokenIndex < change.value.length;
tokenIndex += 1
) {
const leftToken = leftTokens[leftCursor + tokenIndex];
const rightToken = rightTokens[rightCursor + tokenIndex];
if (!leftToken || !rightToken) continue;
const kind = leftToken.raw === rightToken.raw ? "equal" : "normalized";
if (groupKind && groupKind !== kind) flush();
groupKind = kind;
before += leftToken.raw;
after += rightToken.raw;
}
flush();
leftCursor += change.count;
rightCursor += change.count;
}
const appliedNormalizations: string[] = [];
if (options.ignoreCase)
appliedNormalizations.push("Case differences ignored");
if (options.ignoreWhitespace)
appliedNormalizations.push("Whitespace runs and edges normalized");
if (options.ignoreLineEndingStyle)
appliedNormalizations.push("CRLF, LF and CR compared as LF");
if (options.ignoreFinalNewline)
appliedNormalizations.push("Final newline presence ignored");
if (options.unicodeNormalization !== "none")
appliedNormalizations.push(
`Unicode ${options.unicodeNormalization} normalization`,
);
return { rows, appliedNormalizations };
}
+143
View File
@@ -0,0 +1,143 @@
export type DiffMode = "text" | "json" | "xml" | "csv";
export type TextGranularity = "line" | "word" | "code-point" | "grapheme";
export type UnicodeNormalization = "none" | "NFC" | "NFD" | "NFKC" | "NFKD";
export interface TextOptions {
granularity: TextGranularity;
ignoreCase: boolean;
ignoreWhitespace: boolean;
ignoreLineEndingStyle: boolean;
ignoreFinalNewline: boolean;
unicodeNormalization: UnicodeNormalization;
}
export interface JsonOptions {
numberComparison: "numeric" | "lexical";
ignoreObjectOrder: true;
}
export interface XmlOptions {
ignoreComments: boolean;
ignoreAttributeOrder: boolean;
ignoreNamespacePrefixes: boolean;
normalizeCdata: boolean;
trimText: boolean;
collapseWhitespace: boolean;
}
export interface CsvOptions {
delimiter: "auto" | "comma" | "tab";
keyColumns: string;
ignoreRowOrder: true;
}
export interface CompareOptions {
text: TextOptions;
json: JsonOptions;
xml: XmlOptions;
csv: CsvOptions;
}
export interface CompareRequest {
mode: DiffMode;
left: string;
right: string;
leftName?: string;
rightName?: string;
options: CompareOptions;
}
export type Severity = "error" | "warning" | "info";
export interface Diagnostic {
code: string;
message: string;
severity: Severity;
side?: "left" | "right" | "both";
line?: number;
}
export type ChangeKind =
"equal" | "added" | "removed" | "modified" | "normalized";
export interface DisplayRow {
kind: ChangeKind;
path?: string;
left?: string;
right?: string;
leftLine?: number;
rightLine?: number;
detail?: string;
omittedLeft?: number;
omittedRight?: number;
}
export interface NewlineState {
lf: number;
crlf: number;
cr: number;
final: "none" | "lf" | "crlf" | "cr";
}
export interface InputMetadata {
name: string;
bytes: number;
characters: number;
lines: number;
newlines: NewlineState;
}
export interface DiffStats {
equal: number;
added: number;
removed: number;
modified: number;
normalized: number;
}
export interface DiffResult {
schemaVersion: 1;
mode: DiffMode;
exactlyEqual: boolean;
semanticallyEqual: boolean;
rows: DisplayRow[];
stats: DiffStats;
diagnostics: Diagnostic[];
appliedNormalizations: string[];
left: InputMetadata;
right: InputMetadata;
unifiedPatch?: string;
jsonPatch?: string;
report: string;
}
export class DiffToolsError extends Error {
readonly diagnostics: Diagnostic[];
constructor(message: string, diagnostics: Diagnostic[]) {
super(message);
this.name = "DiffToolsError";
this.diagnostics = diagnostics;
}
}
export const DEFAULT_OPTIONS: CompareOptions = {
text: {
granularity: "line",
ignoreCase: false,
ignoreWhitespace: false,
ignoreLineEndingStyle: false,
ignoreFinalNewline: false,
unicodeNormalization: "none",
},
json: { numberComparison: "numeric", ignoreObjectOrder: true },
xml: {
ignoreComments: false,
ignoreAttributeOrder: true,
ignoreNamespacePrefixes: true,
normalizeCdata: true,
trimText: false,
collapseWhitespace: false,
},
csv: { delimiter: "auto", keyColumns: "", ignoreRowOrder: true },
};
+77
View File
@@ -0,0 +1,77 @@
import { compareInputs } from "./compare";
import {
DiffToolsError,
type CompareRequest,
type Diagnostic,
type DiffResult,
} from "./types";
type WorkerResponse =
| { id: number; ok: true; result: DiffResult }
| { id: number; ok: false; message: string; diagnostics: Diagnostic[] };
export interface CompareTask {
promise: Promise<DiffResult>;
cancel(): void;
}
let nextId = 0;
export function createCompareTask(request: CompareRequest): CompareTask {
const id = ++nextId;
if (typeof Worker === "undefined") {
let cancelled = false;
return {
promise: Promise.resolve().then(() => {
if (cancelled)
throw new DOMException("Comparison cancelled.", "AbortError");
return compareInputs(request);
}),
cancel() {
cancelled = true;
},
};
}
const worker = new Worker(
new URL("../workers/diff.worker.ts", import.meta.url),
{ type: "module", name: "diff-tools-comparator" },
);
let settled = false;
let rejectPromise: ((reason?: unknown) => void) | undefined;
const promise = new Promise<DiffResult>((resolve, reject) => {
rejectPromise = reject;
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
if (event.data.id !== id || settled) return;
settled = true;
worker.terminate();
if (event.data.ok) resolve(event.data.result);
else
reject(new DiffToolsError(event.data.message, event.data.diagnostics));
};
worker.onerror = (event) => {
if (settled) return;
settled = true;
worker.terminate();
reject(
new DiffToolsError(event.message || "The comparison worker failed.", [
{
code: "worker.failure",
message: event.message || "The comparison worker failed.",
severity: "error",
side: "both",
},
]),
);
};
worker.postMessage({ id, request });
});
return {
promise,
cancel() {
if (settled) return;
settled = true;
worker.terminate();
rejectPromise?.(new DOMException("Comparison cancelled.", "AbortError"));
},
};
}
+592
View File
@@ -0,0 +1,592 @@
import {
DOMParser,
type Document as XmlDocument,
type Element as DomElement,
} from "@xmldom/xmldom";
import { diffArrays } from "diff";
import { addRow, DIFF_LIMITS, DiffLimitError } from "./limits";
import {
DiffToolsError,
type Diagnostic,
type DisplayRow,
type XmlOptions,
} from "./types";
interface XmlAttribute {
qualifiedName: string;
localName: string;
namespace: string;
value: string;
}
type XmlNode =
| {
type: "element";
qualifiedName: string;
localName: string;
namespace: string;
attributes: XmlAttribute[];
children: XmlNode[];
}
| { type: "text" | "cdata" | "comment"; value: string }
| { type: "processing-instruction"; name: string; value: string };
function expandedName(namespace: string, localName: string): string {
return namespace ? `{${namespace}}${localName}` : localName;
}
function preflightXmlStructure(source: string): void {
let cursor = 0;
let depth = 0;
let elements = 0;
while (cursor < source.length) {
const opening = source.indexOf("<", cursor);
if (opening < 0) break;
if (source.startsWith("<!--", opening)) {
const end = source.indexOf("-->", opening + 4);
cursor = end < 0 ? source.length : end + 3;
continue;
}
if (source.startsWith("<![CDATA[", opening)) {
const end = source.indexOf("]]>", opening + 9);
cursor = end < 0 ? source.length : end + 3;
continue;
}
let end = opening + 1;
let quote = "";
for (; end < source.length; end += 1) {
const character = source[end];
if (quote) {
if (character === quote) quote = "";
} else if (character === '"' || character === "'") quote = character;
else if (character === ">") break;
}
const markup = source.slice(opening, Math.min(end + 1, source.length));
if (/^<\s*\//u.test(markup)) depth = Math.max(0, depth - 1);
else if (!/^<\s*[!?]/u.test(markup)) {
elements += 1;
if (elements > DIFF_LIMITS.maxNodes)
throw new DiffLimitError(
"XML element count",
elements,
DIFF_LIMITS.maxNodes,
);
if (!/\/\s*>$/u.test(markup)) {
depth += 1;
if (depth > DIFF_LIMITS.maxDepth)
throw new DiffLimitError("XML depth", depth, DIFF_LIMITS.maxDepth);
}
}
cursor = end < source.length ? end + 1 : source.length;
}
}
function buildElement(
element: DomElement,
state: { nodes: number },
depth: number,
): XmlNode {
state.nodes += 1;
if (state.nodes > DIFF_LIMITS.maxNodes)
throw new DiffLimitError(
"XML node count",
state.nodes,
DIFF_LIMITS.maxNodes,
);
if (depth > DIFF_LIMITS.maxDepth)
throw new DiffLimitError("XML depth", depth, DIFF_LIMITS.maxDepth);
const attributes: XmlAttribute[] = [];
for (let index = 0; index < element.attributes.length; index += 1) {
const attribute = element.attributes.item(index);
if (!attribute) continue;
attributes.push({
qualifiedName: attribute.name,
localName: attribute.localName || attribute.name,
namespace: attribute.namespaceURI ?? "",
value: attribute.value,
});
}
const children: XmlNode[] = [];
for (let child = element.firstChild; child; child = child.nextSibling) {
state.nodes += 1;
if (state.nodes > DIFF_LIMITS.maxNodes)
throw new DiffLimitError(
"XML node count",
state.nodes,
DIFF_LIMITS.maxNodes,
);
if (child.nodeType === 1) {
state.nodes -= 1;
children.push(buildElement(child as DomElement, state, depth + 1));
} else if (child.nodeType === 3)
children.push({ type: "text", value: child.nodeValue ?? "" });
else if (child.nodeType === 4)
children.push({ type: "cdata", value: child.nodeValue ?? "" });
else if (child.nodeType === 8)
children.push({ type: "comment", value: child.nodeValue ?? "" });
else if (child.nodeType === 7)
children.push({
type: "processing-instruction",
name: child.nodeName,
value: child.nodeValue ?? "",
});
}
return {
type: "element",
qualifiedName: element.tagName,
localName: element.localName || element.tagName,
namespace: element.namespaceURI ?? "",
attributes,
children,
};
}
function parseXml(
source: string,
side: "left" | "right",
diagnostics: Diagnostic[],
): XmlNode {
if (/<\s*!\s*(?:DOCTYPE|ENTITY)\b/iu.test(source))
throw new DiffToolsError(
"XML declarations with entity semantics are rejected.",
[
{
code: "xml.doctype-rejected",
message: `The ${side} XML contains a DOCTYPE or entity declaration.`,
severity: "error",
side,
},
],
);
if (
/<\s*(?:xi:include\b|[^>]*\bxmlns:xi\s*=\s*["']http:\/\/www\.w3\.org\/2001\/XInclude["'])/iu.test(
source,
)
)
throw new DiffToolsError("XML XInclude is rejected.", [
{
code: "xml.xinclude-rejected",
message: `The ${side} XML contains XInclude syntax.`,
severity: "error",
side,
},
]);
preflightXmlStructure(source);
let document: XmlDocument;
try {
document = new DOMParser({
onError(level, message, context) {
if (level === "warning") {
diagnostics.push({
code: "xml.warning",
message,
severity: "warning",
side,
line: context?.locator?.lineNumber,
});
return;
}
throw new DiffToolsError(`Invalid ${side} XML.`, [
{
code: "xml.syntax",
message,
severity: "error",
side,
line: context?.locator?.lineNumber,
},
]);
},
}).parseFromString(source, "application/xml");
} catch (error) {
if (error instanceof DiffToolsError) throw error;
throw new DiffToolsError(`Invalid ${side} XML.`, [
{
code: "xml.syntax",
message:
error instanceof Error ? error.message : `Invalid ${side} XML.`,
severity: "error",
side,
},
]);
}
if (!document.documentElement)
throw new DiffToolsError(`The ${side} XML has no document element.`, [
{
code: "xml.no-root",
message: `The ${side} XML has no document element.`,
severity: "error",
side,
},
]);
return buildElement(document.documentElement, { nodes: 0 }, 0);
}
function normalizeText(value: string, options: XmlOptions): string {
let normalized = value;
if (options.collapseWhitespace) normalized = normalized.replace(/\s+/gu, " ");
if (options.trimText) normalized = normalized.trim();
return normalized;
}
function nodeName(node: XmlNode, options: XmlOptions): string {
if (node.type !== "element") return node.type;
return options.ignoreNamespacePrefixes
? expandedName(node.namespace, node.localName)
: node.qualifiedName;
}
function nodeSignature(node: XmlNode, options: XmlOptions): string {
if (node.type === "element") return `element:${nodeName(node, options)}`;
if (options.normalizeCdata && (node.type === "text" || node.type === "cdata"))
return "text";
if (node.type === "processing-instruction") return `pi:${node.name}`;
return node.type;
}
function nodeText(node: XmlNode): string {
if (node.type === "element") return `<${node.qualifiedName}>`;
if (node.type === "processing-instruction")
return `<?${node.name}${node.value ? ` ${node.value}` : ""}?>`;
if (node.type === "comment") return `<!--${node.value}-->`;
if (node.type === "cdata") return `<![CDATA[${node.value}]]>`;
return node.value;
}
function attributeKey(attribute: XmlAttribute, options: XmlOptions): string {
return options.ignoreNamespacePrefixes
? expandedName(attribute.namespace, attribute.localName)
: attribute.qualifiedName;
}
function compareNode(
before: XmlNode,
after: XmlNode,
path: string,
options: XmlOptions,
rows: DisplayRow[],
): void {
const beforeSignature = nodeSignature(before, options);
const afterSignature = nodeSignature(after, options);
if (beforeSignature !== afterSignature) {
addRow(rows, {
kind: "modified",
path,
left: nodeText(before),
right: nodeText(after),
detail: "XML node kind or expanded name changed.",
});
return;
}
if (before.type === "element" && after.type === "element") {
if (
options.ignoreNamespacePrefixes &&
before.qualifiedName !== after.qualifiedName &&
before.namespace === after.namespace &&
before.localName === after.localName
) {
addRow(rows, {
kind: "normalized",
path,
left: before.qualifiedName,
right: after.qualifiedName,
detail: "Namespace prefixes differ; expanded names are equal.",
});
}
const xmlns = "http://www.w3.org/2000/xmlns/";
const beforeDeclarations = before.attributes.filter(
(attribute) => attribute.namespace === xmlns,
);
const afterDeclarations = after.attributes.filter(
(attribute) => attribute.namespace === xmlns,
);
if (
options.ignoreNamespacePrefixes &&
JSON.stringify(beforeDeclarations) !== JSON.stringify(afterDeclarations)
)
addRow(rows, {
kind: "normalized",
path: `${path}/namespace::*`,
left: beforeDeclarations
.map((attribute) => `${attribute.qualifiedName}=${attribute.value}`)
.join(", "),
right: afterDeclarations
.map((attribute) => `${attribute.qualifiedName}=${attribute.value}`)
.join(", "),
detail: "Namespace declarations differ; expanded names remain equal.",
});
const beforeComparable = options.ignoreNamespacePrefixes
? before.attributes.filter((attribute) => attribute.namespace !== xmlns)
: before.attributes;
const afterComparable = options.ignoreNamespacePrefixes
? after.attributes.filter((attribute) => attribute.namespace !== xmlns)
: after.attributes;
const beforeKeys = beforeComparable.map((attribute) =>
attributeKey(attribute, options),
);
const afterKeys = afterComparable.map((attribute) =>
attributeKey(attribute, options),
);
const beforeKeySet = new Set(beforeKeys);
const afterKeySet = new Set(afterKeys);
const commonBeforeKeys = beforeKeys.filter((key) => afterKeySet.has(key));
const commonAfterKeys = afterKeys.filter((key) => beforeKeySet.has(key));
if (commonBeforeKeys.join("\u0000") !== commonAfterKeys.join("\u0000")) {
addRow(rows, {
kind: options.ignoreAttributeOrder ? "normalized" : "modified",
path: `${path}/@*`,
left: beforeKeys.join(", "),
right: afterKeys.join(", "),
detail: options.ignoreAttributeOrder
? "Attribute order is ignored."
: "Attribute order differs.",
});
}
const beforeAttributes = new Map(
beforeComparable.map((attribute) => [
attributeKey(attribute, options),
attribute,
]),
);
const afterAttributes = new Map(
afterComparable.map((attribute) => [
attributeKey(attribute, options),
attribute,
]),
);
for (const [key, attribute] of beforeAttributes) {
const other = afterAttributes.get(key);
const attributePath = `${path}/@${key}`;
if (!other)
addRow(rows, {
kind: "removed",
path: attributePath,
left: attribute.value,
detail: "Attribute removed.",
});
else if (attribute.value !== other.value)
addRow(rows, {
kind: "modified",
path: attributePath,
left: attribute.value,
right: other.value,
detail: "Attribute value changed.",
});
else if (
options.ignoreNamespacePrefixes &&
attribute.qualifiedName !== other.qualifiedName
)
addRow(rows, {
kind: "normalized",
path: attributePath,
left: attribute.qualifiedName,
right: other.qualifiedName,
detail:
"Attribute namespace prefixes differ; expanded names are equal.",
});
}
for (const [key, attribute] of afterAttributes)
if (!beforeAttributes.has(key))
addRow(rows, {
kind: "added",
path: `${path}/@${key}`,
right: attribute.value,
detail: "Attribute added.",
});
let beforeChildren = before.children;
let afterChildren = after.children;
if (options.ignoreComments) {
const beforeComments = beforeChildren.filter(
(node) => node.type === "comment",
);
const afterComments = afterChildren.filter(
(node) => node.type === "comment",
);
const count = Math.max(beforeComments.length, afterComments.length);
for (let index = 0; index < count; index += 1) {
const leftComment = beforeComments[index];
const rightComment = afterComments[index];
if (
nodeText(leftComment ?? { type: "comment", value: "" }) !==
nodeText(rightComment ?? { type: "comment", value: "" })
)
addRow(rows, {
kind: "normalized",
path: `${path}/comment()[${index + 1}]`,
left: leftComment ? nodeText(leftComment) : undefined,
right: rightComment ? nodeText(rightComment) : undefined,
detail: "Comment difference ignored.",
});
}
beforeChildren = beforeChildren.filter((node) => node.type !== "comment");
afterChildren = afterChildren.filter((node) => node.type !== "comment");
}
compareChildren(beforeChildren, afterChildren, path, options, rows);
return;
}
if (
(before.type === "text" || before.type === "cdata") &&
(after.type === "text" || after.type === "cdata")
) {
const left = normalizeText(before.value, options);
const right = normalizeText(after.value, options);
if (left !== right)
addRow(rows, {
kind: "modified",
path,
left: before.value,
right: after.value,
detail: "Text content changed.",
});
else if (before.value !== after.value || before.type !== after.type)
addRow(rows, {
kind: "normalized",
path,
left: nodeText(before),
right: nodeText(after),
detail: "Text compared equal after the selected XML normalization.",
});
return;
}
if (before.type === "comment" && after.type === "comment") {
if (before.value !== after.value)
addRow(rows, {
kind: "modified",
path,
left: nodeText(before),
right: nodeText(after),
detail: "Comment changed.",
});
return;
}
if (
before.type === "processing-instruction" &&
after.type === "processing-instruction" &&
(before.name !== after.name || before.value !== after.value)
)
addRow(rows, {
kind: "modified",
path,
left: nodeText(before),
right: nodeText(after),
detail: "Processing instruction changed.",
});
}
function compareChildren(
before: XmlNode[],
after: XmlNode[],
parentPath: string,
options: XmlOptions,
rows: DisplayRow[],
): void {
const changes = diffArrays(before, after, {
comparator: (left, right) =>
nodeSignature(left, options) === nodeSignature(right, options),
timeout: DIFF_LIMITS.diffTimeoutMilliseconds,
maxEditLength: DIFF_LIMITS.maxEditLength,
});
if (!changes)
throw new DiffLimitError(
"XML child edit distance",
DIFF_LIMITS.maxEditLength + 1,
DIFF_LIMITS.maxEditLength,
);
let leftIndex = 0;
let rightIndex = 0;
for (let index = 0; index < changes.length; index += 1) {
const change = changes[index];
if (!change) continue;
const next = changes[index + 1];
if (change.removed && next?.added) {
const count = Math.max(change.value.length, next.value.length);
for (let offset = 0; offset < count; offset += 1) {
const left = change.value[offset];
const right = next.value[offset];
addRow(rows, {
kind: left && right ? "modified" : left ? "removed" : "added",
path: `${parentPath}/node()[${Math.max(leftIndex, rightIndex) + offset + 1}]`,
left: left ? nodeText(left) : undefined,
right: right ? nodeText(right) : undefined,
detail:
left && right
? "XML child replaced."
: left
? "XML child removed."
: "XML child added.",
});
}
leftIndex += change.count;
rightIndex += next.count;
index += 1;
} else if (change.removed) {
change.value.forEach((node, offset) =>
addRow(rows, {
kind: "removed",
path: `${parentPath}/node()[${leftIndex + offset + 1}]`,
left: nodeText(node),
detail: "XML child removed.",
}),
);
leftIndex += change.count;
} else if (change.added) {
change.value.forEach((node, offset) =>
addRow(rows, {
kind: "added",
path: `${parentPath}/node()[${rightIndex + offset + 1}]`,
right: nodeText(node),
detail: "XML child added.",
}),
);
rightIndex += change.count;
} else {
for (let offset = 0; offset < change.count; offset += 1) {
const left = before[leftIndex + offset];
const right = after[rightIndex + offset];
if (left && right)
compareNode(
left,
right,
`${parentPath}/node()[${rightIndex + offset + 1}]`,
options,
rows,
);
}
leftIndex += change.count;
rightIndex += change.count;
}
}
}
export interface XmlDiffOutput {
rows: DisplayRow[];
diagnostics: Diagnostic[];
appliedNormalizations: string[];
}
export function compareXml(
left: string,
right: string,
options: XmlOptions,
): XmlDiffOutput {
const diagnostics: Diagnostic[] = [];
const before = parseXml(left, "left", diagnostics);
const after = parseXml(right, "right", diagnostics);
const rows: DisplayRow[] = [];
compareNode(before, after, `/${nodeName(before, options)}[1]`, options, rows);
const appliedNormalizations: string[] = [];
if (options.ignoreNamespacePrefixes)
appliedNormalizations.push(
"Namespace prefixes compared by namespace URI and local name",
);
if (options.ignoreAttributeOrder)
appliedNormalizations.push("Attribute order ignored");
if (options.ignoreComments)
appliedNormalizations.push("Comments ignored but shown as normalized rows");
if (options.normalizeCdata)
appliedNormalizations.push("CDATA and text nodes compared as text");
if (options.trimText) appliedNormalizations.push("Text-node edges trimmed");
if (options.collapseWhitespace)
appliedNormalizations.push("Text-node whitespace runs collapsed");
return { rows, diagnostics, appliedNormalizations };
}
+18
View File
@@ -0,0 +1,18 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);
if ("serviceWorker" in navigator && import.meta.env.PROD) {
window.addEventListener("load", () => {
const url = new URL("./sw.js", document.baseURI);
void navigator.serviceWorker
.register(url, { scope: new URL("./", document.baseURI).pathname })
.catch(() => undefined);
});
}
+771
View File
@@ -0,0 +1,771 @@
:root {
--toolbox-background: #f6f7fb;
--toolbox-surface: #ffffff;
--toolbox-surface-soft: #eff1f7;
--toolbox-text: #202332;
--toolbox-muted: #656b7d;
--toolbox-border: #d9dce7;
--toolbox-accent: #5b4ec4;
--toolbox-accent-hover: #493caf;
--toolbox-accent-soft: #ece9ff;
--toolbox-accent-contrast: #ffffff;
--toolbox-focus: #137d75;
--toolbox-danger: #b42342;
--diff-added: #18794e;
--diff-added-soft: #e9f8ef;
--diff-removed: #b42342;
--diff-removed-soft: #fff0f3;
--diff-modified: #735600;
--diff-modified-soft: #fff8dc;
--diff-normalized: #6547bb;
--diff-normalized-soft: #f0edff;
}
* {
box-sizing: border-box;
}
html {
min-width: 20rem;
min-height: 100%;
background: var(--toolbox-background);
scrollbar-gutter: stable;
}
body {
min-width: 20rem;
min-height: 100vh;
margin: 0;
background: var(--toolbox-background);
color: var(--toolbox-text);
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
}
button,
input,
select,
textarea {
font: inherit;
}
button,
.button {
min-height: 2.55rem;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.4rem;
padding: 0.55rem 0.8rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.65rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
font-weight: 720;
cursor: pointer;
}
button:hover:not(:disabled),
.button:hover {
border-color: var(--toolbox-accent);
background: var(--toolbox-surface-soft);
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
:where(button, input, select, textarea, a):focus-visible {
outline: 3px solid color-mix(in srgb, var(--toolbox-focus) 42%, transparent);
outline-offset: 2px;
}
input,
select,
textarea {
width: 100%;
min-height: 2.55rem;
padding: 0.58rem 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.62rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
textarea {
min-height: 15rem;
resize: vertical;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 0.86rem;
line-height: 1.5;
tab-size: 2;
}
.toolbox-shell__main {
width: min(100%, 90rem);
padding: clamp(0.75rem, 1.8vw, 1.5rem);
}
.workbench {
display: grid;
gap: 1rem;
width: 100%;
}
.hero,
.panel,
.mode-tabs,
.result-tabs {
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
box-shadow: 0 8px 28px rgb(30 36 70 / 4%);
}
.hero {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
padding: clamp(1.1rem, 3vw, 2rem);
}
.hero h1,
.panel h2,
.panel h3,
.help-dialog h2,
.fatal h1 {
margin: 0;
letter-spacing: -0.025em;
}
.hero p:not(.eyebrow) {
max-width: 52rem;
margin: 0.55rem 0 0;
color: var(--toolbox-muted);
line-height: 1.55;
}
.eyebrow {
margin: 0 0 0.3rem;
color: var(--toolbox-accent);
font-size: 0.69rem;
font-weight: 820;
letter-spacing: 0.115em;
text-transform: uppercase;
}
.privacy-pill {
flex: 0 0 auto;
padding: 0.38rem 0.62rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.75rem;
font-weight: 760;
}
.panel {
min-width: 0;
padding: 1rem;
}
.panel-heading,
.artifact-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-end;
margin-bottom: 0.9rem;
}
.mode-tabs,
.result-tabs {
padding: 0.35rem;
overflow-x: auto;
}
.mode-tabs [role="tablist"],
.result-tabs [role="tablist"] {
display: flex;
gap: 0.35rem;
min-width: max-content;
}
.mode-tabs [role="tab"] {
min-width: 9.5rem;
flex-direction: column;
align-items: flex-start;
line-height: 1.2;
}
.mode-tabs [role="tab"] small {
color: var(--toolbox-muted);
font-size: 0.7rem;
font-weight: 580;
}
.mode-tabs [role="tab"][aria-selected="true"],
.result-tabs [role="tab"][aria-selected="true"] {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.mode-tabs [role="tab"][aria-selected="true"] small {
color: inherit;
opacity: 0.84;
}
.option-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: 0.65rem;
align-items: stretch;
}
.field {
display: grid;
min-width: 0;
gap: 0.35rem;
}
.field > span {
font-size: 0.76rem;
font-weight: 750;
}
.toggle {
display: flex;
gap: 0.65rem;
align-items: flex-start;
min-height: 3.6rem;
padding: 0.7rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface-soft);
cursor: pointer;
}
.toggle:has(input:disabled) {
cursor: default;
}
.toggle input {
width: 1.15rem;
min-height: 1.15rem;
margin: 0.1rem 0 0;
accent-color: var(--toolbox-accent);
}
.toggle span,
.toggle small {
display: block;
}
.toggle small,
.option-note,
.muted {
color: var(--toolbox-muted);
}
.toggle small {
margin-top: 0.18rem;
line-height: 1.35;
}
.option-note {
align-self: center;
margin: 0;
line-height: 1.45;
}
.input-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
}
.input-panel h2 {
max-width: 26rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.file-button {
position: relative;
flex: 0 0 auto;
overflow: hidden;
}
.file-button input {
position: absolute;
width: 1px;
height: 1px;
min-height: 0;
opacity: 0;
}
.primary-button {
border-color: var(--toolbox-accent);
background: var(--toolbox-accent);
color: var(--toolbox-accent-contrast);
}
.primary-button:hover:not(:disabled) {
border-color: var(--toolbox-accent-hover);
background: var(--toolbox-accent-hover);
}
.compare-bar {
display: flex;
align-items: center;
gap: 0.6rem;
}
.compare-bar p {
margin: 0 0 0 auto;
color: var(--toolbox-muted);
font-size: 0.85rem;
font-weight: 650;
text-align: right;
}
.compare-bar p[data-status="pending"]::before {
content: "";
display: inline-block;
width: 0.72rem;
height: 0.72rem;
margin-right: 0.45rem;
border: 2px solid var(--toolbox-border);
border-top-color: var(--toolbox-accent);
border-radius: 50%;
vertical-align: -0.08rem;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(1turn);
}
}
.diagnostics {
display: grid;
gap: 0.55rem;
margin: 0;
padding: 0;
list-style: none;
}
.diagnostic {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 0.7rem;
align-items: baseline;
padding: 0.8rem 0.9rem;
border: 1px solid var(--toolbox-border);
border-left: 0.28rem solid var(--toolbox-accent);
border-radius: 0.72rem;
background: var(--toolbox-surface);
}
.diagnostic--error {
border-left-color: var(--toolbox-danger);
}
.diagnostic small {
color: var(--toolbox-muted);
}
.results {
display: grid;
gap: 1rem;
min-width: 0;
}
.summary-grid {
display: grid;
grid-template-columns: minmax(12rem, 1.45fr) repeat(4, minmax(7.25rem, 1fr));
gap: 0.7rem;
overflow-x: auto;
}
.summary-card {
display: grid;
min-width: 7.25rem;
gap: 0.12rem;
padding: 0.75rem;
border: 1px solid var(--toolbox-border);
border-top: 0.25rem solid var(--toolbox-border);
border-radius: 0.75rem;
background: var(--toolbox-surface);
}
.summary-card > span,
.summary-card small {
color: var(--toolbox-muted);
font-size: 0.72rem;
text-transform: capitalize;
}
.summary-card strong {
font-size: 1.35rem;
}
.summary-card--added {
border-top-color: var(--diff-added);
}
.summary-card--removed {
border-top-color: var(--diff-removed);
}
.summary-card--modified {
border-top-color: var(--diff-modified);
}
.summary-card--normalized {
border-top-color: var(--diff-normalized);
}
.metadata-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
}
.metadata dl {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(5rem, 1fr));
gap: 1px;
overflow: hidden;
margin: 0;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-border);
}
.metadata dl div {
padding: 0.55rem;
background: var(--toolbox-surface-soft);
}
.metadata dt {
color: var(--toolbox-muted);
font-size: 0.68rem;
font-weight: 680;
text-transform: uppercase;
}
.metadata dd {
margin: 0.15rem 0 0;
font:
700 0.86rem/1.2 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.normalization-list {
display: flex;
flex-wrap: wrap;
gap: 0.42rem;
align-items: center;
margin-top: 0.8rem;
}
.normalization-list strong {
margin-right: 0.25rem;
font-size: 0.78rem;
}
.normalization-list span {
padding: 0.25rem 0.5rem;
border-radius: 999px;
background: var(--toolbox-accent-soft);
color: var(--toolbox-accent);
font-size: 0.72rem;
font-weight: 680;
}
.result-panel {
min-height: 12rem;
overflow: hidden;
}
.unified-diff,
.side-diff {
display: grid;
gap: 1px;
overflow: auto;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-border);
}
.diff-row {
min-width: 0;
background: var(--toolbox-surface);
}
.diff-row__meta {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
align-items: center;
padding: 0.42rem 0.65rem;
border-bottom: 1px solid var(--toolbox-border);
color: var(--toolbox-muted);
font-size: 0.72rem;
}
.diff-row__meta code {
color: var(--toolbox-text);
}
.kind-badge {
padding: 0.14rem 0.38rem;
border-radius: 999px;
background: var(--toolbox-surface-soft);
color: var(--toolbox-text);
font-weight: 740;
text-transform: uppercase;
}
.paired-lines {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.paired-lines > div,
.single-line {
position: relative;
min-width: 0;
padding: 0.55rem 0.65rem 0.55rem 2rem;
}
.paired-lines > div::before,
.single-line::before {
content: attr(data-prefix);
position: absolute;
left: 0.72rem;
color: var(--toolbox-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-weight: 800;
}
.paired-lines > div + div {
border-left: 1px solid var(--toolbox-border);
}
.diff-row--added .single-line,
.diff-row--modified .paired-lines > div:last-child,
.side-row--added > div:last-child,
.side-row--modified > div:last-child {
background: var(--diff-added-soft);
}
.diff-row--removed .single-line,
.diff-row--modified .paired-lines > div:first-child,
.side-row--removed > div:first-child,
.side-row--modified > div:first-child {
background: var(--diff-removed-soft);
}
.diff-row--normalized .paired-lines > div,
.side-row--normalized > div {
background: var(--diff-normalized-soft);
}
.diff-row pre,
.side-row pre {
overflow-wrap: anywhere;
margin: 0;
white-space: pre-wrap;
font:
0.78rem/1.5 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
}
.side-diff__header,
.side-row {
display: grid;
grid-template-columns: repeat(2, minmax(18rem, 1fr));
min-width: 36rem;
background: var(--toolbox-surface);
}
.side-diff__header > *,
.side-row > div {
min-width: 0;
padding: 0.6rem;
}
.side-diff__header > * + *,
.side-row > div + div {
border-left: 1px solid var(--toolbox-border);
}
.side-row small {
display: block;
margin-bottom: 0.35rem;
color: var(--toolbox-muted);
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.artifact,
.patch-grid {
display: grid;
min-width: 0;
gap: 0.75rem;
}
.patch-grid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 28rem), 1fr));
}
.artifact-heading {
margin: 0;
}
.artifact-heading > div:last-child {
display: flex;
flex-wrap: wrap;
gap: 0.45rem;
}
.artifact textarea {
min-height: 25rem;
}
.empty-result {
margin: 0;
padding: 2rem;
color: var(--toolbox-muted);
text-align: center;
}
.action-notice {
position: fixed;
right: 1rem;
bottom: 1rem;
z-index: 20;
max-width: min(26rem, calc(100% - 2rem));
margin: 0;
padding: 0.7rem 0.9rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.7rem;
background: var(--toolbox-surface);
box-shadow: 0 12px 36px rgb(20 24 45 / 16%);
color: var(--toolbox-text);
}
.action-notice:empty {
display: none;
}
.loading,
.fatal {
width: min(100% - 2rem, 60rem);
margin: 2rem auto;
padding: 1rem;
}
.help-dialog {
width: min(38rem, calc(100% - 2rem));
padding: 1.2rem;
border: 1px solid var(--toolbox-border);
border-radius: 0.9rem;
background: var(--toolbox-surface);
color: var(--toolbox-text);
}
.help-dialog::backdrop {
background: rgb(20 24 45 / 55%);
}
.dialog-heading {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
}
.help-dialog li,
.help-dialog p {
line-height: 1.5;
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
scroll-behavior: auto !important;
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
}
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--diff-added: #74c69d;
--diff-added-soft: #153b2b;
--diff-removed: #ff8fa3;
--diff-removed-soft: #451f29;
--diff-modified: #ffd166;
--diff-modified-soft: #453914;
--diff-normalized: #c5b9ff;
--diff-normalized-soft: #30275c;
}
}
:root[data-theme="dark"] {
--diff-added: #74c69d;
--diff-added-soft: #153b2b;
--diff-removed: #ff8fa3;
--diff-removed-soft: #451f29;
--diff-modified: #ffd166;
--diff-modified-soft: #453914;
--diff-normalized: #c5b9ff;
--diff-normalized-soft: #30275c;
}
@media (max-width: 52rem) {
.input-grid,
.metadata-grid,
.paired-lines {
grid-template-columns: 1fr;
}
.paired-lines > div + div {
border-top: 1px solid var(--toolbox-border);
border-left: 0;
}
.summary-grid {
grid-template-columns: repeat(5, minmax(7.25rem, 1fr));
}
.summary-card--verdict {
grid-column: 1 / -1;
}
}
@media (max-width: 42rem) {
.hero,
.panel-heading,
.artifact-heading,
.compare-bar {
align-items: stretch;
flex-direction: column;
}
.privacy-pill {
align-self: flex-start;
order: -1;
}
.compare-bar p {
margin-left: 0;
text-align: left;
}
.diagnostic {
grid-template-columns: 1fr;
}
}
+8
View File
@@ -0,0 +1,8 @@
import "@testing-library/jest-dom/vitest";
import { afterEach } from "vitest";
import { cleanup } from "@testing-library/react";
afterEach(() => {
cleanup();
localStorage.clear();
});
+41
View File
@@ -0,0 +1,41 @@
{
"$schema": "https://git.add-ideas.de/lotobo/toolbox-sdk/raw/branch/main/schemas/toolbox-app.v1.schema.json",
"schemaVersion": 1,
"id": "de.add-ideas.diff-tools",
"name": "Diff Tools",
"version": "0.1.0",
"description": "Compare text and structured data locally in the browser.",
"entry": "./",
"icon": "./favicon.svg",
"categories": ["developer", "files", "productivity"],
"tags": ["diff", "compare", "json", "xml", "csv", "patch"],
"integration": {
"contextVersion": 1,
"launchModes": ["navigate", "new-tab"],
"embedding": "unsupported"
},
"requirements": {
"secureContext": false,
"workers": true,
"indexedDb": false,
"crossOriginIsolated": false,
"topLevelContext": false
},
"privacy": {
"processing": "local",
"fileUploads": true,
"telemetry": false,
"label": "Inputs stay in this browser; nothing is uploaded."
},
"source": {
"repository": "https://git.add-ideas.de/lotobo/diff-tools",
"license": "GPL-3.0-or-later"
},
"actions": [
{
"id": "source",
"label": "Source",
"url": "https://git.add-ideas.de/lotobo/diff-tools"
}
]
}
+4
View File
@@ -0,0 +1,4 @@
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
import source from "./manifest.source.json";
export const manifest = defineToolboxApp(parseToolboxApp(source));
+1
View File
@@ -0,0 +1 @@
export const APP_VERSION = "0.1.0";
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+30
View File
@@ -0,0 +1,30 @@
/// <reference lib="webworker" />
import { compareInputs, serializeFailure } from "../core/compare";
import type { CompareRequest, DiffResult } from "../core/types";
interface WorkerRequest {
id: number;
request: CompareRequest;
}
type WorkerResponse =
| { id: number; ok: true; result: DiffResult }
| {
id: number;
ok: false;
message: string;
diagnostics: ReturnType<typeof serializeFailure>["diagnostics"];
};
self.onmessage = (event: MessageEvent<WorkerRequest>) => {
const { id, request } = event.data;
let response: WorkerResponse;
try {
response = { id, ok: true, result: compareInputs(request) };
} catch (error) {
response = { id, ok: false, ...serializeFailure(error) };
}
self.postMessage(response);
};
export {};