Release Diff Tools 0.1.0
This commit is contained in:
+35
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 },
|
||||
};
|
||||
@@ -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"));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import "@testing-library/jest-dom/vitest";
|
||||
import { afterEach } from "vitest";
|
||||
import { cleanup } from "@testing-library/react";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.clear();
|
||||
});
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { defineToolboxApp, parseToolboxApp } from "@add-ideas/toolbox-contract";
|
||||
import source from "./manifest.source.json";
|
||||
|
||||
export const manifest = defineToolboxApp(parseToolboxApp(source));
|
||||
@@ -0,0 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -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 {};
|
||||
Reference in New Issue
Block a user