json5-manifest-sync 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- package/README.md +141 -0
- package/dist/json5-manifest-sync.d.ts +80 -0
- package/dist/json5-manifest-sync.js +399 -0
- package/package.json +47 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project are documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.1.2] - 2026-02-25
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed CLI entrypoint detection so execution works through npm/yarn/pnpm `.bin` symlinks and wrappers.
|
|
10
|
+
- Fixed `npm run sync:json5` no-op behavior seen in consuming repositories installed from git dependencies.
|
|
11
|
+
- Kept direct invocation behavior intact for `node dist/json5-manifest-sync.js`.
|
|
12
|
+
|
|
13
|
+
### Tests
|
|
14
|
+
|
|
15
|
+
- Added coverage for direct invocation path detection.
|
|
16
|
+
- Added coverage for symlinked `.bin`-style invocation path detection.
|
|
17
|
+
- Added regression test proving `runCli` updates `package.json5` from canonical `package.json`.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# json5-manifest-sync
|
|
2
|
+
|
|
3
|
+
Keep a documented `package.json5` in sync with the real `package.json` used by Node and package managers.
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install github:BBaysinger/json5-manifest-sync#v0.1.3
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Add a script in your project's `package.json`:
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"scripts": {
|
|
16
|
+
"sync:json5": "json5-manifest-sync"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Run it:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
npm run sync:json5
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Why this exists
|
|
28
|
+
|
|
29
|
+
`package.json` must be strict JSON, which means comments are invalid. That makes it hard to document complex scripts, dependency choices, or workspace settings directly in the manifest.
|
|
30
|
+
|
|
31
|
+
`package.json` is arguably the most important file in most JavaScript/TypeScript repositories. It drives install behavior, scripts, dependency policy, packaging, and release workflows—so it deserves clear inline documentation.
|
|
32
|
+
|
|
33
|
+
At the same time, Node/npm/pnpm/yarn do not treat `package.json5` as a package manifest source. A JSON5 file can be great for human-readable documentation, but tooling still requires `package.json`.
|
|
34
|
+
|
|
35
|
+
> [!NOTE]
|
|
36
|
+
> It would be great to see first-class support for JSON5-style manifests in npm/tooling over time, but today this project provides a practical bridge.
|
|
37
|
+
|
|
38
|
+
This tool solves that gap by letting you maintain both:
|
|
39
|
+
|
|
40
|
+
- `package.json` as the canonical, machine-consumed manifest
|
|
41
|
+
- `package.json5` as the human-documented companion file
|
|
42
|
+
|
|
43
|
+
Then it synchronizes `package.json5` from `package.json` while preserving mapped `//` comments where possible.
|
|
44
|
+
|
|
45
|
+
## What it does
|
|
46
|
+
|
|
47
|
+
- Finds `package.json` files recursively (excluding `node_modules`)
|
|
48
|
+
- Skips paths ignored by your root `.gitignore`
|
|
49
|
+
- For each matching `package.json5`, rewrites values from canonical `package.json`
|
|
50
|
+
- Preserves/migrates mapped `//` comments for keys and supported array items
|
|
51
|
+
- Writes stable JSON5 formatting with trailing commas for cleaner diffs
|
|
52
|
+
|
|
53
|
+
Repository: https://github.com/BBaysinger/json5-manifest-sync
|
|
54
|
+
|
|
55
|
+
## Author
|
|
56
|
+
|
|
57
|
+
Bradley Baysinger ([@BBaysinger](https://github.com/BBaysinger))
|
|
58
|
+
|
|
59
|
+
## Install from GitHub
|
|
60
|
+
|
|
61
|
+
Install from a tag (recommended):
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
npm install github:BBaysinger/json5-manifest-sync#v0.1.3
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
Or from a branch during iteration:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
npm install github:BBaysinger/json5-manifest-sync#main
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Because this repo defines a `prepare` script, npm will build TypeScript during git-based install.
|
|
74
|
+
|
|
75
|
+
## Use in a project
|
|
76
|
+
|
|
77
|
+
Add a script in your consuming project's `package.json`:
|
|
78
|
+
|
|
79
|
+
```json
|
|
80
|
+
{
|
|
81
|
+
"scripts": {
|
|
82
|
+
"sync:json5": "json5-manifest-sync"
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Run it:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
npm run sync:json5
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
## Generated `package.json5` example
|
|
94
|
+
|
|
95
|
+
Illustrative output (trimmed):
|
|
96
|
+
|
|
97
|
+
<!-- prettier-ignore -->
|
|
98
|
+
```json5
|
|
99
|
+
{
|
|
100
|
+
// Package name used by npm and consumers.
|
|
101
|
+
"name": "consumer-app",
|
|
102
|
+
// Release version (keep in sync with git tags).
|
|
103
|
+
"version": "1.2.3",
|
|
104
|
+
// Development and release scripts.
|
|
105
|
+
"scripts": {
|
|
106
|
+
// Compile TypeScript to dist/.
|
|
107
|
+
"build": "tsc -p tsconfig.json",
|
|
108
|
+
},
|
|
109
|
+
}
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For the current full output style, see this repo's live example: [`package.json5`](./package.json5).
|
|
113
|
+
|
|
114
|
+
### Options
|
|
115
|
+
|
|
116
|
+
- `--no-empty-comment` or `--no-empty-comments`
|
|
117
|
+
- `--empty-comment=false`
|
|
118
|
+
- env var: `SYNC_JSON5_ADD_EMPTY_COMMENT=false`
|
|
119
|
+
|
|
120
|
+
### Formatting note
|
|
121
|
+
|
|
122
|
+
Recommended: exclude `package.json5` from Prettier (for example via `.prettierignore`).
|
|
123
|
+
|
|
124
|
+
Prettier's JSON5 formatter can remove quotes from valid keys, which makes `package.json5` less similar to canonical `package.json`. Ignoring `package.json5` helps preserve intentional key/comment style and reduces avoidable drift.
|
|
125
|
+
|
|
126
|
+
> [!NOTE]
|
|
127
|
+
> It would be nice if Prettier provided an option to preserve quoted keys in JSON5.
|
|
128
|
+
|
|
129
|
+
## Example dependency block in consumer
|
|
130
|
+
|
|
131
|
+
```json
|
|
132
|
+
{
|
|
133
|
+
"dependencies": {
|
|
134
|
+
"json5-manifest-sync": "github:BBaysinger/json5-manifest-sync#v0.1.3"
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Changelog
|
|
140
|
+
|
|
141
|
+
See `CHANGELOG.md` for full release history.
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* JSON5 Package Synchronization Tool
|
|
4
|
+
*
|
|
5
|
+
* Synchronizes package.json5 files with their canonical package.json counterparts.
|
|
6
|
+
*
|
|
7
|
+
* Source of truth: package.json (parsed as strict JSON).
|
|
8
|
+
* Output: package.json5 rewritten with stable formatting and with *some* `//` comments
|
|
9
|
+
* preserved/migrated onto the corresponding keys/items.
|
|
10
|
+
*
|
|
11
|
+
* Important: this script does NOT attempt to preserve arbitrary formatting (blank
|
|
12
|
+
* lines, exact indentation, key ordering, etc.). It regenerates the JSON5 structure
|
|
13
|
+
* using a consistent 2-space indent and trailing commas.
|
|
14
|
+
*
|
|
15
|
+
* This tool handles:
|
|
16
|
+
* - Preceding `//` comments immediately above object keys
|
|
17
|
+
* - Trailing `//` comments on the same line as a key/value
|
|
18
|
+
* - Nested objects/arrays via simple brace/bracket tracking
|
|
19
|
+
* - Arrays: preserves comments for string-literal items when each item is on its own line
|
|
20
|
+
* - Always emits trailing commas for cleaner diffs
|
|
21
|
+
*
|
|
22
|
+
* Limitations (by design / current implementation):
|
|
23
|
+
* - Only line comments (`//`) are recognized. Block comments (`/* ... *\/`) are ignored.
|
|
24
|
+
* - Trailing-comment detection is heuristic (quote counting) and is most reliable for
|
|
25
|
+
* double-quoted strings; unusual quoting/escaping can confuse it.
|
|
26
|
+
* - Array item comment mapping is primarily intended for arrays of strings; arrays of
|
|
27
|
+
* objects/numbers will serialize correctly but per-item comment preservation is limited.
|
|
28
|
+
*/
|
|
29
|
+
export type SyncOptions = {
|
|
30
|
+
/**
|
|
31
|
+
* When true, insert a blank comment line (`//`) before any key that has no
|
|
32
|
+
* preceding comment captured from the existing JSON5. This helps visually
|
|
33
|
+
* separate entries like:
|
|
34
|
+
*
|
|
35
|
+
* "private": true,
|
|
36
|
+
* //
|
|
37
|
+
* "type": "module",
|
|
38
|
+
*
|
|
39
|
+
* Defaults to true. Can be disabled via CLI flag `--no-empty-comment` or the
|
|
40
|
+
* environment variable `SYNC_JSON5_ADD_EMPTY_COMMENT=false`.
|
|
41
|
+
*/
|
|
42
|
+
addEmptyCommentIfMissing?: boolean;
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Synchronizes a JSON5 file with its canonical JSON source while preserving formatting.
|
|
46
|
+
*
|
|
47
|
+
* This is the main synchronization function that:
|
|
48
|
+
* - Parses the existing JSON5 to extract comments and structure
|
|
49
|
+
* - Applies the canonical JSON data as the source of truth
|
|
50
|
+
* - Reconstructs the JSON5 with preserved comments and consistent formatting
|
|
51
|
+
* - Uses trailing commas throughout for version control benefits
|
|
52
|
+
*
|
|
53
|
+
* The process ensures that while the data stays synchronized with the canonical
|
|
54
|
+
* source, all the valuable documentation and formatting is maintained.
|
|
55
|
+
*
|
|
56
|
+
* @param raw - Raw contents of the existing JSON5 file
|
|
57
|
+
* @param source - Parsed JSON object from the canonical package.json
|
|
58
|
+
* @returns The updated JSON5 content as a formatted string
|
|
59
|
+
*/
|
|
60
|
+
export declare function syncJson5(raw: string, source: Record<string, unknown>, options?: SyncOptions): string;
|
|
61
|
+
/**
|
|
62
|
+
* CLI entrypoint.
|
|
63
|
+
*
|
|
64
|
+
* Scans from the current working directory and synchronizes any
|
|
65
|
+
* `package.json5` files that have a sibling `package.json`.
|
|
66
|
+
*
|
|
67
|
+
* @param args - Optional CLI arguments (defaults to process argv slice)
|
|
68
|
+
*/
|
|
69
|
+
export declare function runCli(args?: string[]): void;
|
|
70
|
+
/**
|
|
71
|
+
* Determines whether this module is being executed as a CLI entrypoint.
|
|
72
|
+
*
|
|
73
|
+
* This supports direct Node execution and npm/pnpm/yarn .bin symlink paths by
|
|
74
|
+
* comparing normalized real paths when possible.
|
|
75
|
+
*
|
|
76
|
+
* @param argv1 - Entry script path (typically process.argv[1])
|
|
77
|
+
* @param moduleUrl - URL of this module (typically import.meta.url)
|
|
78
|
+
* @returns true when current process should run CLI behavior
|
|
79
|
+
*/
|
|
80
|
+
export declare function isCliInvocation(argv1?: string | undefined, moduleUrl?: string): boolean;
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from "fs";
|
|
3
|
+
import path from "path";
|
|
4
|
+
import { fileURLToPath } from "url";
|
|
5
|
+
import { sync as globSync } from "glob";
|
|
6
|
+
import ignore from "ignore";
|
|
7
|
+
/**
|
|
8
|
+
* Quotes an object key for consistent JSON5 formatting.
|
|
9
|
+
* Always returns a quoted key to maintain consistency across all properties.
|
|
10
|
+
*
|
|
11
|
+
* @param key - The key to quote
|
|
12
|
+
* @returns The quoted key string (e.g., "scripts", "dependencies")
|
|
13
|
+
*/
|
|
14
|
+
function quoteKey(key) {
|
|
15
|
+
return /^[$A-Z_][0-9A-Z_$]*$/i.test(key) ? `"${key}"` : JSON.stringify(key);
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Indents a line by the specified number of spaces and trims surrounding whitespace.
|
|
19
|
+
* Used to maintain consistent indentation throughout the generated JSON5.
|
|
20
|
+
*
|
|
21
|
+
* @param line - The line to indent
|
|
22
|
+
* @param level - The number of spaces to indent (default: 0)
|
|
23
|
+
* @returns The properly indented line
|
|
24
|
+
*/
|
|
25
|
+
function indent(line, level = 0) {
|
|
26
|
+
return " ".repeat(level) + line.trim();
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Parses a JSON5 file and builds a comment map with path-based indexing.
|
|
30
|
+
*
|
|
31
|
+
* This is the core parsing function that:
|
|
32
|
+
* - Tracks nested object/array structure using path stacks
|
|
33
|
+
* - Maps comments to their associated JSON paths (e.g., "pnpm.onlyBuiltDependencies.0")
|
|
34
|
+
* - Handles both preceding comments (above lines) and trailing comments (same line)
|
|
35
|
+
* - Heuristically detects comments vs URLs containing "//" sequences
|
|
36
|
+
* - Supports array items with individual comments and numeric indices
|
|
37
|
+
*
|
|
38
|
+
* The comment map structure allows for precise reconstruction of the original
|
|
39
|
+
* formatting while applying new data from the canonical source.
|
|
40
|
+
*
|
|
41
|
+
* @param lines - Lines from the existing JSON5 file
|
|
42
|
+
* @returns A map of dot-notation paths to comment information
|
|
43
|
+
*/
|
|
44
|
+
function buildCommentMap(lines) {
|
|
45
|
+
const commentsMap = {};
|
|
46
|
+
// State tracking for nested structure parsing.
|
|
47
|
+
const pathStack = [];
|
|
48
|
+
const arrayIndexStack = [];
|
|
49
|
+
let bufferedComments = [];
|
|
50
|
+
let insideArray = false;
|
|
51
|
+
/**
|
|
52
|
+
* Extracts trailing comments from a line while correctly handling URLs.
|
|
53
|
+
*
|
|
54
|
+
* This function attempts to distinguish actual comments vs "//" sequences that
|
|
55
|
+
* appear inside string values (like URLs) using a lightweight heuristic.
|
|
56
|
+
*
|
|
57
|
+
* Notes:
|
|
58
|
+
* - It is designed for typical package.json(5) formatting (double-quoted strings).
|
|
59
|
+
* - It does not implement full JSON5 tokenization; unusual quoting/escaping may
|
|
60
|
+
* lead to false positives/negatives.
|
|
61
|
+
*
|
|
62
|
+
* Examples:
|
|
63
|
+
* - `"url": "https://github.com/user/repo", // This is a comment` ✅
|
|
64
|
+
* - `"url": "https://github.com/user/repo"` (no comment) ✅
|
|
65
|
+
*
|
|
66
|
+
* @param line - The line to analyze for trailing comments
|
|
67
|
+
* @returns The trailing comment string (including "//") or undefined
|
|
68
|
+
*/
|
|
69
|
+
function extractTrailingComment(line) {
|
|
70
|
+
let searchStart = 0;
|
|
71
|
+
while (true) {
|
|
72
|
+
const commentIdx = line.indexOf("//", searchStart);
|
|
73
|
+
if (commentIdx === -1)
|
|
74
|
+
return undefined;
|
|
75
|
+
// Count quotes before this "//" to determine if we're inside a string
|
|
76
|
+
const beforeComment = line.slice(0, commentIdx);
|
|
77
|
+
const quoteCount = (beforeComment.match(/"/g) || []).length;
|
|
78
|
+
// Even number of quotes = outside string = actual comment
|
|
79
|
+
if (quoteCount % 2 === 0) {
|
|
80
|
+
return line.slice(commentIdx).trim();
|
|
81
|
+
}
|
|
82
|
+
// Odd number = inside string (like URL), keep searching
|
|
83
|
+
searchStart = commentIdx + 2;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
// Process each line and associate comments with object/array paths.
|
|
87
|
+
for (let i = 0; i < lines.length; i++) {
|
|
88
|
+
const line = lines[i];
|
|
89
|
+
const trimmed = line.trim();
|
|
90
|
+
// Skip empty lines
|
|
91
|
+
if (!trimmed)
|
|
92
|
+
continue;
|
|
93
|
+
// Collect preceding comments (lines that start with //)
|
|
94
|
+
if (trimmed.startsWith("//")) {
|
|
95
|
+
bufferedComments.push(line);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
// Handle array start patterns: "key": [
|
|
99
|
+
const arrayStartMatch = line.match(/^\s*(["']?)([^"']+)\1\s*:\s*\[\s*$/);
|
|
100
|
+
if (arrayStartMatch) {
|
|
101
|
+
const key = arrayStartMatch[2];
|
|
102
|
+
const fullPath = [...pathStack, key].join(".");
|
|
103
|
+
const commentInfo = {
|
|
104
|
+
preceding: [...bufferedComments],
|
|
105
|
+
};
|
|
106
|
+
const trailing = extractTrailingComment(line);
|
|
107
|
+
if (trailing) {
|
|
108
|
+
commentInfo.trailing = trailing;
|
|
109
|
+
}
|
|
110
|
+
if (commentInfo.preceding.length > 0 || commentInfo.trailing) {
|
|
111
|
+
commentsMap[fullPath] = commentInfo;
|
|
112
|
+
}
|
|
113
|
+
pathStack.push(key);
|
|
114
|
+
arrayIndexStack.push(0);
|
|
115
|
+
insideArray = true;
|
|
116
|
+
bufferedComments = [];
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
// Handle array items - lines that start with quotes but contain no colon
|
|
120
|
+
if (insideArray && trimmed.startsWith('"') && !trimmed.includes(":")) {
|
|
121
|
+
const currentArrayIndex = arrayIndexStack[arrayIndexStack.length - 1];
|
|
122
|
+
const arrayItemPath = [...pathStack, currentArrayIndex.toString()].join(".");
|
|
123
|
+
const commentInfo = {
|
|
124
|
+
preceding: [...bufferedComments],
|
|
125
|
+
};
|
|
126
|
+
const trailing = extractTrailingComment(line);
|
|
127
|
+
if (trailing) {
|
|
128
|
+
commentInfo.trailing = trailing;
|
|
129
|
+
}
|
|
130
|
+
if (commentInfo.preceding.length > 0 || commentInfo.trailing) {
|
|
131
|
+
commentsMap[arrayItemPath] = commentInfo;
|
|
132
|
+
}
|
|
133
|
+
// Increment array index for next item and clear comment buffer
|
|
134
|
+
arrayIndexStack[arrayIndexStack.length - 1]++;
|
|
135
|
+
bufferedComments = [];
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
// Handle regular object keys.
|
|
139
|
+
const keyMatch = line.match(/^\s*(["']?)([^"']+)\1\s*:/);
|
|
140
|
+
if (keyMatch) {
|
|
141
|
+
const key = keyMatch[2];
|
|
142
|
+
const fullPath = [...pathStack, key].join(".");
|
|
143
|
+
const commentInfo = {
|
|
144
|
+
preceding: [...bufferedComments],
|
|
145
|
+
};
|
|
146
|
+
const trailing = extractTrailingComment(line);
|
|
147
|
+
if (trailing) {
|
|
148
|
+
commentInfo.trailing = trailing;
|
|
149
|
+
}
|
|
150
|
+
if (commentInfo.preceding.length > 0 || commentInfo.trailing) {
|
|
151
|
+
commentsMap[fullPath] = commentInfo;
|
|
152
|
+
}
|
|
153
|
+
bufferedComments = [];
|
|
154
|
+
// If this is an object start, update the path stack
|
|
155
|
+
if (trimmed.endsWith("{")) {
|
|
156
|
+
pathStack.push(key);
|
|
157
|
+
insideArray = false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
else if (bufferedComments.length > 0 && trimmed !== "") {
|
|
161
|
+
// Non-comment, non-key lines break the comment block
|
|
162
|
+
bufferedComments = [];
|
|
163
|
+
}
|
|
164
|
+
// Handle closing brackets to maintain proper nesting.
|
|
165
|
+
if (trimmed === "}" ||
|
|
166
|
+
trimmed === "}," ||
|
|
167
|
+
trimmed === "]" ||
|
|
168
|
+
trimmed === "],") {
|
|
169
|
+
if (trimmed.startsWith("]")) {
|
|
170
|
+
insideArray = false;
|
|
171
|
+
if (arrayIndexStack.length > 0) {
|
|
172
|
+
arrayIndexStack.pop();
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (pathStack.length)
|
|
176
|
+
pathStack.pop();
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
return commentsMap;
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Generates a simple blank comment used as a visual spacer when enabled.
|
|
183
|
+
*
|
|
184
|
+
* @returns A blank comment string
|
|
185
|
+
*/
|
|
186
|
+
function generateScriptComment() {
|
|
187
|
+
return "//";
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Synchronizes a JSON5 file with its canonical JSON source while preserving formatting.
|
|
191
|
+
*
|
|
192
|
+
* This is the main synchronization function that:
|
|
193
|
+
* - Parses the existing JSON5 to extract comments and structure
|
|
194
|
+
* - Applies the canonical JSON data as the source of truth
|
|
195
|
+
* - Reconstructs the JSON5 with preserved comments and consistent formatting
|
|
196
|
+
* - Uses trailing commas throughout for version control benefits
|
|
197
|
+
*
|
|
198
|
+
* The process ensures that while the data stays synchronized with the canonical
|
|
199
|
+
* source, all the valuable documentation and formatting is maintained.
|
|
200
|
+
*
|
|
201
|
+
* @param raw - Raw contents of the existing JSON5 file
|
|
202
|
+
* @param source - Parsed JSON object from the canonical package.json
|
|
203
|
+
* @returns The updated JSON5 content as a formatted string
|
|
204
|
+
*/
|
|
205
|
+
export function syncJson5(raw, source, options = { addEmptyCommentIfMissing: true }) {
|
|
206
|
+
const lines = raw.split("\n");
|
|
207
|
+
const comments = buildCommentMap(lines);
|
|
208
|
+
const addEmptyCommentIfMissing = options.addEmptyCommentIfMissing !== undefined
|
|
209
|
+
? options.addEmptyCommentIfMissing
|
|
210
|
+
: true;
|
|
211
|
+
/**
|
|
212
|
+
* Recursively writes an object in normalized JSON5 style with mapped comments.
|
|
213
|
+
*
|
|
214
|
+
* @param obj - Object subtree from canonical JSON
|
|
215
|
+
* @param path - Dot-notation path for comment lookups
|
|
216
|
+
* @param level - Current indentation level in spaces
|
|
217
|
+
* @returns Formatted output lines for this subtree
|
|
218
|
+
*/
|
|
219
|
+
function writeObject(obj, path = [], level = 2) {
|
|
220
|
+
const result = [];
|
|
221
|
+
// Type guard to ensure obj is an object
|
|
222
|
+
if (typeof obj !== "object" || obj === null || Array.isArray(obj)) {
|
|
223
|
+
return result;
|
|
224
|
+
}
|
|
225
|
+
const keys = Object.keys(obj);
|
|
226
|
+
const indentSize = 2;
|
|
227
|
+
for (let i = 0; i < keys.length; i++) {
|
|
228
|
+
const key = keys[i];
|
|
229
|
+
const fullPath = [...path, key].join(".");
|
|
230
|
+
const val = obj[key];
|
|
231
|
+
const comma = ","; // Always add trailing comma for version control benefits
|
|
232
|
+
const commentInfo = comments[fullPath];
|
|
233
|
+
// Add any preceding comments above this key
|
|
234
|
+
if (commentInfo?.preceding && commentInfo.preceding.length > 0) {
|
|
235
|
+
for (const c of commentInfo.preceding) {
|
|
236
|
+
result.push(indent(c, level));
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
else if (addEmptyCommentIfMissing &&
|
|
240
|
+
!comments.hasOwnProperty(fullPath)) {
|
|
241
|
+
// No preceding comment captured — optionally add a blank one for spacing
|
|
242
|
+
// Applies to any section, not just scripts
|
|
243
|
+
const autoComment = generateScriptComment();
|
|
244
|
+
result.push(indent(autoComment, level));
|
|
245
|
+
}
|
|
246
|
+
if (typeof val === "object" && val !== null && !Array.isArray(val)) {
|
|
247
|
+
// Handle nested objects with optional trailing comment on opening brace
|
|
248
|
+
const openLine = `${quoteKey(key)}: {`;
|
|
249
|
+
const withTrailing = commentInfo?.trailing
|
|
250
|
+
? `${openLine} ${commentInfo.trailing}`
|
|
251
|
+
: openLine;
|
|
252
|
+
result.push(indent(withTrailing, level));
|
|
253
|
+
// Recursively process nested object
|
|
254
|
+
result.push(...writeObject(val, [...path, key], level + indentSize));
|
|
255
|
+
result.push(indent(`}${comma}`, level));
|
|
256
|
+
}
|
|
257
|
+
else if (Array.isArray(val)) {
|
|
258
|
+
// Handle arrays with optional trailing comment on opening bracket
|
|
259
|
+
const openLine = `${quoteKey(key)}: [`;
|
|
260
|
+
const withTrailing = commentInfo?.trailing
|
|
261
|
+
? `${openLine} ${commentInfo.trailing}`
|
|
262
|
+
: openLine;
|
|
263
|
+
result.push(indent(withTrailing, level));
|
|
264
|
+
// Process each array item with individual comments
|
|
265
|
+
for (let j = 0; j < val.length; j++) {
|
|
266
|
+
const itemPath = [...path, key, j.toString()].join(".");
|
|
267
|
+
const itemCommentInfo = comments[itemPath];
|
|
268
|
+
// Add preceding comments for this array item
|
|
269
|
+
if (itemCommentInfo?.preceding) {
|
|
270
|
+
for (const c of itemCommentInfo.preceding) {
|
|
271
|
+
result.push(indent(c, level + indentSize));
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
else if (addEmptyCommentIfMissing &&
|
|
275
|
+
!comments.hasOwnProperty(itemPath)) {
|
|
276
|
+
const autoComment = generateScriptComment();
|
|
277
|
+
result.push(indent(autoComment, level + indentSize));
|
|
278
|
+
}
|
|
279
|
+
// Add array item with optional trailing comment
|
|
280
|
+
const itemLine = `${JSON.stringify(val[j])},`;
|
|
281
|
+
const withTrailing = itemCommentInfo?.trailing
|
|
282
|
+
? `${itemLine} ${itemCommentInfo.trailing}`
|
|
283
|
+
: itemLine;
|
|
284
|
+
result.push(indent(withTrailing, level + indentSize));
|
|
285
|
+
}
|
|
286
|
+
result.push(indent(`]${comma}`, level));
|
|
287
|
+
}
|
|
288
|
+
else {
|
|
289
|
+
// Handle primitive values (strings, numbers, booleans) with optional trailing comment
|
|
290
|
+
const valueLine = `${quoteKey(key)}: ${JSON.stringify(val)}${comma}`;
|
|
291
|
+
const withTrailing = commentInfo?.trailing
|
|
292
|
+
? `${valueLine} ${commentInfo.trailing}`
|
|
293
|
+
: valueLine;
|
|
294
|
+
result.push(indent(withTrailing, level));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
return result;
|
|
298
|
+
}
|
|
299
|
+
// Generate the complete JSON5 structure
|
|
300
|
+
const generated = writeObject(source, [], 2);
|
|
301
|
+
return `{\n${generated.join("\n")}\n}`;
|
|
302
|
+
}
|
|
303
|
+
// 🔁 Main Execution Runner
|
|
304
|
+
//
|
|
305
|
+
// This section handles the file discovery and processing logic:
|
|
306
|
+
// - Finds all package.json files in the project (excluding node_modules)
|
|
307
|
+
// - Checks for corresponding package.json5 files
|
|
308
|
+
// - Applies root .gitignore rules to skip excluded files
|
|
309
|
+
// - Performs synchronization for each valid pair
|
|
310
|
+
/**
|
|
311
|
+
* CLI entrypoint.
|
|
312
|
+
*
|
|
313
|
+
* Scans from the current working directory and synchronizes any
|
|
314
|
+
* `package.json5` files that have a sibling `package.json`.
|
|
315
|
+
*
|
|
316
|
+
* @param args - Optional CLI arguments (defaults to process argv slice)
|
|
317
|
+
*/
|
|
318
|
+
export function runCli(args = process.argv.slice(2)) {
|
|
319
|
+
const rootDir = process.cwd();
|
|
320
|
+
const ig = ignore();
|
|
321
|
+
const gitignorePath = path.join(rootDir, ".gitignore");
|
|
322
|
+
// Load gitignore rules if available
|
|
323
|
+
if (fs.existsSync(gitignorePath)) {
|
|
324
|
+
ig.add(fs.readFileSync(gitignorePath, "utf8"));
|
|
325
|
+
}
|
|
326
|
+
// Find all package.json files recursively, excluding node_modules
|
|
327
|
+
const packageJsonPaths = globSync("**/package.json", {
|
|
328
|
+
cwd: rootDir,
|
|
329
|
+
ignore: ["**/node_modules/**"],
|
|
330
|
+
absolute: true,
|
|
331
|
+
});
|
|
332
|
+
// Parse simple CLI flags / env for options
|
|
333
|
+
let addEmptyCommentIfMissing = true;
|
|
334
|
+
// Env toggle: SYNC_JSON5_ADD_EMPTY_COMMENT=false
|
|
335
|
+
if (typeof process.env.SYNC_JSON5_ADD_EMPTY_COMMENT === "string" &&
|
|
336
|
+
process.env.SYNC_JSON5_ADD_EMPTY_COMMENT.toLowerCase() === "false") {
|
|
337
|
+
addEmptyCommentIfMissing = false;
|
|
338
|
+
}
|
|
339
|
+
// CLI toggles: --no-empty-comment, --empty-comment=false
|
|
340
|
+
for (const arg of args) {
|
|
341
|
+
if (arg === "--no-empty-comment" || arg === "--no-empty-comments") {
|
|
342
|
+
addEmptyCommentIfMissing = false;
|
|
343
|
+
}
|
|
344
|
+
else if (arg.startsWith("--empty-comment=")) {
|
|
345
|
+
const v = arg.split("=", 2)[1]?.toLowerCase();
|
|
346
|
+
if (v === "false" || v === "0" || v === "no") {
|
|
347
|
+
addEmptyCommentIfMissing = false;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
// Process each package.json to sync with its package.json5 counterpart
|
|
352
|
+
for (const packageJsonPath of packageJsonPaths) {
|
|
353
|
+
const relPath = path.relative(rootDir, packageJsonPath);
|
|
354
|
+
// Skip files that match gitignore patterns
|
|
355
|
+
if (ig.ignores(relPath))
|
|
356
|
+
continue;
|
|
357
|
+
const json5Path = packageJsonPath + "5"; // package.json5
|
|
358
|
+
// Skip if no corresponding package.json5 exists
|
|
359
|
+
if (!fs.existsSync(json5Path)) {
|
|
360
|
+
console.info(`⏩ Skipping ${relPath} — no package.json5 found`);
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
// Perform the synchronization
|
|
364
|
+
const rawJson5 = fs.readFileSync(json5Path, "utf8");
|
|
365
|
+
const sourceJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8"));
|
|
366
|
+
const updated = syncJson5(rawJson5, sourceJson, {
|
|
367
|
+
addEmptyCommentIfMissing,
|
|
368
|
+
});
|
|
369
|
+
// Write the synchronized result
|
|
370
|
+
fs.writeFileSync(json5Path, updated);
|
|
371
|
+
console.info(`✅ Synced ${relPath} → ${path.basename(json5Path)}`);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Determines whether this module is being executed as a CLI entrypoint.
|
|
376
|
+
*
|
|
377
|
+
* This supports direct Node execution and npm/pnpm/yarn .bin symlink paths by
|
|
378
|
+
* comparing normalized real paths when possible.
|
|
379
|
+
*
|
|
380
|
+
* @param argv1 - Entry script path (typically process.argv[1])
|
|
381
|
+
* @param moduleUrl - URL of this module (typically import.meta.url)
|
|
382
|
+
* @returns true when current process should run CLI behavior
|
|
383
|
+
*/
|
|
384
|
+
export function isCliInvocation(argv1 = process.argv[1], moduleUrl = import.meta.url) {
|
|
385
|
+
if (!argv1)
|
|
386
|
+
return false;
|
|
387
|
+
const modulePath = fileURLToPath(moduleUrl);
|
|
388
|
+
const entryPath = path.resolve(argv1);
|
|
389
|
+
try {
|
|
390
|
+
return fs.realpathSync(entryPath) === fs.realpathSync(modulePath);
|
|
391
|
+
}
|
|
392
|
+
catch {
|
|
393
|
+
// Fall back to normalized absolute-path comparison if realpath fails.
|
|
394
|
+
return path.resolve(entryPath) === path.resolve(modulePath);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
if (isCliInvocation()) {
|
|
398
|
+
runCli();
|
|
399
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "json5-manifest-sync",
|
|
3
|
+
"version": "0.1.3",
|
|
4
|
+
"description": "Sync package.json5 files from canonical package.json while preserving key comments.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Bradley Baysinger",
|
|
7
|
+
"url": "https://github.com/BBaysinger"
|
|
8
|
+
},
|
|
9
|
+
"type": "module",
|
|
10
|
+
"main": "./dist/json5-manifest-sync.js",
|
|
11
|
+
"types": "./dist/json5-manifest-sync.d.ts",
|
|
12
|
+
"exports": {
|
|
13
|
+
".": {
|
|
14
|
+
"types": "./dist/json5-manifest-sync.d.ts",
|
|
15
|
+
"default": "./dist/json5-manifest-sync.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"json5-manifest-sync": "./dist/json5-manifest-sync.js"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"README.md",
|
|
24
|
+
"CHANGELOG.md"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"build": "tsc -p tsconfig.json",
|
|
28
|
+
"format": "prettier . --write",
|
|
29
|
+
"format:check": "prettier . --check",
|
|
30
|
+
"test": "vitest run",
|
|
31
|
+
"test:watch": "vitest",
|
|
32
|
+
"prepare": "npm run build"
|
|
33
|
+
},
|
|
34
|
+
"engines": {
|
|
35
|
+
"node": ">=18"
|
|
36
|
+
},
|
|
37
|
+
"dependencies": {
|
|
38
|
+
"glob": "^11.0.1",
|
|
39
|
+
"ignore": "^7.0.4"
|
|
40
|
+
},
|
|
41
|
+
"devDependencies": {
|
|
42
|
+
"@types/node": "^22.13.10",
|
|
43
|
+
"prettier": "^3.8.1",
|
|
44
|
+
"typescript": "^5.8.2",
|
|
45
|
+
"vitest": "^2.1.9"
|
|
46
|
+
}
|
|
47
|
+
}
|