๐Ÿ“ฆ threepointone / aywson

Modify JSONC while preserving comments and formatting.

โ˜… 28 stars โ‘‚ 1 forks ๐Ÿ‘ 28 watching โš–๏ธ MIT License
๐Ÿ“ฅ Clone https://github.com/threepointone/aywson.git
HTTPS git clone https://github.com/threepointone/aywson.git
SSH git clone git@github.com:threepointone/aywson.git
CLI gh repo clone threepointone/aywson
github-actions[bot] github-actions[bot] Version Packages (#19) 535d704 2 months ago ๐Ÿ“ History
๐Ÿ“‚ main View all commits โ†’
๐Ÿ“ .changeset
๐Ÿ“ .github
๐Ÿ“ src
๐Ÿ“„ .gitignore
๐Ÿ“„ .prettierrc
๐Ÿ“„ aywson.png
๐Ÿ“„ biome.json
๐Ÿ“„ CHANGELOG.md
๐Ÿ“„ CONTRIBUTING.md
๐Ÿ“„ LICENSE
๐Ÿ“„ package-lock.json
๐Ÿ“„ package.json
๐Ÿ“„ README.md
๐Ÿ“„ tsconfig.json
๐Ÿ“„ README.md

aywson

๐–†๐–—๐–Š ๐–ž๐–† ๐–œ๐–Ž๐–“๐–“๐–Ž๐–“๐–Œ, ๐–˜๐–”๐–“?

Are ya winning, son?

Modify JSONC while preserving comments and formatting.

npm install aywson

Usage

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

modify

Replace 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 **.

parse

Parse 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);

Path-based Operations

Paths can be specified as either:

  • String paths: "config.database.host" or "items[0].name" (dot-notation, like the CLI)
  • Array paths: ["config", "database", "host"] or ["items", 0, "name"]
Both formats work for all path-based operations.

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 Strategies

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

Key Operations

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 Operations

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>(json);

// 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

## CLI
bash

Parse JSONC to JSON (strips comments, handles trailing commas)

aywson parse config.jsonc

Read a value

aywson get config.json database.host

Set a value (shows diff and writes to file)

aywson set config.json database.port 5433

Preview without writing

aywson set --dry-run config.json database.port 5433

Modify with replace semantics

aywson modify config.json '{"database": {"host": "prod.db.com"}}'

Merge without deleting existing fields

aywson merge config.json '{"newField": true}'

Remove a field

aywson remove config.json database.debug

Sort object keys alphabetically

aywson sort config.json

Sort only a specific nested object

aywson sort config.json dependencies

Sort without recursing into nested objects

aywson sort config.json --no-deep

Format/prettify JSONC

aywson format config.json

Format with 4-space indentation

aywson format config.json --tab-size 4

Format with tabs instead of spaces

aywson format config.json --tabs

Get a comment (above, or trailing as fallback)

aywson comment config.json database.host

Set a comment above a field

aywson comment config.json database.host "production database"

Remove a comment above a field

aywson uncomment config.json database.host

Get a trailing comment explicitly

aywson comment --trailing config.json database.port

Set a trailing comment

aywson comment --trailing config.json database.port "default: 5432"

Remove a trailing comment

aywson uncomment --trailing config.json database.port
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 Options
bash

Path validation (prevents path traversal attacks)

aywson get config.json database.host # โœ… Works aywson get ../etc/passwd root # โŒ Blocked by default aywson get --allow-path-traversal ../etc/passwd root # โœ… Override (not recommended)

File size limits (default: 50MB)

aywson parse large.json # โœ… Works if < 50MB aywson parse --max-file-size 100000000 large.json # โœ… Custom limit (100MB) aywson parse --no-file-size-limit huge.json # โœ… Disable limit (not recommended)

JSON parsing limits (via environment variables)

AYWSONMAXJSON_SIZE=20000000 aywson modify config.json '{"large": "data"}' AYWSONMAXJSON_DEPTH=200 aywson merge config.json '{"deep": {"nested": {...}}}'
## 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

Blocked by default

aywson get ../sensitive-file.json key

Override (use with caution)

aywson get --allow-path-traversal ../sensitive-file.json key
### 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

Default 50MB limit

aywson parse large.json

Custom limit (100MB)

aywson parse --max-file-size 104857600 large.json

No limit (not recommended)

aywson parse --no-file-size-limit huge.json
**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

Increase JSON size limit to 20MB

AYWSONMAXJSON_SIZE=20971520 aywson modify config.json '{"large": "data"}'

Increase depth limit to 200 levels

AYWSONMAXJSON_DEPTH=200 aywson merge config.json '{"deep": {...}}'

Both limits

AYWSONMAXJSONSIZE=20971520 AYWSONMAXJSONDEPTH=200 aywson modify config.json '...'
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 ```