json-tree-merge 0.1.0
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/LICENSE +21 -0
- package/README.md +255 -0
- package/dist/cli/help.d.ts +1 -0
- package/dist/cli/help.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +9 -0
- package/dist/cli/load-config.d.ts +9 -0
- package/dist/cli/load-config.js +83 -0
- package/dist/cli/main.d.ts +6 -0
- package/dist/cli/main.js +73 -0
- package/dist/cli/parse-args.d.ts +11 -0
- package/dist/cli/parse-args.js +101 -0
- package/dist/cli/resolve-options.d.ts +14 -0
- package/dist/cli/resolve-options.js +31 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/lib/create-logger.d.ts +2 -0
- package/dist/lib/create-logger.js +8 -0
- package/dist/lib/find-group-files.d.ts +7 -0
- package/dist/lib/find-group-files.js +19 -0
- package/dist/lib/is-group-file-in-scope.d.ts +6 -0
- package/dist/lib/is-group-file-in-scope.js +15 -0
- package/dist/lib/is-group-json-file.d.ts +1 -0
- package/dist/lib/is-group-json-file.js +6 -0
- package/dist/lib/merge-files-into-tree.d.ts +9 -0
- package/dist/lib/merge-files-into-tree.js +44 -0
- package/dist/lib/merge-json-tree.d.ts +13 -0
- package/dist/lib/merge-json-tree.js +39 -0
- package/dist/lib/scan-directory.d.ts +5 -0
- package/dist/lib/scan-directory.js +33 -0
- package/dist/lib/write-tree.d.ts +9 -0
- package/dist/lib/write-tree.js +49 -0
- package/package.json +59 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 sedlukha
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
# json-tree-merge
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/json-tree-merge)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
|
|
7
|
+
> Scan a directory tree for `<group>.json` files and fold them into a single
|
|
8
|
+
> nested-object JSON per `<group>`. Framework- and domain-agnostic — great for
|
|
9
|
+
> [next-intl](https://next-intl.dev) locale merging, but the engine knows
|
|
10
|
+
> nothing about locales or messages.
|
|
11
|
+
|
|
12
|
+
- **Zero runtime dependencies** — only `node:fs` and `node:path`.
|
|
13
|
+
- **Atomic writes** — uses the `tmp + rename` pattern so a crash never leaves
|
|
14
|
+
half-written files.
|
|
15
|
+
- **Works via CLI and as a library** — same engine either way.
|
|
16
|
+
- **Runs through `npx`** — no global install needed.
|
|
17
|
+
- **TypeScript types included.**
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Quick start
|
|
22
|
+
|
|
23
|
+
### Via `npx` (no install)
|
|
24
|
+
|
|
25
|
+
```bash
|
|
26
|
+
npx json-tree-merge \
|
|
27
|
+
--input ./packages \
|
|
28
|
+
--output ./messages \
|
|
29
|
+
--groups en,ru \
|
|
30
|
+
--exclude messages,src
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### Install locally
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install --save-dev json-tree-merge
|
|
37
|
+
# or: pnpm add -D json-tree-merge / yarn add -D json-tree-merge
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
Then in `package.json`:
|
|
41
|
+
|
|
42
|
+
```jsonc
|
|
43
|
+
{
|
|
44
|
+
"scripts": {
|
|
45
|
+
"merge:i18n": "json-tree-merge --input ./packages --output ./messages --groups en,ru"
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
---
|
|
51
|
+
|
|
52
|
+
## What it does
|
|
53
|
+
|
|
54
|
+
For each input directory, the engine recursively finds files whose basename
|
|
55
|
+
matches one of the supplied `groupNames` (`en.json`, `ru.json`, …), reads
|
|
56
|
+
them, and merges the contents into a single output JSON per group. Each
|
|
57
|
+
directory segment between an input path and the file becomes a nested key,
|
|
58
|
+
minus anything in `excludePathSegments`.
|
|
59
|
+
|
|
60
|
+
### Example
|
|
61
|
+
|
|
62
|
+
Given this tree:
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
packages/
|
|
66
|
+
├── auth/
|
|
67
|
+
│ └── messages/
|
|
68
|
+
│ ├── en.json {"login": "Log in"}
|
|
69
|
+
│ └── ru.json {"login": "Войти"}
|
|
70
|
+
└── billing/
|
|
71
|
+
└── messages/
|
|
72
|
+
└── en.json {"checkout": "Checkout"}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Running:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx json-tree-merge \
|
|
79
|
+
--input ./packages \
|
|
80
|
+
--output ./messages \
|
|
81
|
+
--groups en,ru \
|
|
82
|
+
--exclude messages
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
Produces:
|
|
86
|
+
|
|
87
|
+
```jsonc
|
|
88
|
+
// messages/en.json
|
|
89
|
+
{
|
|
90
|
+
"auth": { "login": "Log in" },
|
|
91
|
+
"billing": { "checkout": "Checkout" }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// messages/ru.json
|
|
95
|
+
{
|
|
96
|
+
"auth": { "login": "Войти" }
|
|
97
|
+
}
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The `messages` segment is stripped because it was passed to `--exclude`.
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
## CLI
|
|
105
|
+
|
|
106
|
+
```
|
|
107
|
+
json-tree-merge [options]
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
| Flag | Alias | Description |
|
|
111
|
+
| --------------------- | ----- | ------------------------------------------------------------------------ |
|
|
112
|
+
| `--input <dir>` | `-i` | Directory to scan recursively. Repeatable, or comma-separated. |
|
|
113
|
+
| `--output <dir>` | `-o` | Directory where merged `<group>.json` files are written. |
|
|
114
|
+
| `--groups <names>` | `-g` | Comma-separated allowed basenames (e.g. `en,ru`). |
|
|
115
|
+
| `--exclude <names>` | `-e` | Comma-separated path segments to strip from the nested keys. |
|
|
116
|
+
| `--config <file>` | `-c` | Load options from a JSON config file. Flags override file values. |
|
|
117
|
+
| `--debug` | | Print verbose progress to stderr. |
|
|
118
|
+
| `--help` | `-h` | Show help. |
|
|
119
|
+
| `--version` | `-v` | Print version. |
|
|
120
|
+
|
|
121
|
+
Repeating flags is equivalent to a comma-separated list:
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
json-tree-merge -i ./apps -i ./packages -o ./out -g en,ru
|
|
125
|
+
# same as
|
|
126
|
+
json-tree-merge -i ./apps,./packages -o ./out -g en,ru
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Config file
|
|
130
|
+
|
|
131
|
+
If `--config <file>` is not specified, the CLI looks for
|
|
132
|
+
`json-tree-merge.config.json` (then `.json-tree-merge.json`) in the current
|
|
133
|
+
working directory. CLI flags always override config values.
|
|
134
|
+
|
|
135
|
+
```jsonc
|
|
136
|
+
// json-tree-merge.config.json
|
|
137
|
+
{
|
|
138
|
+
"input": ["./packages", "./apps"],
|
|
139
|
+
"output": "./messages",
|
|
140
|
+
"groups": ["en", "ru"],
|
|
141
|
+
"exclude": ["messages", "src"],
|
|
142
|
+
"debug": false
|
|
143
|
+
}
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Run with just:
|
|
147
|
+
|
|
148
|
+
```bash
|
|
149
|
+
npx json-tree-merge
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
### Exit codes
|
|
153
|
+
|
|
154
|
+
| Code | Meaning |
|
|
155
|
+
| ---- | ------------------------------------------------------ |
|
|
156
|
+
| `0` | Success. |
|
|
157
|
+
| `1` | Runtime error (I/O, invalid JSON, etc). |
|
|
158
|
+
| `2` | Invalid CLI usage or missing/invalid configuration. |
|
|
159
|
+
|
|
160
|
+
---
|
|
161
|
+
|
|
162
|
+
## Programmatic API
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
import { mergeJsonTree, createLogger } from "json-tree-merge"
|
|
166
|
+
|
|
167
|
+
const { sourceFiles, written } = mergeJsonTree({
|
|
168
|
+
inputPaths: ["/abs/path/to/packages", "/abs/path/to/apps"],
|
|
169
|
+
outputDir: "/abs/path/to/messages",
|
|
170
|
+
groupNames: ["en", "ru"] as const,
|
|
171
|
+
excludePathSegments: ["messages", "src"],
|
|
172
|
+
logger: createLogger(true), // optional — defaults to no-op
|
|
173
|
+
isShuttingDown: () => false, // optional — defaults to () => false
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
console.log(`Wrote ${written.length} groups from ${sourceFiles} files`)
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
### `mergeJsonTree(options)`
|
|
180
|
+
|
|
181
|
+
The primary entry point. Scans, merges, and writes one file per group.
|
|
182
|
+
|
|
183
|
+
| Option | Type | Default | Description |
|
|
184
|
+
| --------------------- | ------------------- | ------------- | ------------------------------------------------------------------------ |
|
|
185
|
+
| `inputPaths` | `string[]` | — | Absolute directories to scan recursively. |
|
|
186
|
+
| `outputDir` | `string` | — | Absolute directory where merged `<group>.json` files are written. |
|
|
187
|
+
| `groupNames` | `readonly string[]` | — | Allowed file basenames. `de.json` is ignored unless `"de"` is listed. |
|
|
188
|
+
| `excludePathSegments` | `string[]` | `[]` | Path segments stripped when computing the nested key path. |
|
|
189
|
+
| `logger` | `Logger` | no-op | `(...args) => void`. Use `createLogger(true)` for verbose output. |
|
|
190
|
+
| `isShuttingDown` | `() => boolean` | `() => false` | Hook to abort writes during graceful shutdown (e.g. SIGTERM handlers). |
|
|
191
|
+
| `relevantChanges` | `string[]` | `[]` | Files that triggered this merge (used only for log output). |
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
|
|
195
|
+
```ts
|
|
196
|
+
{ sourceFiles: number; written: string[] }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Primitives
|
|
200
|
+
|
|
201
|
+
For advanced consumers that want to compose differently:
|
|
202
|
+
|
|
203
|
+
| Export | Purpose |
|
|
204
|
+
| ----------------------- | ---------------------------------------------------- |
|
|
205
|
+
| `scanDirectory` | Recursive scanner. |
|
|
206
|
+
| `mergeFilesIntoTree` | Pure transform (no I/O). |
|
|
207
|
+
| `writeTree` | Atomic write of the merged tree. |
|
|
208
|
+
| `findGroupFiles` | Scan wrapper across multiple input paths. |
|
|
209
|
+
| `isGroupJsonFile` | Basename predicate. |
|
|
210
|
+
| `isGroupFileInScope` | Full predicate (in input, not in output, allowed). |
|
|
211
|
+
| `createLogger(debug)` | Debug-gated logger factory. |
|
|
212
|
+
|
|
213
|
+
---
|
|
214
|
+
|
|
215
|
+
## Behavior details
|
|
216
|
+
|
|
217
|
+
### Atomic writes
|
|
218
|
+
|
|
219
|
+
Files are written via the `tmp + rename` pattern, so a SIGTERM during the
|
|
220
|
+
write cannot leave the destination truncated. `rename` is atomic on POSIX
|
|
221
|
+
within the same filesystem.
|
|
222
|
+
|
|
223
|
+
### Shutdown gating
|
|
224
|
+
|
|
225
|
+
Pass `isShuttingDown` so the engine can skip writes once your host process
|
|
226
|
+
starts shutting down. The library itself doesn't install signal handlers —
|
|
227
|
+
that's the integration's responsibility.
|
|
228
|
+
|
|
229
|
+
### Logging
|
|
230
|
+
|
|
231
|
+
The library emits unprefixed messages via the injected `logger`. Integrations
|
|
232
|
+
typically wrap `createLogger` with their own prefix:
|
|
233
|
+
|
|
234
|
+
```ts
|
|
235
|
+
const baseLogger = createLogger(debug)
|
|
236
|
+
const logger: Logger = (...args) => baseLogger("[MyPlugin]", ...args)
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Skipped directories
|
|
240
|
+
|
|
241
|
+
The recursive scan skips `node_modules`, `.next`, `.turbo`, `.git`, and `dist`
|
|
242
|
+
by default. The output directory is also skipped automatically, so re-running
|
|
243
|
+
the CLI never re-ingests its own output.
|
|
244
|
+
|
|
245
|
+
---
|
|
246
|
+
|
|
247
|
+
## Requirements
|
|
248
|
+
|
|
249
|
+
- Node.js **18** or newer.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## License
|
|
254
|
+
|
|
255
|
+
[MIT](LICENSE)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const HELP_TEXT = "json-tree-merge \u2014 fold per-group JSON files in a directory tree into one file per group.\n\nUsage:\n json-tree-merge --input <dir> [--input <dir> ...] --output <dir> --groups <names> [options]\n json-tree-merge --config <file>\n json-tree-merge # uses ./json-tree-merge.config.json if present\n\nOptions:\n -i, --input <dir> Directory to scan recursively. Repeatable, or comma-separated.\n -o, --output <dir> Directory where merged <group>.json files are written.\n -g, --groups <names> Comma-separated allowed basenames (e.g. \"en,ru\").\n -e, --exclude <names> Comma-separated path segments to strip from nested keys.\n -c, --config <file> Load options from a JSON file. Flags override file values.\n --debug Print verbose progress to stderr.\n -h, --help Show this help.\n -v, --version Print package version.\n\nConfig file format (JSON):\n {\n \"input\": [\"./packages\", \"./apps\"],\n \"output\": \"./messages\",\n \"groups\": [\"en\", \"ru\"],\n \"exclude\": [\"messages\", \"src\"],\n \"debug\": false\n }\n\nExamples:\n json-tree-merge --input ./packages --output ./messages --groups en,ru\n json-tree-merge -i ./apps -i ./packages -o ./out -g en,ru -e messages,src\n json-tree-merge --config ./i18n.config.json\n";
|
package/dist/cli/help.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export const HELP_TEXT = `json-tree-merge — fold per-group JSON files in a directory tree into one file per group.
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
json-tree-merge --input <dir> [--input <dir> ...] --output <dir> --groups <names> [options]
|
|
5
|
+
json-tree-merge --config <file>
|
|
6
|
+
json-tree-merge # uses ./json-tree-merge.config.json if present
|
|
7
|
+
|
|
8
|
+
Options:
|
|
9
|
+
-i, --input <dir> Directory to scan recursively. Repeatable, or comma-separated.
|
|
10
|
+
-o, --output <dir> Directory where merged <group>.json files are written.
|
|
11
|
+
-g, --groups <names> Comma-separated allowed basenames (e.g. "en,ru").
|
|
12
|
+
-e, --exclude <names> Comma-separated path segments to strip from nested keys.
|
|
13
|
+
-c, --config <file> Load options from a JSON file. Flags override file values.
|
|
14
|
+
--debug Print verbose progress to stderr.
|
|
15
|
+
-h, --help Show this help.
|
|
16
|
+
-v, --version Print package version.
|
|
17
|
+
|
|
18
|
+
Config file format (JSON):
|
|
19
|
+
{
|
|
20
|
+
"input": ["./packages", "./apps"],
|
|
21
|
+
"output": "./messages",
|
|
22
|
+
"groups": ["en", "ru"],
|
|
23
|
+
"exclude": ["messages", "src"],
|
|
24
|
+
"debug": false
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
Examples:
|
|
28
|
+
json-tree-merge --input ./packages --output ./messages --groups en,ru
|
|
29
|
+
json-tree-merge -i ./apps -i ./packages -o ./out -g en,ru -e messages,src
|
|
30
|
+
json-tree-merge --config ./i18n.config.json
|
|
31
|
+
`;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface FileConfig {
|
|
2
|
+
debug?: boolean;
|
|
3
|
+
exclude?: string[];
|
|
4
|
+
groups?: string[];
|
|
5
|
+
input?: string[];
|
|
6
|
+
output?: string;
|
|
7
|
+
}
|
|
8
|
+
export declare const findConfigFile: (explicitPath: string | undefined, cwd: string) => string | null;
|
|
9
|
+
export declare const loadConfig: (configPath: string) => FileConfig;
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const DEFAULT_CONFIG_NAMES = [
|
|
4
|
+
"json-tree-merge.config.json",
|
|
5
|
+
".json-tree-merge.json",
|
|
6
|
+
];
|
|
7
|
+
// Locates a config file: explicit path wins; otherwise probes well-known names
|
|
8
|
+
// in `cwd`. Returns `null` (not throws) when nothing is found so the CLI can
|
|
9
|
+
// fall back to pure-flag mode.
|
|
10
|
+
export const findConfigFile = (explicitPath, cwd) => {
|
|
11
|
+
if (explicitPath) {
|
|
12
|
+
const absolute = path.isAbsolute(explicitPath)
|
|
13
|
+
? explicitPath
|
|
14
|
+
: path.resolve(cwd, explicitPath);
|
|
15
|
+
if (!fs.existsSync(absolute)) {
|
|
16
|
+
throw new Error(`Config file not found: ${absolute}`);
|
|
17
|
+
}
|
|
18
|
+
return absolute;
|
|
19
|
+
}
|
|
20
|
+
for (const name of DEFAULT_CONFIG_NAMES) {
|
|
21
|
+
const candidate = path.resolve(cwd, name);
|
|
22
|
+
if (fs.existsSync(candidate)) {
|
|
23
|
+
return candidate;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
};
|
|
28
|
+
const isStringArray = (value) => Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
29
|
+
const validate = (raw, source) => {
|
|
30
|
+
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
31
|
+
throw new Error(`${source}: config must be a JSON object`);
|
|
32
|
+
}
|
|
33
|
+
const obj = raw;
|
|
34
|
+
const result = {};
|
|
35
|
+
if (obj.input !== undefined) {
|
|
36
|
+
if (!isStringArray(obj.input)) {
|
|
37
|
+
throw new Error(`${source}: "input" must be an array of strings`);
|
|
38
|
+
}
|
|
39
|
+
result.input = obj.input;
|
|
40
|
+
}
|
|
41
|
+
if (obj.output !== undefined) {
|
|
42
|
+
if (typeof obj.output !== "string") {
|
|
43
|
+
throw new Error(`${source}: "output" must be a string`);
|
|
44
|
+
}
|
|
45
|
+
result.output = obj.output;
|
|
46
|
+
}
|
|
47
|
+
if (obj.groups !== undefined) {
|
|
48
|
+
if (!isStringArray(obj.groups)) {
|
|
49
|
+
throw new Error(`${source}: "groups" must be an array of strings`);
|
|
50
|
+
}
|
|
51
|
+
result.groups = obj.groups;
|
|
52
|
+
}
|
|
53
|
+
if (obj.exclude !== undefined) {
|
|
54
|
+
if (!isStringArray(obj.exclude)) {
|
|
55
|
+
throw new Error(`${source}: "exclude" must be an array of strings`);
|
|
56
|
+
}
|
|
57
|
+
result.exclude = obj.exclude;
|
|
58
|
+
}
|
|
59
|
+
if (obj.debug !== undefined) {
|
|
60
|
+
if (typeof obj.debug !== "boolean") {
|
|
61
|
+
throw new Error(`${source}: "debug" must be a boolean`);
|
|
62
|
+
}
|
|
63
|
+
result.debug = obj.debug;
|
|
64
|
+
}
|
|
65
|
+
return result;
|
|
66
|
+
};
|
|
67
|
+
export const loadConfig = (configPath) => {
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = fs.readFileSync(configPath, "utf-8");
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
throw new Error(`Failed to read config ${configPath}`, { cause: error });
|
|
74
|
+
}
|
|
75
|
+
let parsed;
|
|
76
|
+
try {
|
|
77
|
+
parsed = JSON.parse(raw);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
throw new Error(`Failed to parse config ${configPath}`, { cause: error });
|
|
81
|
+
}
|
|
82
|
+
return validate(parsed, configPath);
|
|
83
|
+
};
|
package/dist/cli/main.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { createLogger } from "../lib/create-logger.js";
|
|
3
|
+
import { mergeJsonTree } from "../lib/merge-json-tree.js";
|
|
4
|
+
import { HELP_TEXT } from "./help.js";
|
|
5
|
+
import { findConfigFile, loadConfig } from "./load-config.js";
|
|
6
|
+
import { parseArgs } from "./parse-args.js";
|
|
7
|
+
import { resolveOptions } from "./resolve-options.js";
|
|
8
|
+
const readVersion = () => {
|
|
9
|
+
// Resolved relative to the compiled file in dist/cli/main.js — climb to the
|
|
10
|
+
// package root. For npx, this is the unpacked tarball.
|
|
11
|
+
const url = new URL("../../package.json", import.meta.url);
|
|
12
|
+
const raw = fs.readFileSync(url, "utf-8");
|
|
13
|
+
return JSON.parse(raw).version;
|
|
14
|
+
};
|
|
15
|
+
export const runCli = ({ argv, cwd, stderr, stdout, }) => {
|
|
16
|
+
let args;
|
|
17
|
+
try {
|
|
18
|
+
args = parseArgs(argv);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
stderr.write(`${error.message}\n`);
|
|
22
|
+
return 2;
|
|
23
|
+
}
|
|
24
|
+
if (args.help) {
|
|
25
|
+
stdout.write(HELP_TEXT);
|
|
26
|
+
return 0;
|
|
27
|
+
}
|
|
28
|
+
if (args.version) {
|
|
29
|
+
try {
|
|
30
|
+
stdout.write(`${readVersion()}\n`);
|
|
31
|
+
return 0;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
stdout.write("0.0.0\n");
|
|
35
|
+
return 0;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
let config = null;
|
|
39
|
+
try {
|
|
40
|
+
const configPath = findConfigFile(args.config, cwd);
|
|
41
|
+
if (configPath) {
|
|
42
|
+
config = loadConfig(configPath);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
stderr.write(`${error.message}\n`);
|
|
47
|
+
return 2;
|
|
48
|
+
}
|
|
49
|
+
let options;
|
|
50
|
+
try {
|
|
51
|
+
options = resolveOptions({ args, config, cwd });
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
stderr.write(`${error.message}\n`);
|
|
55
|
+
return 2;
|
|
56
|
+
}
|
|
57
|
+
const logger = createLogger(options.debug);
|
|
58
|
+
try {
|
|
59
|
+
const { sourceFiles, written } = mergeJsonTree({
|
|
60
|
+
excludePathSegments: options.excludePathSegments,
|
|
61
|
+
groupNames: options.groupNames,
|
|
62
|
+
inputPaths: options.inputPaths,
|
|
63
|
+
logger,
|
|
64
|
+
outputDir: options.outputDir,
|
|
65
|
+
});
|
|
66
|
+
stdout.write(`Merged ${sourceFiles} file(s) into ${written.length} group(s): ${written.join(", ") || "none"}\n`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
stderr.write(`Error: ${error.message}\n`);
|
|
71
|
+
return 1;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export interface ParsedArgs {
|
|
2
|
+
config?: string;
|
|
3
|
+
debug?: boolean;
|
|
4
|
+
exclude?: string[];
|
|
5
|
+
groups?: string[];
|
|
6
|
+
help?: boolean;
|
|
7
|
+
input?: string[];
|
|
8
|
+
output?: string;
|
|
9
|
+
version?: boolean;
|
|
10
|
+
}
|
|
11
|
+
export declare const parseArgs: (argv: string[]) => ParsedArgs;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
const splitList = (value) => value
|
|
2
|
+
.split(",")
|
|
3
|
+
.map((item) => item.trim())
|
|
4
|
+
.filter(Boolean);
|
|
5
|
+
// Tiny argv parser: supports `--flag value`, `--flag=value`, repeated flags
|
|
6
|
+
// (e.g. `--input a --input b`), and boolean flags (`--debug`, `--help`).
|
|
7
|
+
// Avoids pulling in `yargs`/`commander` for a ~6-flag CLI.
|
|
8
|
+
export const parseArgs = (argv) => {
|
|
9
|
+
const result = {};
|
|
10
|
+
const inputs = [];
|
|
11
|
+
const groups = [];
|
|
12
|
+
const excludes = [];
|
|
13
|
+
let i = 0;
|
|
14
|
+
while (i < argv.length) {
|
|
15
|
+
const token = argv[i];
|
|
16
|
+
let key;
|
|
17
|
+
let value;
|
|
18
|
+
if (token === undefined) {
|
|
19
|
+
break;
|
|
20
|
+
}
|
|
21
|
+
if (token.startsWith("--")) {
|
|
22
|
+
const eq = token.indexOf("=");
|
|
23
|
+
if (eq >= 0) {
|
|
24
|
+
key = token.slice(2, eq);
|
|
25
|
+
value = token.slice(eq + 1);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
key = token.slice(2);
|
|
29
|
+
const next = argv[i + 1];
|
|
30
|
+
if (next !== undefined && !next.startsWith("--")) {
|
|
31
|
+
value = next;
|
|
32
|
+
i += 1;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
else {
|
|
37
|
+
throw new Error(`Unexpected positional argument: ${token}`);
|
|
38
|
+
}
|
|
39
|
+
switch (key) {
|
|
40
|
+
case "input":
|
|
41
|
+
case "i":
|
|
42
|
+
if (value === undefined) {
|
|
43
|
+
throw new Error("--input requires a directory path");
|
|
44
|
+
}
|
|
45
|
+
inputs.push(...splitList(value));
|
|
46
|
+
break;
|
|
47
|
+
case "output":
|
|
48
|
+
case "o":
|
|
49
|
+
if (value === undefined) {
|
|
50
|
+
throw new Error("--output requires a directory path");
|
|
51
|
+
}
|
|
52
|
+
result.output = value;
|
|
53
|
+
break;
|
|
54
|
+
case "groups":
|
|
55
|
+
case "g":
|
|
56
|
+
if (value === undefined) {
|
|
57
|
+
throw new Error("--groups requires a comma-separated list");
|
|
58
|
+
}
|
|
59
|
+
groups.push(...splitList(value));
|
|
60
|
+
break;
|
|
61
|
+
case "exclude":
|
|
62
|
+
case "e":
|
|
63
|
+
if (value === undefined) {
|
|
64
|
+
throw new Error("--exclude requires a comma-separated list");
|
|
65
|
+
}
|
|
66
|
+
excludes.push(...splitList(value));
|
|
67
|
+
break;
|
|
68
|
+
case "config":
|
|
69
|
+
case "c":
|
|
70
|
+
if (value === undefined) {
|
|
71
|
+
throw new Error("--config requires a file path");
|
|
72
|
+
}
|
|
73
|
+
result.config = value;
|
|
74
|
+
break;
|
|
75
|
+
case "debug":
|
|
76
|
+
result.debug = value === undefined ? true : value !== "false";
|
|
77
|
+
break;
|
|
78
|
+
case "help":
|
|
79
|
+
case "h":
|
|
80
|
+
result.help = true;
|
|
81
|
+
break;
|
|
82
|
+
case "version":
|
|
83
|
+
case "v":
|
|
84
|
+
result.version = true;
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
throw new Error(`Unknown flag: --${key}`);
|
|
88
|
+
}
|
|
89
|
+
i += 1;
|
|
90
|
+
}
|
|
91
|
+
if (inputs.length > 0) {
|
|
92
|
+
result.input = inputs;
|
|
93
|
+
}
|
|
94
|
+
if (groups.length > 0) {
|
|
95
|
+
result.groups = groups;
|
|
96
|
+
}
|
|
97
|
+
if (excludes.length > 0) {
|
|
98
|
+
result.exclude = excludes;
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
};
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { FileConfig } from "./load-config.js";
|
|
2
|
+
import type { ParsedArgs } from "./parse-args.js";
|
|
3
|
+
export interface ResolvedOptions {
|
|
4
|
+
debug: boolean;
|
|
5
|
+
excludePathSegments: string[];
|
|
6
|
+
groupNames: string[];
|
|
7
|
+
inputPaths: string[];
|
|
8
|
+
outputDir: string;
|
|
9
|
+
}
|
|
10
|
+
export declare const resolveOptions: ({ args, config, cwd, }: {
|
|
11
|
+
args: ParsedArgs;
|
|
12
|
+
config: FileConfig | null;
|
|
13
|
+
cwd: string;
|
|
14
|
+
}) => ResolvedOptions;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
const resolveDir = (cwd, value) => path.isAbsolute(value) ? value : path.resolve(cwd, value);
|
|
3
|
+
// Merge precedence: CLI flags > config file > defaults. Required fields are
|
|
4
|
+
// surfaced via a single error so users see everything that's missing at once.
|
|
5
|
+
export const resolveOptions = ({ args, config, cwd, }) => {
|
|
6
|
+
const inputRaw = args.input ?? config?.input;
|
|
7
|
+
const outputRaw = args.output ?? config?.output;
|
|
8
|
+
const groupsRaw = args.groups ?? config?.groups;
|
|
9
|
+
const excludeRaw = args.exclude ?? config?.exclude ?? [];
|
|
10
|
+
const debug = args.debug ?? config?.debug ?? false;
|
|
11
|
+
const missing = [];
|
|
12
|
+
if (!inputRaw || inputRaw.length === 0) {
|
|
13
|
+
missing.push("input");
|
|
14
|
+
}
|
|
15
|
+
if (!outputRaw) {
|
|
16
|
+
missing.push("output");
|
|
17
|
+
}
|
|
18
|
+
if (!groupsRaw || groupsRaw.length === 0) {
|
|
19
|
+
missing.push("groups");
|
|
20
|
+
}
|
|
21
|
+
if (missing.length > 0) {
|
|
22
|
+
throw new Error(`Missing required option(s): ${missing.join(", ")}. Run with --help.`);
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
debug,
|
|
26
|
+
excludePathSegments: excludeRaw,
|
|
27
|
+
groupNames: groupsRaw,
|
|
28
|
+
inputPaths: inputRaw.map((dir) => resolveDir(cwd, dir)),
|
|
29
|
+
outputDir: resolveDir(cwd, outputRaw),
|
|
30
|
+
};
|
|
31
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createLogger, type Logger } from "./lib/create-logger.js";
|
|
2
|
+
export { findGroupFiles } from "./lib/find-group-files.js";
|
|
3
|
+
export { isGroupFileInScope } from "./lib/is-group-file-in-scope.js";
|
|
4
|
+
export { isGroupJsonFile } from "./lib/is-group-json-file.js";
|
|
5
|
+
export { mergeFilesIntoTree } from "./lib/merge-files-into-tree.js";
|
|
6
|
+
export { mergeJsonTree } from "./lib/merge-json-tree.js";
|
|
7
|
+
export { scanDirectory } from "./lib/scan-directory.js";
|
|
8
|
+
export { writeTree } from "./lib/write-tree.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { createLogger } from "./lib/create-logger.js";
|
|
2
|
+
export { findGroupFiles } from "./lib/find-group-files.js";
|
|
3
|
+
export { isGroupFileInScope } from "./lib/is-group-file-in-scope.js";
|
|
4
|
+
export { isGroupJsonFile } from "./lib/is-group-json-file.js";
|
|
5
|
+
export { mergeFilesIntoTree } from "./lib/merge-files-into-tree.js";
|
|
6
|
+
export { mergeJsonTree } from "./lib/merge-json-tree.js";
|
|
7
|
+
export { scanDirectory } from "./lib/scan-directory.js";
|
|
8
|
+
export { writeTree } from "./lib/write-tree.js";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { scanDirectory } from "./scan-directory.js";
|
|
2
|
+
// `inputPaths` and `outputDir` are expected to be absolute. The library does
|
|
3
|
+
// not re-resolve them — callers are responsible for normalization.
|
|
4
|
+
export const findGroupFiles = ({ groupNames, inputPaths, logger, outputDir, }) => {
|
|
5
|
+
logger("Starting file scan...");
|
|
6
|
+
logger("Input paths:", inputPaths);
|
|
7
|
+
logger("Output dir:", outputDir);
|
|
8
|
+
logger("Looking for groups:", groupNames);
|
|
9
|
+
const results = inputPaths.flatMap((directory) => {
|
|
10
|
+
logger("Scanning directory:", directory);
|
|
11
|
+
return scanDirectory({
|
|
12
|
+
directory,
|
|
13
|
+
groupNames,
|
|
14
|
+
outputPath: outputDir,
|
|
15
|
+
});
|
|
16
|
+
});
|
|
17
|
+
logger("Total files found:", results.length);
|
|
18
|
+
return results;
|
|
19
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { isGroupJsonFile } from "./is-group-json-file.js";
|
|
3
|
+
// `path.relative` returns a non-`..` path only when `filePath` is inside `dir`,
|
|
4
|
+
// which is safer than a substring check.
|
|
5
|
+
const isInside = (filePath, dir) => {
|
|
6
|
+
const relative = path.relative(dir, filePath);
|
|
7
|
+
return (Boolean(relative) &&
|
|
8
|
+
!relative.startsWith("..") &&
|
|
9
|
+
!path.isAbsolute(relative));
|
|
10
|
+
};
|
|
11
|
+
export const isGroupFileInScope = ({ filePath, groupNames, inputPaths, outputDir, }) => {
|
|
12
|
+
const isInInputPath = inputPaths.some((inputPath) => isInside(filePath, inputPath));
|
|
13
|
+
const isInOutputPath = isInside(filePath, outputDir);
|
|
14
|
+
return (isInInputPath && !isInOutputPath && isGroupJsonFile(filePath, groupNames));
|
|
15
|
+
};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const isGroupJsonFile: (fileName: string, groupNames: readonly string[]) => boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
interface NestedTree {
|
|
2
|
+
[key: string]: NestedTree | unknown;
|
|
3
|
+
}
|
|
4
|
+
export declare const mergeFilesIntoTree: ({ excludePathSegments, files, inputPaths, }: {
|
|
5
|
+
excludePathSegments: string[];
|
|
6
|
+
files: string[];
|
|
7
|
+
inputPaths: string[];
|
|
8
|
+
}) => Record<string, NestedTree>;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const readJsonFile = (filePath) => {
|
|
4
|
+
let raw;
|
|
5
|
+
try {
|
|
6
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
7
|
+
}
|
|
8
|
+
catch (error) {
|
|
9
|
+
throw new Error(`Failed to read ${filePath}`, { cause: error });
|
|
10
|
+
}
|
|
11
|
+
try {
|
|
12
|
+
return JSON.parse(raw);
|
|
13
|
+
}
|
|
14
|
+
catch (error) {
|
|
15
|
+
throw new Error(`Failed to parse JSON in ${filePath}`, { cause: error });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
18
|
+
export const mergeFilesIntoTree = ({ excludePathSegments, files, inputPaths, }) => files.reduce((tree, filePath) => {
|
|
19
|
+
const fileName = path.basename(filePath);
|
|
20
|
+
const groupName = path.basename(fileName, ".json");
|
|
21
|
+
// `inputPaths` are expected to be absolute. Pick the longest matching
|
|
22
|
+
// parent so nested input paths resolve to the deepest match.
|
|
23
|
+
const matchingInputPath = inputPaths
|
|
24
|
+
.filter((inputPath) => {
|
|
25
|
+
const relative = path.relative(inputPath, filePath);
|
|
26
|
+
return !(relative.startsWith("..") || path.isAbsolute(relative));
|
|
27
|
+
})
|
|
28
|
+
.sort((a, b) => b.length - a.length)[0];
|
|
29
|
+
if (!matchingInputPath) {
|
|
30
|
+
throw new Error(`File path ${filePath} does not match any input paths.`);
|
|
31
|
+
}
|
|
32
|
+
const relativeDir = path.relative(matchingInputPath, path.dirname(filePath));
|
|
33
|
+
const pathParts = relativeDir
|
|
34
|
+
.split(path.sep)
|
|
35
|
+
.filter((part) => part && !excludePathSegments.includes(part));
|
|
36
|
+
const fileContents = readJsonFile(filePath);
|
|
37
|
+
tree[groupName] ??= {};
|
|
38
|
+
const branch = pathParts.reduce((current, part) => {
|
|
39
|
+
current[part] ??= {};
|
|
40
|
+
return current[part];
|
|
41
|
+
}, tree[groupName]);
|
|
42
|
+
Object.assign(branch, fileContents);
|
|
43
|
+
return tree;
|
|
44
|
+
}, {});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Logger } from "./create-logger.js";
|
|
2
|
+
export declare const mergeJsonTree: ({ excludePathSegments, groupNames, inputPaths, isShuttingDown, logger, outputDir, relevantChanges, }: {
|
|
3
|
+
excludePathSegments?: string[];
|
|
4
|
+
groupNames: readonly string[];
|
|
5
|
+
inputPaths: string[];
|
|
6
|
+
isShuttingDown?: () => boolean;
|
|
7
|
+
logger?: Logger;
|
|
8
|
+
outputDir: string;
|
|
9
|
+
relevantChanges?: string[];
|
|
10
|
+
}) => {
|
|
11
|
+
sourceFiles: number;
|
|
12
|
+
written: string[];
|
|
13
|
+
};
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { findGroupFiles } from "./find-group-files.js";
|
|
3
|
+
import { mergeFilesIntoTree } from "./merge-files-into-tree.js";
|
|
4
|
+
import { writeTree } from "./write-tree.js";
|
|
5
|
+
const noop = () => { };
|
|
6
|
+
const alwaysFalse = () => false;
|
|
7
|
+
export const mergeJsonTree = ({ excludePathSegments, groupNames, inputPaths, isShuttingDown = alwaysFalse, logger = noop, outputDir, relevantChanges = [], }) => {
|
|
8
|
+
if (relevantChanges.length > 0) {
|
|
9
|
+
const fileNames = relevantChanges.map((f) => path.basename(f)).join(", ");
|
|
10
|
+
logger(`Merging: ${fileNames}`);
|
|
11
|
+
}
|
|
12
|
+
logger("Getting group files from:", inputPaths);
|
|
13
|
+
const groupFiles = findGroupFiles({
|
|
14
|
+
groupNames,
|
|
15
|
+
inputPaths,
|
|
16
|
+
logger,
|
|
17
|
+
outputDir,
|
|
18
|
+
});
|
|
19
|
+
logger("Found group files count:", groupFiles.length);
|
|
20
|
+
if (groupFiles.length === 0) {
|
|
21
|
+
logger("No group files found!");
|
|
22
|
+
return { sourceFiles: 0, written: [] };
|
|
23
|
+
}
|
|
24
|
+
logger("Merging files into tree...");
|
|
25
|
+
const tree = mergeFilesIntoTree({
|
|
26
|
+
excludePathSegments: excludePathSegments ?? [],
|
|
27
|
+
files: groupFiles,
|
|
28
|
+
inputPaths,
|
|
29
|
+
});
|
|
30
|
+
logger("Tree top-level keys:", Object.keys(tree));
|
|
31
|
+
const { written } = writeTree({
|
|
32
|
+
isShuttingDown,
|
|
33
|
+
logger,
|
|
34
|
+
outputDir,
|
|
35
|
+
tree,
|
|
36
|
+
});
|
|
37
|
+
logger("Merge completed");
|
|
38
|
+
return { sourceFiles: groupFiles.length, written };
|
|
39
|
+
};
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { isGroupJsonFile } from "./is-group-json-file.js";
|
|
4
|
+
const SKIP_DIRS = new Set(["node_modules", ".next", ".turbo", ".git", "dist"]);
|
|
5
|
+
const isInside = (childAbs, parentAbs) => {
|
|
6
|
+
const relative = path.relative(parentAbs, childAbs);
|
|
7
|
+
return (relative === "" || !(relative.startsWith("..") || path.isAbsolute(relative)));
|
|
8
|
+
};
|
|
9
|
+
// Recursively scan a directory for group JSON files. Skips well-known
|
|
10
|
+
// build/output folders so we never re-ingest our own output.
|
|
11
|
+
export const scanDirectory = ({ directory, groupNames, outputPath, }) => {
|
|
12
|
+
if (isInside(path.resolve(directory), path.resolve(outputPath))) {
|
|
13
|
+
return [];
|
|
14
|
+
}
|
|
15
|
+
const items = fs.readdirSync(directory, { withFileTypes: true });
|
|
16
|
+
return items.flatMap((item) => {
|
|
17
|
+
const fullPath = path.join(directory, item.name);
|
|
18
|
+
if (item.isDirectory()) {
|
|
19
|
+
if (SKIP_DIRS.has(item.name)) {
|
|
20
|
+
return [];
|
|
21
|
+
}
|
|
22
|
+
return scanDirectory({
|
|
23
|
+
directory: fullPath,
|
|
24
|
+
groupNames,
|
|
25
|
+
outputPath,
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
if (item.isFile() && isGroupJsonFile(item.name, groupNames)) {
|
|
29
|
+
return [fullPath];
|
|
30
|
+
}
|
|
31
|
+
return [];
|
|
32
|
+
});
|
|
33
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Logger } from "./create-logger.js";
|
|
2
|
+
export declare const writeTree: ({ isShuttingDown, logger, outputDir, tree, }: {
|
|
3
|
+
isShuttingDown: () => boolean;
|
|
4
|
+
logger: Logger;
|
|
5
|
+
outputDir: string;
|
|
6
|
+
tree: Record<string, unknown>;
|
|
7
|
+
}) => {
|
|
8
|
+
written: string[];
|
|
9
|
+
};
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
// Atomic write: stage to a temp file then rename over the destination.
|
|
4
|
+
// POSIX `rename` is atomic on the same filesystem, so a reader (or a
|
|
5
|
+
// SIGTERM mid-write) can never observe a truncated file.
|
|
6
|
+
const atomicWrite = (destination, content) => {
|
|
7
|
+
const tempPath = `${destination}.${process.pid}.tmp`;
|
|
8
|
+
try {
|
|
9
|
+
writeFileSync(tempPath, content, "utf-8");
|
|
10
|
+
renameSync(tempPath, destination);
|
|
11
|
+
}
|
|
12
|
+
catch (error) {
|
|
13
|
+
try {
|
|
14
|
+
unlinkSync(tempPath);
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
// Temp may not exist if writeFileSync failed before creating it.
|
|
18
|
+
}
|
|
19
|
+
throw error;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
export const writeTree = ({ isShuttingDown, logger, outputDir, tree, }) => {
|
|
23
|
+
const written = [];
|
|
24
|
+
if (isShuttingDown()) {
|
|
25
|
+
logger("Skipping write — process is shutting down");
|
|
26
|
+
return { written };
|
|
27
|
+
}
|
|
28
|
+
logger("Writing tree to disk...");
|
|
29
|
+
logger("Output dir:", outputDir);
|
|
30
|
+
for (const [groupName, branch] of Object.entries(tree)) {
|
|
31
|
+
if (isShuttingDown()) {
|
|
32
|
+
logger(`Aborting before ${groupName}.json — shutting down`);
|
|
33
|
+
return { written };
|
|
34
|
+
}
|
|
35
|
+
try {
|
|
36
|
+
const destination = resolve(outputDir, `${groupName}.json`);
|
|
37
|
+
const content = JSON.stringify(branch, null, 2);
|
|
38
|
+
logger(`Writing ${groupName}.json (${content.length} bytes)`);
|
|
39
|
+
atomicWrite(destination, content);
|
|
40
|
+
written.push(groupName);
|
|
41
|
+
logger(`Wrote ${groupName}.json`);
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
logger(`Error writing ${groupName}.json:`, error);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
logger("All files written");
|
|
48
|
+
return { written };
|
|
49
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "json-tree-merge",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Scan a directory tree for <group>.json files and merge them into one nested-object JSON per group. Framework-agnostic; great for next-intl locale folding.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"json",
|
|
7
|
+
"merge",
|
|
8
|
+
"tree",
|
|
9
|
+
"i18n",
|
|
10
|
+
"next-intl",
|
|
11
|
+
"locale",
|
|
12
|
+
"messages",
|
|
13
|
+
"cli"
|
|
14
|
+
],
|
|
15
|
+
"license": "MIT",
|
|
16
|
+
"author": "sedlukha",
|
|
17
|
+
"homepage": "https://github.com/sedlukha/json-tree-merge#readme",
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/sedlukha/json-tree-merge.git"
|
|
21
|
+
},
|
|
22
|
+
"bugs": {
|
|
23
|
+
"url": "https://github.com/sedlukha/json-tree-merge/issues"
|
|
24
|
+
},
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "./dist/index.js",
|
|
27
|
+
"types": "./dist/index.d.ts",
|
|
28
|
+
"exports": {
|
|
29
|
+
".": {
|
|
30
|
+
"types": "./dist/index.d.ts",
|
|
31
|
+
"default": "./dist/index.js"
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
"bin": {
|
|
35
|
+
"json-tree-merge": "./dist/cli/index.js"
|
|
36
|
+
},
|
|
37
|
+
"files": [
|
|
38
|
+
"dist",
|
|
39
|
+
"README.md",
|
|
40
|
+
"LICENSE"
|
|
41
|
+
],
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "rimraf dist && tsc -p tsconfig.build.json && node scripts/post-build.js",
|
|
47
|
+
"dev": "tsx src/cli/index.ts",
|
|
48
|
+
"start": "node dist/cli/index.js",
|
|
49
|
+
"test": "tsx --test test/*.test.ts",
|
|
50
|
+
"typecheck": "tsc --noEmit",
|
|
51
|
+
"prepublishOnly": "npm run build && npm test"
|
|
52
|
+
},
|
|
53
|
+
"devDependencies": {
|
|
54
|
+
"@types/node": "^22.0.0",
|
|
55
|
+
"rimraf": "^6.0.0",
|
|
56
|
+
"tsx": "^4.19.0",
|
|
57
|
+
"typescript": "^5.5.0"
|
|
58
|
+
}
|
|
59
|
+
}
|