json-remap-engine
v0.3.0
Published
JSONPath-driven remapping engine that produces JSON Patch operations.
Downloads
62
Maintainers
Readme
json-remap-engine
A lightweight, fully client-side rules engine that rewrites JSON documents by evaluating JSONPath matchers and producing JSON Patch operations. The core logic is extracted from Token Tamer and packaged for reuse in build scripts, CLIs, and browser applications.
- 💡 JSONPath matchers decide which values to prune, replace, or move.
- 🩺 Rich diagnostics list every rule, match, and warning so you can render UI feedback or fail builds.
- 🛠️ JSON Patch output (
remove,replace,move) lets you persist the changes or run them through existing patch tools. - 🧾 TOON text output (optional) – render the transformed JSON as compact human-readable text using
@byjohann/toonwith sensible defaults. - 📦 Framework agnostic – ships as ESM and CJS bundles with TypeScript declarations.
Installation
npm install json-remap-engineThe package targets Node 18+ and modern browsers (requires structuredClone or falls back to JSON serialization).
Quick start
import {
runTransformer,
createRemoveRule,
createReplaceRule,
createMoveRule,
encodeToToon,
OutputEncoding,
type EncodeOptions,
} from "json-remap-engine";
const rules = [
createRemoveRule("$.payload.largeBlob"),
createReplaceRule("$.status", "published"),
createReplaceRule("$.title", "$.metadata.safeTitle"),
createReplaceRule("$.problematic_tests[*].styles[?(@.sriError==false)]", "$.styles.safe"),
createRemoveRule("$.problematic_tests[?(@.inspection && @.inspection.meta && @.inspection.meta.status == 'OK')].inspection"),
createMoveRule("$.draft.body", "$.published[0].body"),
];
const { document, operations, diagnostics, ok } = runTransformer(input, rules);
if (!ok) {
console.error(diagnostics.flatMap((rule) => rule.errors));
}
console.log(document); // transformed JSON
console.log(operations); // JSON Patch operations that were applied
// Optional: encoded output via the same function
const encoded = runTransformer(input, rules, { encoding: OutputEncoding.Toon });
console.log(encoded.output); // TOON text (tab-delimited, '#' length marker by default)Example transformation
This sample applies every rule type, including an in-place rename that derives its new key from the matched object.
Source JSON
{
"summary": {
"status": "draft",
"services": [
{
"service": { "id": "svc-001", "description": "" },
"metadata": { "alias": "service_now" },
"analytics": { "hits": 0 }
}
]
},
"legacyTitle": "Legacy release name"
}Rules (assuming the helper factories are imported from json-remap-engine)
const source = /* JSON from the previous block */;
const rules = [
createReplaceRule("$.summary.status", "published", { id: "publish-status" }),
createRenameRule("$.summary.services[*].service", "$.metadata.alias", {
id: "rename-service",
targetMode: "jsonpath",
}),
createRemoveRule("$.summary.services[*].analytics", { id: "drop-analytics" }),
createMoveRule("$.legacyTitle", "/summary/title", { id: "hoist-title" }),
];
const { document, operations } = runTransformer(source, rules);Transformed document
{
"summary": {
"status": "published",
"services": [
{
"service_now": { "id": "svc-001", "description": "" },
"metadata": { "alias": "service_now" }
}
],
"title": "Legacy release name"
}
}Patch operations
[
{ "op": "replace", "path": "/summary/status", "value": "published" },
{ "op": "move", "from": "/summary/services/0/service", "path": "/summary/services/0/service_now" },
{ "op": "remove", "path": "/summary/services/0/analytics" },
{ "op": "move", "from": "/legacyTitle", "path": "/summary/title" }
]The rename rule runs against each service object. The JSONPath $.metadata.alias is resolved relative to the matched object, so array indices never leak into the expression, and the emitted patch still uses the standard move operation.
Why another JSON remapping approach?
- There is no single, standards-track "XSLT for JSON." Specs such as JSONPath, JSON Pointer, and JSON Patch solve slices of the problem, but teams still resort to bespoke remapping glue.
- JSONPath excels at discovering nodes (filters, wildcards, script expressions) yet stops short of templating or producing concrete mutations. Authors have to wrap selectors with imperative code.
- JSON Patch (RFC 6902) and JSON Pointer (RFC 6901) provide the minimal, auditable set of operations—but hand-writing large pointer-based rule sets for documents with evolving shapes is brittle.
json-remap-engine bridges these gaps: rules describe discovery in JSONPath while the engine emits standards-compliant JSON Patch operations with normalized pointers for downstream tooling and audits.
Alternatives
If you need template-driven transformations or full expression languages, consider:
- JSLT – JSONPath-inspired selectors paired with a functional templating language.
- JSONata – declarative queries with mapping, aggregation, and user-defined functions.
- Jolt – a Java DSL aimed at streaming pipeline remaps.
These tools are powerful but ship larger interpreters and opinionated runtimes. json-remap-engine stays lightweight for build steps, CLIs, and browser diagnostics that need deterministic JSONPath-to-JSON Patch bridging.
Rule builders
The helper factories mirror the original UI defaults and add a few ergonomics for library consumers. All helpers accept an optional options object with id, allowEmptyMatcher, allowEmptyValue, and disabled flags. When using move rules, allowEmptyValue: true now treats unresolved targets as a no-op instead of producing an error.
| Helper | Purpose | Key defaults |
| --- | --- | --- |
| createRemoveRule(matcher, options) | Removes every JSONPath match | allowEmptyMatcher=false |
| createReplaceRule(matcher, value, options) | Replaces each match with a literal value or another JSONPath value | valueMode="auto" detects JSONPath when strings start with $; pass valueMode: "literal" to keep strings like "$100" |
| createMoveRule(matcher, target, options) | Moves the source match to the target (JSON Pointer by default) | targetMode="auto" interprets leading / as JSON Pointer, leading $ as JSONPath |
| createRenameRule(matcher, target, options) | Renames object property keys in place | targetMode="auto" treats $/@ prefixes as parent-scoped JSONPath; literal strings are trimmed and applied directly |
For full control you can construct Rule objects manually.
Move rules are hardened against prototype-pollution attacks: targets containing __proto__, constructor, or prototype segments are rejected. Additionally, when allowEmptyValue is true they quietly skip execution if the target JSONPath resolves to zero pointers.
import type { Rule } from "json-remap-engine";
const customRule: Rule = {
id: "warn",
matcher: "$.items[?(@.status == 'deprecated')]",
op: "remove",
allowEmptyMatcher: true,
};Diagnostics & error handling
runTransformer returns a TransformerResult:
interface TransformerResult {
ok: boolean; // true when no rule reported errors
document: unknown; // cloned & transformed input
operations: JsonPatchOperation[]; // applied operations in execution order
diagnostics: RuleDiagnostic[]; // per-rule detail (matches, errors, warnings)
errors: string[]; // flattened list of rule errors
warnings: string[]; // flattened list of rule warnings
}When a rule fails its matcher or target it remains in diagnostics with status: "skipped" and a human-friendly message so applications can bubble the failure to users.
Pointer utilities
Additional helpers are exported for converting between analysis paths, JSONPath, and JSON Pointer strings:
analysisPathToPointer("root.payload.items[0]") // => "/payload/items/0"simpleJsonPathToPointer("$.payload.items[0]") // => "/payload/items/0"pointerExists(document, "/payload/items/0")simpleJsonPathToPointer("$.problematic_tests[*].styles[?(@.sriError==false)]") // => null (wildcards with filters are intentionally unsupported)simpleJsonPathToPointer("$.problematic_tests[?(@.inspection && @.inspection.meta && @.inspection.meta.status == 'OK')].inspection") // => null (requires guarded access to avoid runtime errors)
These utilities are reused internally when resolving move targets but exposed for downstream tooling.
Encoded output (JSON or TOON)
The library can optionally return a TOON representation of the transformed document.
encodeToToon(value, options?)– encode any JSON-compatible value.runTransformer(input, rules, { encoding })– parameterize output encoding while keeping the same API. UseOutputEncoding.JsonPretty,OutputEncoding.JsonCompact, orOutputEncoding.Toon.
Defaults (EncodeOptions) favor compact readability:
import { defaultToonOptions, OutputEncodingDescription, type EncodeOptions } from "json-remap-engine";
// defaultToonOptions = { delimiter: "\t", indent: 2, lengthMarker: "#" }You can override options and the types are re-exported for convenience:
const options: EncodeOptions = { delimiter: "|", indent: 2 };
const { output } = runTransformer(input, rules, { encoding: OutputEncoding.Toon, toonOptions: options });
// Use descriptions for UI selections
console.log(OutputEncodingDescription.Toon); // "TOON text format using @byjohann/toon."JSON Schema
A machine-readable definition of the rule format lives at docs/rules.schema.json. The file targets JSON Schema Draft 2020-12 and advertises its $id as https://json-remap-engine.dev/schemas/rules.schema.json so external tooling can $ref it directly.
Known limitations & compatibility notes
- Replacement strings that start with
$are treated as JSONPath expressions by default. UsevalueMode: "literal"when you need the literal$prefix. - Move targets resolved via JSONPath must map to exactly one pointer; ambiguous matches raise errors.
- Complex JSONPath constructs (filters, script expressions) are evaluated by
jsonpath-plus. Only “simple” paths (dot, bracket, numeric indices) can be converted to pointers when the JSONPath returns zero matches. - Deep cloning falls back to
JSON.parse(JSON.stringify(...)), so values such asBigIntorMapwill not survive cloning. - Pointer segments named
__proto__,constructor, orprototypeare rejected for mutating operations to guard against prototype pollution. Pointer utilities also avoid treating inherited properties as existing members.
JSON Patch compliance
The engine emits only remove, replace, and move operations and applies them using the pointer semantics from RFC 6902 and RFC 6901. Rename rules lower to JSON Patch move operations so downstream tooling remains fully compliant:
removerequires the pointer to resolve and executes array deletions in descending index order to preserve RFC removal guarantees.replacerequires the pointer to resolve before overwriting, mirroring the RFC requirement to test existence first.moveinternally resolvesfrom, removes it, and re-inserts the cloned value at the destination using the same constraints as RFCadd.
Because operations are applied to a cloned working document, the resulting JSON Patch array can be replayed with any compliant RFC 6902 implementation.
Scripts
npm run build– bundle todist/(CJS, ESM, and declaration files)npm run test– run the Vitest suitenpm run lint– TypeScript type-check (no emit)npm run check– type-check then tests
Continuous integration
GitHub Actions runs the same checks on every push and pull request via .github/workflows/ci.yml. The workflow uses npm ci, executes npm run check, and builds the production bundles on Node.js 18.x and 20.x.
Published GitHub releases automatically trigger .github/workflows/release.yml, which repeats the checks and runs npm publish --access public. Store an npm automation token in the repository secret NPM_TOKEN before tagging a release.
License
MIT
