Modify JSONC while preserving comments and formatting.
https://github.com/threepointone/aywson.git
๐๐๐ ๐๐ ๐๐๐๐๐๐๐, ๐๐๐?
Modify JSONC while preserving comments and formatting.
npm install aywson
import {
parse, // parse JSONC to object
modify, // replace fields, delete unlisted
get, // read value at path
set, // write value at path (with optional comment)
remove, // delete field at path
merge, // update fields, keep unlisted
replace, // alias for modify
patch, // alias for merge
rename, // rename a key
move, // move field to new path
getComment, // read comment (above or trailing)
setComment, // add comment above field
removeComment, // remove comment above field
getTrailingComment, // read trailing comment
setTrailingComment, // add trailing comment
removeTrailingComment, // remove trailing comment
sort, // sort object keys
format // format/prettify JSONC
} from "aywson";
modifyReplace fields, delete unlisted. Comments above deleted fields are also deleted, unless they start with **.
import { modify } from "aywson";
modify('{ /* keep this */ "a": 1, "b": 2 }', { a: 10 });
// โ '{ /* keep this */ "a": 10 }' โ comment preserved, b deleted
modify uses replace semantics โ fields not in changes are deleted. Comments (both above and trailing) on deleted fields are also deleted, unless they start with **.
parseParse a JSONC string into a JavaScript value. Unlike JSON.parse(), this handles comments and trailing commas.
import { parse } from "aywson";
parse(`{
// database config
"host": "localhost",
"port": 5432,
}`);
// โ { host: "localhost", port: 5432 }
// With TypeScript generics
interface Config {
host: string;
port: number;
}
const config = parse<Config>(jsonString);
Paths can be specified as either:
"config.database.host" or "items[0].name" (dot-notation, like the CLI)["config", "database", "host"] or ["items", 0, "name"]get(json, path)Get a value at a path.
// Using string path
get('{ "config": { "enabled": true } }', "config.enabled");
// โ true
// Using array path
get('{ "config": { "enabled": true } }', ["config", "enabled"]);
// โ true
has(json, path)Check if a path exists.
has('{ "foo": "bar" }', "foo"); // โ true (string path)
has('{ "foo": "bar" }', ["foo"]); // โ true (array path)
has('{ "foo": "bar" }', "baz"); // โ false
set(json, path, value, comment?)Set a value at a path, optionally with a comment.
// Using string path
set('{ "foo": "bar" }', "foo", "baz");
// โ '{ "foo": "baz" }'
// Using array path
set('{ "foo": "bar" }', ["foo"], "baz");
// โ '{ "foo": "baz" }'
// With a comment
set('{ "foo": "bar" }', "foo", "baz", "this is foo");
// โ adds "// this is foo" above the field
// Nested paths work with both formats
set('{ "config": {} }', "config.enabled", true);
// or
set('{ "config": {} }', ["config", "enabled"], true);
remove(json, path)Remove a field. Comments (both above and trailing) are also removed, unless they start with **.
// Using string path
remove(
`{
// this is foo
"foo": "bar",
"baz": 123
}`,
"foo"
);
// โ '{ "baz": 123 }' โ comment removed too
// Using array path
remove(
`{
"foo": "bar", // trailing comment
"baz": 123
}`,
["foo"]
);
// โ '{ "baz": 123 }' โ trailing comment removed too
// Nested paths
remove(json, "config.database.host");
// or
remove(json, ["config", "database", "host"]);
merge(json, changes)Update/add fields, never delete (unless explicit undefined).
merge('{ "a": 1, "b": 2 }', { a: 10 });
// โ '{ "a": 10, "b": 2 }' โ b preserved
replace(json, changes)Delete fields not in changes (same as modify).
replace('{ "a": 1, "b": 2 }', { a: 10 });
// โ '{ "a": 10 }' โ b deleted
patch(json, changes)Alias for merge. Use undefined to delete.
patch('{ "a": 1, "b": 2 }', { a: undefined });
// โ '{ "b": 2 }' โ a explicitly deleted
rename(json, path, newKey)Rename a key while preserving its value.
// Using string path
rename('{ "oldName": 123 }', "oldName", "newName");
// โ '{ "newName": 123 }'
// Using array path
rename('{ "oldName": 123 }', ["oldName"], "newName");
// โ '{ "newName": 123 }'
// Nested paths
rename(json, "config.oldKey", "newKey");
// or
rename(json, ["config", "oldKey"], "newKey");
move(json, fromPath, toPath)Move a field to a different location.
// Using string paths
move(
'{ "source": { "value": 123 }, "target": {} }',
"source.value",
"target.value"
);
// โ '{ "source": {}, "target": { "value": 123 } }'
// Using array paths
move(
'{ "source": { "value": 123 }, "target": {} }',
["source", "value"],
["target", "value"]
);
// โ '{ "source": {}, "target": { "value": 123 } }'
// Mixed formats also work
move(json, "source.value", ["target", "value"]);
sort(json, path?, options?)Sort object keys alphabetically while preserving comments (both above and trailing) with their respective keys.
sort(`{
// z comment
"z": 1,
// a comment
"a": 2
}`);
// โ '{ "a": 2, "z": 1 }' with comments preserved
// Trailing comments are also preserved
sort(`{
"z": 1, // z trailing
"a": 2 // a trailing
}`);
// โ '{ "a": 2 // a trailing, "z": 1 // z trailing }'
Path: Specify a path to sort only a nested object (defaults to "" or [] for root).
// Using string path
sort(json, "config.database"); // Sort only the database object
// Using array path
sort(json, ["config", "database"]); // Sort only the database object
// Root level (both equivalent)
sort(json); // or sort(json, "") or sort(json, [])
Options:
comparator?: (a: string, b: string) => number โ Custom sort function. Defaults to alphabetical.deep?: boolean โ Sort nested objects recursively. Defaults to true.// Custom sort order (reverse alphabetical)
sort(json, "", { comparator: (a, b) => b.localeCompare(a) });
// Only sort top-level keys (not nested objects)
sort(json, "", { deep: false });
// Sort only a specific nested object, non-recursively
sort(json, "config", { deep: false });
%%CODEBLOCK16%%ts
import { format } from "aywson";
// Format minified JSON
format('{"foo":"bar","baz":123}');
// โ '{
// "foo": "bar",
// "baz": 123
// }'
// Comments are preserved
format('{ /* important */ "foo": "bar" }');
// โ '{
// /* important */
// "foo": "bar"
// }'
**Options:**
- `tabSize?: number` โ Number of spaces per indentation level. Defaults to `2`.
- `insertSpaces?: boolean` โ Use spaces instead of tabs. Defaults to `true`.
- `eol?: string` โ End of line character. Defaults to `'\n'`.ts
// Use 4 spaces for indentation
format(json, { tabSize: 4 });
// Use tabs instead of spaces format(json, { insertSpaces: false });
// Use Windows-style line endings format(json, { eol: "\r\n" });
## Comment Operations
### `setComment(json, path, comment)`
Add or update a comment above a field.ts
// Using string path
setComment(
{
"enabled": true
},
"enabled",
"controls the feature"
);
// โ adds "// controls the feature" above the field
// Using array path setComment(json, ["config", "enabled"], "controls the feature");
### `removeComment(json, path)`
Remove the comment above a field.ts
// Using string path
removeComment(
{
// this will be removed
"foo": "bar"
},
"foo"
);
// โ '{ "foo": "bar" }'
// Using array path removeComment(json, ["config", "enabled"]);
### `getComment(json, path)`
Get the comment associated with a field. First checks for a comment above, then falls back to a trailing comment.ts
// Using string path
getComment(
{
// this is foo
"foo": "bar"
},
"foo"
);
// โ "this is foo"
// Using array path
getComment(
{
"foo": "bar" // trailing comment
},
["foo"]
);
// โ "trailing comment"
getComment('{ "foo": "bar" }', "foo"); // โ null (no comment)
## Trailing Comments
Trailing comments are comments on the same line after a field value:jsonc
{
"foo": "bar", // this is a trailing comment
"baz": 123 // another trailing comment
}
### `getTrailingComment(json, path)`
Get the trailing comment after a field (explicitly, ignoring comments above).ts
// Using string path
getTrailingComment(
{
"foo": "bar", // trailing comment
"baz": 123
},
"foo"
);
// โ "trailing comment"
// Using array path getTrailingComment(json, ["config", "database", "host"]);
### `setTrailingComment(json, path, comment)`
Add or update a trailing comment after a field.ts
// Using string path
setTrailingComment(
{
"foo": "bar",
"baz": 123
},
"foo",
"this is foo"
);
// โ '{ "foo": "bar" // this is foo, "baz": 123 }'
// Using array path
setTrailingComment(
{
"foo": "bar", // old comment
"baz": 123
},
["foo"],
"new comment"
);
// โ replaces "old comment" with "new comment"
### `removeTrailingComment(json, path)`
Remove the trailing comment after a field.ts
// Using string path
removeTrailingComment(
{
"foo": "bar", // this will be removed
"baz": 123
},
"foo"
);
// โ '{ "foo": "bar", "baz": 123 }'
// Using array path removeTrailingComment(json, ["config", "database", "host"]);
### Comments Above vs Trailing
You can have both a comment above and a trailing comment:ts
const json = {
// comment above
"foo": "bar", // trailing comment
"baz": 123
};
getComment(json, "foo"); // โ "comment above" (prefers above) getTrailingComment(json, "foo"); // โ "trailing comment"
// Set comment above (preserves trailing) setComment(json, "foo", "new above"); // โ both comments preserved, above is updated
// Remove comment above (preserves trailing) removeComment(json, "foo"); // โ trailing comment still there
## Preserving Comments
When deleting fields, comments are deleted by default. Start a comment with `**` to preserve it:ts
remove(
{
// this comment will be deleted
"config": {}
},
"config"
);
// โ '{}' โ comment deleted with field
remove(
{
// ** this comment will be preserved
"config": {}
},
"config"
);
// โ '{ // ** this comment will be preserved }' โ comment kept
## Object Iteration & Transformation
Even though aywson works on strings, you can still do full object manipulation:ts
import { parse, set, remove, merge } from "aywson";
let json = {
// Database settings
"database": {
"host": "localhost",
"port": 5432
},
// Feature flags
"features": {
"darkMode": false,
"beta": true
}
};
// Parse to iterate/transform
const config = parse
// Example: Update all feature flags to false
for (const [key, value] of Object.entries(config.features as object)) {
if (typeof value === "boolean") {
json = set(json, features.${key}, false); // String path
// or: json = set(json, ["features", key], false); // Array path
}
}
// Example: Remove fields based on condition for (const key of Object.keys(config)) { if (key.startsWith("_")) { json = remove(json, key); // String path // or: json = remove(json, [key]); // Array path } }
// Example: Bulk update from transformed object const updates = Object.fromEntries( Object.entries(config.database as object).map(([k, v]) => [ k, typeof v === "string" ? v.toUpperCase() : v ]) ); json = merge(json, { database: updates });
The key insight: use `parse()` to read and decide _what_ to change, then use `set()`/`remove()`/`merge()` to apply changes while preserving formatting and comments.
## Building JSONC from Scratch
You can build a JSONC file from scratch using `set()` with comments:ts
import { set } from "aywson";
let json = "{}";
// Build up the structure with comments (using string paths) json = set(json, "database", {}, "Database configuration"); json = set(json, "database.host", "localhost", "Primary database host"); json = set(json, "database.port", 5432); json = set(json, "database.ssl", true, "Enable SSL in production");
json = set(json, "features", {}, "Feature flags"); json = set(json, "features.darkMode", false); json = set(json, "features.beta", true, "Beta features - use with caution"); // Note: Array paths like ["database", "host"] are also supported
console.log(json);
Output:jsonc
{
// Database configuration
"database": {
// Primary database host
"host": "localhost",
"port": 5432,
// Enable SSL in production
"ssl": true
},
// Feature flags
"features": {
"darkMode": false,
// Beta features - use with caution
"beta": true
}
}
For more complex construction, you can also use `merge()`:ts
import { merge, setComment } from "aywson";
let json = "{}";
// Add multiple fields at once json = merge(json, { name: "my-app", version: "1.0.0", scripts: { build: "tsc", test: "vitest" } });
// Add comments where needed json = setComment(json, "scripts", "Available npm scripts"); // Note: Array paths like ["scripts"] are also supported
## CLIbash
Mutating commands always show a colored diff. Use `--dry-run` (`-n`) to preview without writing.
**Path syntax:** The CLI uses dot-notation: `config.database.host` or bracket notation for indices: `items[0].name`. The API supports both string paths (same as CLI) and array paths: `["config", "database", "host"]`.
### Security Optionsbash
## Security
aywson includes several security features to protect against common attacks when processing untrusted input:
### Path Validation
By default, the CLI prevents path traversal attacks by validating that all file paths stay within the current working directory. This prevents access to files outside the intended directory (e.g., `../etc/passwd`).
**Override:** Use the `--allow-path-traversal` flag to bypass this protection (not recommended for untrusted input).bash
### File Size Limits
To prevent memory exhaustion attacks, file size is limited by default to **50MB**. Files larger than this limit will be rejected.
**Override:** Use `--max-file-size <bytes>` to set a custom limit, or `--no-file-size-limit` to disable the limit entirely.bash
**Note:** Stdin (`-`) is exempt from file size limits.
### JSON Parsing Limits
JSON input is validated for both size and nesting depth to prevent denial-of-service attacks:
- **Default max size:** 10MB
- **Default max depth:** 100 levels
**Override:** Set environment variables to customize these limits:bash
These limits apply to JSON arguments in `set`, `modify`, and `merge` commands.
### Security Best Practices
1. **Don't disable security features** unless you fully trust your input sources
2. **Use appropriate limits** for your use case rather than disabling them entirely
3. **Validate input** before passing it to aywson when processing untrusted data
4. **Run with least privilege** - don't run aywson as root or with elevated permissions
5. **Keep dependencies updated** - regularly update aywson and its dependencies for security patches
## Comparison with `comment-json`
[`comment-json`](https://www.npmjs.com/package/comment-json) is another popular package for working with JSON files that contain comments. Here's how the two packages differ:
### Architecture
| Aspect | aywson | comment-json |
| ------------------- | ------------------------------------- | -------------------------------------- |
| **Approach** | String-in, string-out | Parse to object, modify, stringify |
| **Formatting** | Preserves original formatting exactly | Re-stringifies (may change formatting) |
| **Mutations** | Immutable (returns new string) | Mutable (modifies object in place) |
| **Comment storage** | Stays in the string | Symbol properties on object |
### Feature Set
| Category | aywson | comment-json |
| --------------------- | ------------------------------------------------------------ | ------------------------------------ |
| **Core** | `parse()` | `parse()`, `stringify()`, `assign()` |
| **Path operations** | `get()`, `has()`, `set()`, `remove()` | Object/array access |
| **Bulk updates** | `merge()`, `modify()` | `assign()` |
| **Key manipulation** | `rename()`, `move()`, `sort()` | โ |
| **Comment API** | `getComment()`, `setComment()`, `getTrailingComment()`, etc. | Symbol-based access |
| **Comment positions** | Above field and trailing (same line) | Many (before, after, inline, etc.) |
| **Extras** | CLI, `**` prefix to preserve comments | `CommentArray` for array operations |
### When to use aywson
- You need **exact formatting preservation** (whitespace, indentation, trailing commas)
- You want **surgical edits** without re-serializing the entire file
- You prefer **immutable operations** that return new strings
- You need **high-level operations** like rename, move, or sort
- You want **explicit comment manipulation** with a simple API
### When to use comment-json
- You want to work with a **JavaScript object** and make many modifications before writing back
- You're comfortable with **Symbol-based comment access**
- Re-stringifying the entire file is acceptable for your use case
### Example comparison
**comment-json:**js
const { parse, stringify, assign } = require("comment-json");
const obj = parse(jsonString); obj.database.port = 5433; assign(obj.database, { ssl: true }); const result = stringify(obj, null, 2);
**aywson:**js
import { set, merge } from "aywson";
let result = set(jsonString, "database.port", 5433); result = merge(result, { database: { ssl: true } }); // Original formatting preserved exactly ```