Skip to content

paperjsx/document-diff

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 

Repository files navigation

@paperjsx/document-diff

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.

npm license

Why

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.

Install

npm install @paperjsx/document-diff

Requires Node.js >=18. Single runtime dependency: jsondiffpatch.

Quick Start

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 }

API

diffDocuments(before, after, plugin, options?) => ChangeSet

Computes a diff between two JSON-serializable documents.

  • before, after — the two document snapshots to compare.
  • plugin — a DiffPlugin that normalizes inputs and optionally annotates each change.
  • options.includeSummary — set to false to skip generating the human summary string (default true).

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";
}

DiffPlugin<TNormalized>

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.

Helpers

  • createDiffKey(...parts) — joins non-empty, non-null parts with : to build stable __diffKey identities for array items (used by the default objectHash for move detection).
  • isInternalDiffField(segment)true if the path segment starts with the reserved __diff prefix (these paths are suppressed automatically).

Move Detection

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.

Semantic Interpretation

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.

Path Format

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.

License

Apache-2.0. See LICENSE.

About

Diff JSON document specs

Topics

Resources

License

Security policy

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors