First-class JSONC manipulation in JavaScript
Previously I wrote about first-class JSONC manipulation in Rust. This weekend I wrapped this project to make it available in JavaScript.
Current approach (not great)
From my understanding, the current way most people modify JSONC files in JavaScript is via the jsonc-parser npm package.
To use this, you create a list of edits, then you apply the edits. For example:
import { assertEquals } from "@std/assert";
import { applyEdits, modify } from "jsonc-parser";
const jsonText = `{
"value": 1
}`;
const edits = modify(
jsonText,
["value"], // JSON path to modify
2, // new value
{
formattingOptions: {
insertSpaces: true,
tabSize: 2,
},
},
);
const finalText = applyEdits(jsonText, edits);
assertEquals(
finalText,
`{
"value": 2
}`,
);
This works, but quickly becomes complex for simple scenarios. Say we have a configuration file that could possibly look like this...
{
"plugins": [
"https://plugins.dprint.dev/json-0.17.0.wasm"
]
}
...and we want to programmatically append a new url to the plugins array. We need to handle the plugins property not existing, it existing with a non-array value, it being empty, or it having other elements.
Ask an AI to help you with this with npm:jsonc-parser
and you'll see the code
is quite complex.
Goal
Similar to the last blog post, the API I idealized was one where the code looks similar to this list where each step reads like a high-level description of intent:
- Parse the text.
- Get and ensure the root value is an object.
- Get and ensure that object has a plugins array value property.
- Append the url to the plugins array.
- Get the final text.
Solution
I published jsonc-morph this weekend
that achieves this API. You can install it via deno add jsr:@david/jsonc-morph
or npm install jsonc-morph
Here's an example:
import { parse } from "@david/jsonc-morph";
import { assertEquals } from "@std/assert";
const jsonText = `{
"plugins": [
"https://plugins.dprint.dev/json-0.17.0.wasm" // json plugin
]
}`;
// 1. Parse the text.
const root = parse(jsonText);
// 2. Get and ensure the root value is an object.
const rootObj = root.asObjectOrForce();
// 3. Get and ensure that object has a plugins array value property.
const plugins = rootObj.getIfArrayOrForce("plugins");
// 4. Append the url to the plugins array.
plugins.append("https://plugins.dprint.dev/typescript-0.95.11.wasm");
// 5. Get the final text.
assertEquals(
root.toString(),
`{
"plugins": [
"https://plugins.dprint.dev/json-0.17.0.wasm", // json plugin
"https://plugins.dprint.dev/typescript-0.95.11.wasm"
]
}`,
);
The complexity is abstracted away, and low level concerns are automatically handled.
- Comments in the file are maintained and not shifted around when making changes.
- Proper indentation and newlines are handled for us.
- If the data currently uses trailing commas, that will be respected.
- Trailing commas can be forced by calling
root.setTrailingCommas(true);
- Trailing commas can be forced by calling
You might have noticed this API is similar to my project ts-morph, which is for modifying TypeScript/JavaScript files.
Implementation
So how does this work under the hood?
This implementation uses a concrete syntax tree (CST) which is like an abstract syntax tree (AST), but also stores the whitespace, tokens, and comments in the tree. This allows for easily manipulating the tree in place taking into account everything found in the file, then printing it out when done.
I already did all the hard work in Rust though, so for this JS library I used Claude to generate wrapper code with wasm-bindgen then built it with wasmbuild to get a Wasm module that makes it available to JS.