Plugin-driven JSON document diffing for PaperJSX engines — produces a typed ChangeSet (added / removed / modified / moved) with human-readable paths and severity, suitable for review UIs, redlines, and audit logs.
jsondiffpatch produces compact deltas optimized for patching, not reading. Review tooling needs the opposite: stable paths, semantic descriptions ("Slide 2 title changed"), and a severity scale so UIs can highlight material edits. @paperjsx/document-diff wraps jsondiffpatch with a plugin hook that lets each PaperJSX engine contribute domain-specific interpretation while sharing the core walker, path formatter, and move detection.
npm install @paperjsx/document-diffRequires Node.js >=18. Single runtime dependency: jsondiffpatch.
import { diffDocuments, type DiffPlugin } from "@paperjsx/document-diff";
const plugin: DiffPlugin = {
normalize: (doc) => doc,
};
const result = diffDocuments(
{ title: "Q1 Review", slides: [{ heading: "Revenue" }] },
{ title: "Q1 Review", slides: [{ heading: "Revenue growth" }] },
plugin,
);
// result.changes:
// [{ type: "modified", path: "slides[0].heading", severity: "minor",
// before: "Revenue", after: "Revenue growth", description: "slides[0].heading modified" }]
//
// result.summary: "1 change: 1 item modified"
// result.statistics: { added: 0, removed: 0, modified: 1, moved: 0 }Computes a diff between two JSON-serializable documents.
before,after— the two document snapshots to compare.plugin— aDiffPluginthat normalizes inputs and optionally annotates each change.options.includeSummary— set tofalseto skip generating the human summary string (defaulttrue).
Returns a ChangeSet:
interface ChangeSet {
changes: Change[]; // ordered list of non-suppressed changes
summary: string; // e.g. "3 changes: 2 slides added, 1 title modified"
statistics: DiffStatistics; // counts by change kind
}
interface Change {
type: "added" | "removed" | "modified" | "moved";
path: string; // e.g. "slides[0].text[2].content"
description: string;
before?: unknown;
after?: unknown;
severity: "major" | "minor" | "cosmetic";
}interface DiffPlugin<TNormalized = unknown> {
// Transform the raw input before diffing (strip render metadata,
// sort stable keys, attach __diffKey identities for move detection, etc.)
normalize(document: unknown): TNormalized;
// Override description / severity / summary label for a specific change.
// Return `null` to drop the change entirely.
interpretChange?(ctx: DiffInterpretContext<TNormalized>): DiffInterpretResult | null;
// Cheap predicate to suppress a change before any interpretation runs.
shouldSuppress?(ctx: DiffInterpretContext<TNormalized>): boolean;
}DiffInterpretContext exposes type, path, pathString, fromPath (for moves), before, after, and the normalized documents so plugins can consult sibling context when classifying a change.
createDiffKey(...parts)— joins non-empty, non-null parts with:to build stable__diffKeyidentities for array items (used by the defaultobjectHashfor move detection).isInternalDiffField(segment)—trueif the path segment starts with the reserved__diffprefix (these paths are suppressed automatically).
Array items are matched by __diffKey when present, falling back to positional index. Attach __diffKey inside your normalize() so reordered items surface as a single moved change instead of an added + removed pair:
const plugin: DiffPlugin = {
normalize: (doc) => ({
...doc,
slides: doc.slides.map((slide, i) => ({
...slide,
__diffKey: createDiffKey("slide", slide.id ?? i),
})),
}),
};__diffKey and any other __diff* field is stripped from the reported path automatically.
Plugins can upgrade generic diffs into domain-aware descriptions:
const pptxPlugin: DiffPlugin = {
normalize: (doc) => doc,
interpretChange: (ctx) => {
if (ctx.path[0] === "slides" && ctx.type === "added") {
return {
description: `Slide ${Number(ctx.path[1]) + 1} added`,
severity: "major",
summaryLabel: "slide added",
};
}
if (/^slides\[\d+\]\.notes$/.test(ctx.pathString)) {
return { severity: "cosmetic", summaryLabel: "speaker note edited" };
}
return null; // fall back to defaults
},
shouldSuppress: (ctx) =>
ctx.pathString.endsWith(".renderedAt") || ctx.pathString.endsWith(".cacheKey"),
};shouldSuppress runs before interpretChange and is the right place to drop non-semantic render metadata. Returning null from interpretChange keeps the change with default formatting; returning the explicit object overrides any or all of description / severity / summaryLabel.
Paths use dotted property access for identifier-safe keys, bracketed JSON strings for other keys, and [n] for array indices:
title
slides[0].heading
meta["content-type"]
This format is stable across runs and safe to surface in UI.
Apache-2.0. See LICENSE.