@helpers4/json 0.0.0-stage → 3.1.2-alpha.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.md +160 -0
- package/README.md +96 -2
- package/lib/index.d.ts +463 -0
- package/lib/index.js +928 -0
- package/lib/index.js.map +1 -0
- package/llms.txt +699 -0
- package/meta/api.json +662 -0
- package/meta/category.json +6 -0
- package/meta/examples.json +185 -0
- package/meta/licenses.json +4 -0
- package/package.json +53 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,928 @@
|
|
|
1
|
+
//#region helpers/_shared/_jsonReader.ts
|
|
2
|
+
/** JSON5 whitespace: JSON's four, plus VT, FF, NBSP, LS, PS, BOM and every Unicode space separator. @ignore */
|
|
3
|
+
var JSON5_SPACE = /[\t\n\v\f\r \u00A0\u2028\u2029\uFEFF\p{Zs}]/u;
|
|
4
|
+
/** @ignore */
|
|
5
|
+
var LINE_TERMINATOR = /[\n\r\u2028\u2029]/;
|
|
6
|
+
/** First character of an unquoted JSON5 key (ECMAScript IdentifierStart). @ignore */
|
|
7
|
+
var IDENTIFIER_START = /[\p{ID_Start}$_]/u;
|
|
8
|
+
/** Any later character of an unquoted JSON5 key (ECMAScript IdentifierPart). @ignore */
|
|
9
|
+
var IDENTIFIER_PART = /[\p{ID_Continue}$\u200C\u200D]/u;
|
|
10
|
+
/** @ignore */
|
|
11
|
+
var HEX_DIGIT = /[0-9A-Fa-f]/;
|
|
12
|
+
/** A lone surrogate or a Unicode noncharacter, which I-JSON forbids in strings. @ignore */
|
|
13
|
+
var I_JSON_FORBIDDEN = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uFDD0-\uFDEF\uFFFE\uFFFF]|[\uD83F\uD87F\uD8BF\uD8FF\uD93F\uD97F\uD9BF\uD9FF\uDA3F\uDA7F\uDABF\uDAFF\uDB3F\uDB7F\uDBBF\uDBFF][\uDFFE\uDFFF]/;
|
|
14
|
+
/** A double has 15 to 17 significant decimal digits; more than 17 expresses precision it can't hold. @ignore */
|
|
15
|
+
var MAX_SIGNIFICANT_DIGITS = 17;
|
|
16
|
+
/** @ignore */
|
|
17
|
+
function isDigit(char) {
|
|
18
|
+
return char !== void 0 && char >= "0" && char <= "9";
|
|
19
|
+
}
|
|
20
|
+
/** 1-based line and column of `index` in `text`; `\r\n` counts as one line break. @ignore */
|
|
21
|
+
function position(text, index) {
|
|
22
|
+
let line = 1;
|
|
23
|
+
let lineStart = 0;
|
|
24
|
+
for (let k = 0; k < index; k++) {
|
|
25
|
+
const char = text[k];
|
|
26
|
+
if (char === "\n" || char === "\u2028" || char === "\u2029" || char === "\r" && text[k + 1] !== "\n") {
|
|
27
|
+
line++;
|
|
28
|
+
lineStart = k + 1;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return `line ${line}, column ${index - lineStart + 1}`;
|
|
32
|
+
}
|
|
33
|
+
/** Defines `key` like `JSON.parse` does: `__proto__` becomes an own property, never the prototype. @ignore */
|
|
34
|
+
function define(object, key, value) {
|
|
35
|
+
if (key === "__proto__") Object.defineProperty(object, key, {
|
|
36
|
+
value,
|
|
37
|
+
writable: true,
|
|
38
|
+
enumerable: true,
|
|
39
|
+
configurable: true
|
|
40
|
+
});
|
|
41
|
+
else object[key] = value;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Reads one JSON or JSON5 value from `text`, the whole of it, and throws a `SyntaxError` naming
|
|
45
|
+
* the line and column of the first problem otherwise. Builds values the way `JSON.parse` does
|
|
46
|
+
* (same numbers, `__proto__` as an own property, the last of duplicate keys wins unless `iJson`).
|
|
47
|
+
* @ignore
|
|
48
|
+
*/
|
|
49
|
+
function readJson(text, { dialect, iJson = false }) {
|
|
50
|
+
const json5 = dialect === "json5";
|
|
51
|
+
let i = 0;
|
|
52
|
+
const fail = (message, at = i) => {
|
|
53
|
+
throw new SyntaxError(`parse: ${message} at ${position(text, at)}`);
|
|
54
|
+
};
|
|
55
|
+
const unexpected = () => i >= text.length ? fail("unexpected end of input") : fail(`unexpected character ${JSON.stringify(text[i])}`);
|
|
56
|
+
const isSpace = (char) => json5 ? JSON5_SPACE.test(char) : char === " " || char === " " || char === "\n" || char === "\r";
|
|
57
|
+
const skipSpace = () => {
|
|
58
|
+
while (i < text.length) {
|
|
59
|
+
const char = text[i];
|
|
60
|
+
if (isSpace(char)) i++;
|
|
61
|
+
else if (json5 && char === "/" && text[i + 1] === "/") {
|
|
62
|
+
i += 2;
|
|
63
|
+
while (i < text.length && !LINE_TERMINATOR.test(text[i])) i++;
|
|
64
|
+
} else if (json5 && char === "/" && text[i + 1] === "*") {
|
|
65
|
+
const end = text.indexOf("*/", i + 2);
|
|
66
|
+
if (end === -1) fail("unterminated block comment");
|
|
67
|
+
i = end + 2;
|
|
68
|
+
} else return;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
const hexDigits = (count) => {
|
|
72
|
+
const start = i;
|
|
73
|
+
for (let k = 0; k < count; k++) {
|
|
74
|
+
if (!HEX_DIGIT.test(text[i] ?? "")) unexpected();
|
|
75
|
+
i++;
|
|
76
|
+
}
|
|
77
|
+
return Number.parseInt(text.slice(start, i), 16);
|
|
78
|
+
};
|
|
79
|
+
const literal = (word, result) => {
|
|
80
|
+
for (const expected of word) {
|
|
81
|
+
if (text[i] !== expected) unexpected();
|
|
82
|
+
i++;
|
|
83
|
+
}
|
|
84
|
+
return result;
|
|
85
|
+
};
|
|
86
|
+
const checkIJsonNumber = (raw, parsed, at) => {
|
|
87
|
+
if (!Number.isFinite(parsed)) fail(`number ${raw} is beyond the range of a double (I-JSON)`, at);
|
|
88
|
+
const significant = raw.replace(/^[+-]/, "").split(/[eE]/)[0].replace(".", "").replace(/^0+/, "").replace(/0+$/, "");
|
|
89
|
+
if (significant.length > MAX_SIGNIFICANT_DIGITS || parsed === 0 && significant !== "") fail(`number ${raw} is more precise than a double holds (I-JSON)`, at);
|
|
90
|
+
if (/^-?\d+$/.test(raw) && !Number.isSafeInteger(parsed)) fail(`integer ${raw} is outside the exact range of a double (I-JSON)`, at);
|
|
91
|
+
};
|
|
92
|
+
const number = () => {
|
|
93
|
+
const start = i;
|
|
94
|
+
let sign = 1;
|
|
95
|
+
if (text[i] === "-" || json5 && text[i] === "+") {
|
|
96
|
+
if (text[i] === "-") sign = -1;
|
|
97
|
+
i++;
|
|
98
|
+
}
|
|
99
|
+
if (json5) {
|
|
100
|
+
if (text.startsWith("Infinity", i)) return sign * literal("Infinity", Infinity);
|
|
101
|
+
if (text.startsWith("NaN", i)) return literal("NaN", NaN);
|
|
102
|
+
if (text[i] === "0" && (text[i + 1] === "x" || text[i + 1] === "X")) {
|
|
103
|
+
i += 2;
|
|
104
|
+
const digitsStart = i;
|
|
105
|
+
while (HEX_DIGIT.test(text[i] ?? "")) i++;
|
|
106
|
+
if (i === digitsStart) unexpected();
|
|
107
|
+
return sign * Number.parseInt(text.slice(digitsStart, i), 16);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
const integerStart = i;
|
|
111
|
+
if (text[i] === "0") i++;
|
|
112
|
+
else while (isDigit(text[i])) i++;
|
|
113
|
+
const hasInteger = i > integerStart;
|
|
114
|
+
if (!hasInteger && !(json5 && text[i] === ".")) unexpected();
|
|
115
|
+
if (text[i] === ".") {
|
|
116
|
+
i++;
|
|
117
|
+
const fractionStart = i;
|
|
118
|
+
while (isDigit(text[i])) i++;
|
|
119
|
+
if (i === fractionStart && (!json5 || !hasInteger)) unexpected();
|
|
120
|
+
}
|
|
121
|
+
if (text[i] === "e" || text[i] === "E") {
|
|
122
|
+
i++;
|
|
123
|
+
if (text[i] === "+" || text[i] === "-") i++;
|
|
124
|
+
const exponentStart = i;
|
|
125
|
+
while (isDigit(text[i])) i++;
|
|
126
|
+
if (i === exponentStart) unexpected();
|
|
127
|
+
}
|
|
128
|
+
const raw = text.slice(start, i);
|
|
129
|
+
const parsed = Number(raw.replace(/^\+/, ""));
|
|
130
|
+
if (iJson) checkIJsonNumber(raw, parsed, start);
|
|
131
|
+
return parsed;
|
|
132
|
+
};
|
|
133
|
+
const escape = () => {
|
|
134
|
+
i++;
|
|
135
|
+
const char = text[i];
|
|
136
|
+
switch (char) {
|
|
137
|
+
case "\"":
|
|
138
|
+
case "\\":
|
|
139
|
+
case "/":
|
|
140
|
+
i++;
|
|
141
|
+
return char;
|
|
142
|
+
case "b":
|
|
143
|
+
i++;
|
|
144
|
+
return "\b";
|
|
145
|
+
case "f":
|
|
146
|
+
i++;
|
|
147
|
+
return "\f";
|
|
148
|
+
case "n":
|
|
149
|
+
i++;
|
|
150
|
+
return "\n";
|
|
151
|
+
case "r":
|
|
152
|
+
i++;
|
|
153
|
+
return "\r";
|
|
154
|
+
case "t":
|
|
155
|
+
i++;
|
|
156
|
+
return " ";
|
|
157
|
+
case "u":
|
|
158
|
+
i++;
|
|
159
|
+
return String.fromCharCode(hexDigits(4));
|
|
160
|
+
}
|
|
161
|
+
if (!json5 || char === void 0 || isDigit(char) && char !== "0") return unexpected();
|
|
162
|
+
i++;
|
|
163
|
+
switch (char) {
|
|
164
|
+
case "v": return "\v";
|
|
165
|
+
case "0":
|
|
166
|
+
if (isDigit(text[i])) unexpected();
|
|
167
|
+
return "\0";
|
|
168
|
+
case "x": return String.fromCharCode(hexDigits(2));
|
|
169
|
+
case "\r":
|
|
170
|
+
if (text[i] === "\n") i++;
|
|
171
|
+
return "";
|
|
172
|
+
case "\n":
|
|
173
|
+
case "\u2028":
|
|
174
|
+
case "\u2029": return "";
|
|
175
|
+
default: return char;
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const string = (quote) => {
|
|
179
|
+
const start = i;
|
|
180
|
+
i++;
|
|
181
|
+
let result = "";
|
|
182
|
+
for (;;) {
|
|
183
|
+
const char = text[i];
|
|
184
|
+
if (char === void 0) fail("unterminated string", start);
|
|
185
|
+
if (char === quote) break;
|
|
186
|
+
if (char === "\\") result += escape();
|
|
187
|
+
else {
|
|
188
|
+
if (char === "\n" || char === "\r" || !json5 && char < " ") unexpected();
|
|
189
|
+
result += char;
|
|
190
|
+
i++;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
i++;
|
|
194
|
+
if (iJson && I_JSON_FORBIDDEN.test(result)) fail("string holds a lone surrogate or a noncharacter (I-JSON)", start);
|
|
195
|
+
return result;
|
|
196
|
+
};
|
|
197
|
+
const identifier = () => {
|
|
198
|
+
let name = "";
|
|
199
|
+
for (;;) {
|
|
200
|
+
const pattern = name === "" ? IDENTIFIER_START : IDENTIFIER_PART;
|
|
201
|
+
if (text[i] === "\\") {
|
|
202
|
+
const at = i;
|
|
203
|
+
i++;
|
|
204
|
+
if (text[i] !== "u") unexpected();
|
|
205
|
+
i++;
|
|
206
|
+
const char = String.fromCharCode(hexDigits(4));
|
|
207
|
+
if (!pattern.test(char)) fail(`invalid key character ${JSON.stringify(char)}`, at);
|
|
208
|
+
name += char;
|
|
209
|
+
} else {
|
|
210
|
+
const codePoint = text.codePointAt(i);
|
|
211
|
+
const char = codePoint === void 0 ? "" : String.fromCodePoint(codePoint);
|
|
212
|
+
if (char === "" || !pattern.test(char)) break;
|
|
213
|
+
name += char;
|
|
214
|
+
i += char.length;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
if (name === "") unexpected();
|
|
218
|
+
return name;
|
|
219
|
+
};
|
|
220
|
+
const key = () => {
|
|
221
|
+
if (text[i] === "\"") return string("\"");
|
|
222
|
+
if (!json5) return unexpected();
|
|
223
|
+
return text[i] === "'" ? string("'") : identifier();
|
|
224
|
+
};
|
|
225
|
+
function object() {
|
|
226
|
+
i++;
|
|
227
|
+
const result = {};
|
|
228
|
+
const seen = iJson ? /* @__PURE__ */ new Set() : void 0;
|
|
229
|
+
skipSpace();
|
|
230
|
+
if (text[i] === "}") {
|
|
231
|
+
i++;
|
|
232
|
+
return result;
|
|
233
|
+
}
|
|
234
|
+
for (;;) {
|
|
235
|
+
const keyStart = i;
|
|
236
|
+
const name = key();
|
|
237
|
+
if (seen) {
|
|
238
|
+
if (seen.has(name)) fail(`duplicate key ${JSON.stringify(name)} (I-JSON)`, keyStart);
|
|
239
|
+
seen.add(name);
|
|
240
|
+
}
|
|
241
|
+
skipSpace();
|
|
242
|
+
if (text[i] !== ":") unexpected();
|
|
243
|
+
i++;
|
|
244
|
+
skipSpace();
|
|
245
|
+
define(result, name, value());
|
|
246
|
+
skipSpace();
|
|
247
|
+
if (text[i] === ",") {
|
|
248
|
+
i++;
|
|
249
|
+
skipSpace();
|
|
250
|
+
if (json5 && text[i] === "}") break;
|
|
251
|
+
} else if (text[i] === "}") break;
|
|
252
|
+
else unexpected();
|
|
253
|
+
}
|
|
254
|
+
i++;
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
function array() {
|
|
258
|
+
i++;
|
|
259
|
+
const result = [];
|
|
260
|
+
skipSpace();
|
|
261
|
+
if (text[i] === "]") {
|
|
262
|
+
i++;
|
|
263
|
+
return result;
|
|
264
|
+
}
|
|
265
|
+
for (;;) {
|
|
266
|
+
result.push(value());
|
|
267
|
+
skipSpace();
|
|
268
|
+
if (text[i] === ",") {
|
|
269
|
+
i++;
|
|
270
|
+
skipSpace();
|
|
271
|
+
if (json5 && text[i] === "]") break;
|
|
272
|
+
} else if (text[i] === "]") break;
|
|
273
|
+
else unexpected();
|
|
274
|
+
}
|
|
275
|
+
i++;
|
|
276
|
+
return result;
|
|
277
|
+
}
|
|
278
|
+
function value() {
|
|
279
|
+
switch (text[i]) {
|
|
280
|
+
case "{": return object();
|
|
281
|
+
case "[": return array();
|
|
282
|
+
case "\"": return string("\"");
|
|
283
|
+
case "'": return json5 ? string("'") : unexpected();
|
|
284
|
+
case "t": return literal("true", true);
|
|
285
|
+
case "f": return literal("false", false);
|
|
286
|
+
case "n": return literal("null", null);
|
|
287
|
+
default: return number();
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
skipSpace();
|
|
291
|
+
if (i >= text.length) unexpected();
|
|
292
|
+
const result = value();
|
|
293
|
+
skipSpace();
|
|
294
|
+
if (i < text.length) unexpected();
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
297
|
+
//#endregion
|
|
298
|
+
//#region helpers/_shared/_stripJsonComments.ts
|
|
299
|
+
/**
|
|
300
|
+
* This file is part of helpers4.
|
|
301
|
+
* Copyright (C) 2025 baxyz
|
|
302
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
303
|
+
*
|
|
304
|
+
* Internal helper used by `node/safeReadJsonFile` and `json/parse` to support JSONC (JSON with
|
|
305
|
+
* line and block comments, plus trailing commas) — the dialect used by tsconfig.json, VS Code's
|
|
306
|
+
* settings.json, etc. Not exported from any package barrel — tests live in
|
|
307
|
+
* _stripJsonComments.test.ts.
|
|
308
|
+
*/
|
|
309
|
+
/**
|
|
310
|
+
* Blanks out line comments (starting with two slashes), block comments (slash-star ... star-slash),
|
|
311
|
+
* and trailing commas (before `}` or `]`) in a JSONC string, so the result can be passed to
|
|
312
|
+
* `JSON.parse`. String literals are tracked so a comment-like or comma character occurring
|
|
313
|
+
* *inside* a JSON string (e.g. a URL) is left untouched — this is a string-boundary-aware pass,
|
|
314
|
+
* not a naive regex strip.
|
|
315
|
+
*
|
|
316
|
+
* Everything removed is replaced by spaces (line breaks inside a block comment are kept), so the
|
|
317
|
+
* output has exactly the same length and line layout as the input: a position `JSON.parse`
|
|
318
|
+
* reports in an error on the output points at the same place in the original JSONC text.
|
|
319
|
+
* @ignore
|
|
320
|
+
*/
|
|
321
|
+
function stripJsonComments(input) {
|
|
322
|
+
let output = "";
|
|
323
|
+
let inString = false;
|
|
324
|
+
let pendingComma = "";
|
|
325
|
+
const flushPendingComma = () => {
|
|
326
|
+
output += pendingComma;
|
|
327
|
+
pendingComma = "";
|
|
328
|
+
};
|
|
329
|
+
const emitFiller = (text) => {
|
|
330
|
+
if (pendingComma) pendingComma += text;
|
|
331
|
+
else output += text;
|
|
332
|
+
};
|
|
333
|
+
let i = 0;
|
|
334
|
+
while (i < input.length) {
|
|
335
|
+
const char = input[i];
|
|
336
|
+
if (inString) {
|
|
337
|
+
output += char;
|
|
338
|
+
if (char === "\\") {
|
|
339
|
+
i++;
|
|
340
|
+
if (i < input.length) output += input[i];
|
|
341
|
+
} else if (char === "\"") inString = false;
|
|
342
|
+
i++;
|
|
343
|
+
continue;
|
|
344
|
+
}
|
|
345
|
+
if (char === "\"") {
|
|
346
|
+
flushPendingComma();
|
|
347
|
+
inString = true;
|
|
348
|
+
output += char;
|
|
349
|
+
i++;
|
|
350
|
+
continue;
|
|
351
|
+
}
|
|
352
|
+
if (char === "/" && input[i + 1] === "/") {
|
|
353
|
+
const start = i;
|
|
354
|
+
i += 2;
|
|
355
|
+
while (i < input.length && input[i] !== "\n" && input[i] !== "\r") i++;
|
|
356
|
+
emitFiller(" ".repeat(i - start));
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
if (char === "/" && input[i + 1] === "*") {
|
|
360
|
+
const start = i;
|
|
361
|
+
i += 2;
|
|
362
|
+
while (i < input.length && !(input[i] === "*" && input[i + 1] === "/")) i++;
|
|
363
|
+
i = Math.min(i + 2, input.length);
|
|
364
|
+
emitFiller(input.slice(start, i).replace(/[^\r\n]/g, " "));
|
|
365
|
+
continue;
|
|
366
|
+
}
|
|
367
|
+
if (char === ",") {
|
|
368
|
+
flushPendingComma();
|
|
369
|
+
pendingComma = char;
|
|
370
|
+
i++;
|
|
371
|
+
continue;
|
|
372
|
+
}
|
|
373
|
+
if (char === "}" || char === "]") {
|
|
374
|
+
output += pendingComma.replace(",", " ");
|
|
375
|
+
pendingComma = "";
|
|
376
|
+
output += char;
|
|
377
|
+
i++;
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (char === " " || char === " " || char === "\n" || char === "\r") {
|
|
381
|
+
emitFiller(char);
|
|
382
|
+
i++;
|
|
383
|
+
continue;
|
|
384
|
+
}
|
|
385
|
+
flushPendingComma();
|
|
386
|
+
output += char;
|
|
387
|
+
i++;
|
|
388
|
+
}
|
|
389
|
+
flushPendingComma();
|
|
390
|
+
return output;
|
|
391
|
+
}
|
|
392
|
+
//#endregion
|
|
393
|
+
//#region helpers/json/parse.ts
|
|
394
|
+
/**
|
|
395
|
+
* This file is part of helpers4.
|
|
396
|
+
* Copyright (C) 2025 baxyz
|
|
397
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
398
|
+
*/
|
|
399
|
+
/**
|
|
400
|
+
* Parses JSON permissively: strict JSON, JSONC (the dialect of `tsconfig.json` and VS Code's
|
|
401
|
+
* `settings.json`, also known as HuJSON or JWCC) and JSON5 — each a superset of the one before.
|
|
402
|
+
*
|
|
403
|
+
* **Accepts:**
|
|
404
|
+
* - everything {@link parseStrict} accepts (strict JSON, RFC 8259)
|
|
405
|
+
* - comments: `// …` to the end of the line, and `/* … *\/`
|
|
406
|
+
* - one trailing comma after the last item of an object or array
|
|
407
|
+
* - object keys without quotes, when they are ECMAScript identifiers (`{ name: 1 }`, `{ $id: 1 }`)
|
|
408
|
+
* - strings in single quotes, and in either quote: the escapes `\'`, `\v`, `\0` and `\xHH`, any
|
|
409
|
+
* other escaped character standing for itself, and a backslash before a line break to continue
|
|
410
|
+
* the string on the next line
|
|
411
|
+
* - numbers with a leading `+`, a leading or trailing dot (`.5`, `5.`), in hexadecimal (`0x1F`),
|
|
412
|
+
* and `Infinity`, `-Infinity` and `NaN`
|
|
413
|
+
* - any Unicode whitespace between tokens, including a leading byte order mark (U+FEFF)
|
|
414
|
+
*
|
|
415
|
+
* **Rejects:** more than one trailing comma in a row, leading zeros (`01`), octal or binary
|
|
416
|
+
* numbers, unquoted keys that aren't identifiers (`{ my-key: 1 }`), raw line breaks inside a
|
|
417
|
+
* string, an unterminated comment or string, `undefined`, and more than one value.
|
|
418
|
+
*
|
|
419
|
+
* **On invalid input:** throws a `SyntaxError` naming the line and column of the first problem.
|
|
420
|
+
*
|
|
421
|
+
* Strict JSON goes straight to the native `JSON.parse`, and JSONC to `JSON.parse` after its
|
|
422
|
+
* comments are blanked out; only text that is neither goes through the JSON5 reader (written for
|
|
423
|
+
* helpers4 from the JSON5 1.0.0 specification), so the common cases cost nothing extra.
|
|
424
|
+
*
|
|
425
|
+
* Values are built as `JSON.parse` builds them: the last of duplicate keys wins, and a
|
|
426
|
+
* `__proto__` key is an own property, never the prototype. `NaN` and `Infinity` have no JSON
|
|
427
|
+
* representation — {@link stringify} writes them as `null`.
|
|
428
|
+
*
|
|
429
|
+
* @param text - The JSON, JSONC or JSON5 text to parse
|
|
430
|
+
* @returns The parsed value, typed as `T`
|
|
431
|
+
* @throws {SyntaxError} When `text` isn't JSON5 (and so neither JSONC nor JSON)
|
|
432
|
+
* @see {@link safeParse} — same parsing, returns `null` or a fallback instead of throwing
|
|
433
|
+
* @see {@link parseStrict} — strict JSON only, e.g. to validate an API payload
|
|
434
|
+
* @see {@link parseLines} — one value per line (NDJSON, JSON Lines)
|
|
435
|
+
* @see {@link parseSequence} — JSON text sequences (RFC 7464)
|
|
436
|
+
* @example
|
|
437
|
+
* parse('{"a":1}') // => { a: 1 }
|
|
438
|
+
* @example
|
|
439
|
+
* parse(`{
|
|
440
|
+
* // the output directory
|
|
441
|
+
* "outDir": "dist",
|
|
442
|
+
* }`) // => { outDir: 'dist' }
|
|
443
|
+
* @example
|
|
444
|
+
* parse("{ name: 'app', retries: 0x3, ratio: .5, }") // => { name: 'app', retries: 3, ratio: 0.5 }
|
|
445
|
+
* @since next
|
|
446
|
+
*/
|
|
447
|
+
function parse(text) {
|
|
448
|
+
const input = text.startsWith("") ? text.slice(1) : text;
|
|
449
|
+
try {
|
|
450
|
+
return JSON.parse(input);
|
|
451
|
+
} catch {
|
|
452
|
+
try {
|
|
453
|
+
return JSON.parse(stripJsonComments(input));
|
|
454
|
+
} catch {
|
|
455
|
+
return readJson(input, { dialect: "json5" });
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
//#endregion
|
|
460
|
+
//#region helpers/json/parseLines.ts
|
|
461
|
+
/** @ignore */
|
|
462
|
+
var LINE_FEED = "\n";
|
|
463
|
+
/**
|
|
464
|
+
* Parses newline-delimited JSON — one JSON value per line. Covers both NDJSON
|
|
465
|
+
* (`application/x-ndjson`, `.ndjson`) and JSON Lines (`.jsonl`), which are the same format.
|
|
466
|
+
*
|
|
467
|
+
* **Accepts:**
|
|
468
|
+
* - one strict JSON value per line (each line is read like {@link parseStrict})
|
|
469
|
+
* - `\n` and `\r\n` line endings
|
|
470
|
+
* - a trailing line break after the last value, or none
|
|
471
|
+
* - blank lines, skipped by default (see {@link ParseLinesOptions.blankLines})
|
|
472
|
+
* - an empty text, which holds zero values
|
|
473
|
+
*
|
|
474
|
+
* **Rejects:** comments, trailing commas and any other non-strict JSON on a line, a value spread
|
|
475
|
+
* over several lines, and a leading byte order mark (JSON Lines forbids it).
|
|
476
|
+
*
|
|
477
|
+
* **On invalid input:** throws a `SyntaxError` naming the 1-based line number, with the
|
|
478
|
+
* original parse error as its `cause` — unless `invalidLines`/`blankLines` say to skip it.
|
|
479
|
+
*
|
|
480
|
+
* @param text - The newline-delimited JSON text
|
|
481
|
+
* @param options - How to handle blank and invalid lines — see {@link ParseLinesOptions}
|
|
482
|
+
* @returns The parsed values, in line order, typed as `T[]`
|
|
483
|
+
* @throws {SyntaxError} When a line isn't valid JSON (or is blank with `blankLines: 'fail'`)
|
|
484
|
+
* @see {@link safeParseLines} — same parsing, returns `null` or a fallback instead of throwing
|
|
485
|
+
* @see {@link stringifyLines} — the reverse operation
|
|
486
|
+
* @see {@link parseSequence} — JSON text sequences (RFC 7464), the record-separator variant
|
|
487
|
+
* @see {@link parse} — a single JSON or JSONC value
|
|
488
|
+
* @example
|
|
489
|
+
* parseLines('{"id":1}\n{"id":2}\n') // => [{ id: 1 }, { id: 2 }]
|
|
490
|
+
* @example
|
|
491
|
+
* parseLines('{"id":1}\n{"id":', { invalidLines: 'skip' }) // => [{ id: 1 }]
|
|
492
|
+
* @since next
|
|
493
|
+
*/
|
|
494
|
+
function parseLines(text, options = {}) {
|
|
495
|
+
const { blankLines = "skip", invalidLines = "fail" } = options;
|
|
496
|
+
if (text === "") return [];
|
|
497
|
+
const lines = text.split(LINE_FEED);
|
|
498
|
+
if (text.endsWith(LINE_FEED)) lines.pop();
|
|
499
|
+
const values = [];
|
|
500
|
+
lines.forEach((line, index) => {
|
|
501
|
+
const lineNumber = index + 1;
|
|
502
|
+
if (line.trim() === "") {
|
|
503
|
+
if (blankLines === "fail") throw new SyntaxError(`parseLines: line ${lineNumber} is blank`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
try {
|
|
507
|
+
values.push(JSON.parse(line));
|
|
508
|
+
} catch (error) {
|
|
509
|
+
if (invalidLines === "fail") throw new SyntaxError(`parseLines: line ${lineNumber}: ${error.message}`, { cause: error });
|
|
510
|
+
}
|
|
511
|
+
});
|
|
512
|
+
return values;
|
|
513
|
+
}
|
|
514
|
+
//#endregion
|
|
515
|
+
//#region helpers/json/parseSequence.ts
|
|
516
|
+
/** The ASCII record separator that starts every record of a JSON text sequence. @ignore */
|
|
517
|
+
var RECORD_SEPARATOR = "";
|
|
518
|
+
/**
|
|
519
|
+
* JSON whitespace, the only thing that can end a top-level number, `true`, `false` or `null` —
|
|
520
|
+
* without it, RFC 7464 §2.4 says such a record may have been truncated (`123` cut from `1234`).
|
|
521
|
+
* @ignore
|
|
522
|
+
*/
|
|
523
|
+
var TRAILING_JSON_WHITESPACE = /[ \t\n\r]$/;
|
|
524
|
+
/**
|
|
525
|
+
* Parses a JSON text sequence (RFC 7464, `application/json-seq`): records that each start with
|
|
526
|
+
* the ASCII record separator (RS, U+001E) and hold one JSON text, as written by `jq --seq`.
|
|
527
|
+
*
|
|
528
|
+
* **Accepts:**
|
|
529
|
+
* - one strict JSON value per record (each record is read like {@link parseStrict})
|
|
530
|
+
* - JSON values spread over several lines inside a record — unlike {@link parseLines}
|
|
531
|
+
* - several RS in a row (empty records are ignored, as the RFC's grammar allows)
|
|
532
|
+
* - an empty text, which holds zero records
|
|
533
|
+
*
|
|
534
|
+
* **Rejects (as invalid records):**
|
|
535
|
+
* - anything before the first RS
|
|
536
|
+
* - a record that isn't strict JSON, or holds only whitespace
|
|
537
|
+
* - a top-level number, `true`, `false` or `null` not followed by whitespace — RFC 7464 §2.4
|
|
538
|
+
* requires dropping it, since it may have been truncated
|
|
539
|
+
*
|
|
540
|
+
* **On invalid input:** throws a `SyntaxError` naming the 1-based record number, with the
|
|
541
|
+
* original parse error (if any) as its `cause` — unless `invalidRecords: 'skip'` says to drop it.
|
|
542
|
+
*
|
|
543
|
+
* @param text - The JSON text sequence
|
|
544
|
+
* @param options - How to handle invalid records — see {@link ParseSequenceOptions}
|
|
545
|
+
* @returns The parsed values, in record order, typed as `T[]`
|
|
546
|
+
* @throws {SyntaxError} When a record is invalid and `invalidRecords` is `'fail'`
|
|
547
|
+
* @see {@link safeParseSequence} — same parsing, returns `null` or a fallback instead of throwing
|
|
548
|
+
* @see {@link stringifySequence} — the reverse operation
|
|
549
|
+
* @see {@link parseLines} — newline-delimited JSON (NDJSON, JSON Lines)
|
|
550
|
+
* @example
|
|
551
|
+
* parseSequence('\u001E{"id":1}\n\u001E{"id":2}\n') // => [{ id: 1 }, { id: 2 }]
|
|
552
|
+
* @example
|
|
553
|
+
* // A crash cut the last record: skip it, as RFC 7464 recommends
|
|
554
|
+
* parseSequence('\u001E{"id":1}\n\u001E{"id', { invalidRecords: 'skip' }) // => [{ id: 1 }]
|
|
555
|
+
* @since next
|
|
556
|
+
*/
|
|
557
|
+
function parseSequence(text, options = {}) {
|
|
558
|
+
const { invalidRecords = "fail" } = options;
|
|
559
|
+
const [beforeFirst, ...records] = text.split(RECORD_SEPARATOR);
|
|
560
|
+
const invalid = (where, reason, cause) => {
|
|
561
|
+
if (invalidRecords === "fail") throw new SyntaxError(`parseSequence: ${where}: ${reason}`, cause === void 0 ? void 0 : { cause });
|
|
562
|
+
};
|
|
563
|
+
if (beforeFirst.trim() !== "") invalid("before the first record", "text outside any record (missing record separator)");
|
|
564
|
+
const values = [];
|
|
565
|
+
records.forEach((record, index) => {
|
|
566
|
+
const where = `record ${index + 1}`;
|
|
567
|
+
if (record === "") return;
|
|
568
|
+
let value;
|
|
569
|
+
try {
|
|
570
|
+
value = JSON.parse(record);
|
|
571
|
+
} catch (error) {
|
|
572
|
+
invalid(where, error.message, error);
|
|
573
|
+
return;
|
|
574
|
+
}
|
|
575
|
+
if (!(typeof value === "object" && value !== null || typeof value === "string") && !TRAILING_JSON_WHITESPACE.test(record)) {
|
|
576
|
+
invalid(where, `top-level ${JSON.stringify(value)} is not followed by whitespace and may be truncated`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
values.push(value);
|
|
580
|
+
});
|
|
581
|
+
return values;
|
|
582
|
+
}
|
|
583
|
+
//#endregion
|
|
584
|
+
//#region helpers/json/parseStrict.ts
|
|
585
|
+
/**
|
|
586
|
+
* This file is part of helpers4.
|
|
587
|
+
* Copyright (C) 2025 baxyz
|
|
588
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
589
|
+
*/
|
|
590
|
+
/**
|
|
591
|
+
* Parses strict JSON (RFC 8259) — exactly what `JSON.parse` accepts, nothing more.
|
|
592
|
+
*
|
|
593
|
+
* **Accepts:** standard JSON only — double-quoted strings and keys, finite decimal numbers,
|
|
594
|
+
* `true`/`false`/`null`, objects and arrays, surrounding whitespace.
|
|
595
|
+
*
|
|
596
|
+
* **Rejects:** comments, trailing commas, single quotes, unquoted keys, hexadecimal numbers,
|
|
597
|
+
* `NaN`/`Infinity`, a leading byte order mark (U+FEFF), and anything else that isn't strict JSON.
|
|
598
|
+
*
|
|
599
|
+
* **On invalid input:** throws the native `SyntaxError` from `JSON.parse`.
|
|
600
|
+
*
|
|
601
|
+
* Use it to validate input that must be strict JSON, e.g. an API payload. To also read config
|
|
602
|
+
* files with comments or JSON5, use {@link parse}. To never throw, use {@link safeParseStrict}.
|
|
603
|
+
*
|
|
604
|
+
* @param text - The JSON text to parse
|
|
605
|
+
* @returns The parsed value, typed as `T`
|
|
606
|
+
* @throws {SyntaxError} When `text` isn't strict JSON
|
|
607
|
+
* @see {@link safeParseStrict} — same parsing, returns `null` or a fallback instead of throwing
|
|
608
|
+
* @see {@link parse} — also accepts JSONC and JSON5
|
|
609
|
+
* @see {@link parseLines} — one value per line (NDJSON, JSON Lines)
|
|
610
|
+
* @see {@link parseSequence} — JSON text sequences (RFC 7464)
|
|
611
|
+
* @example
|
|
612
|
+
* parseStrict<{ a: number }>('{"a":1}') // => { a: 1 }
|
|
613
|
+
* parseStrict('{"a":1,}') // throws SyntaxError (trailing comma)
|
|
614
|
+
* @since next
|
|
615
|
+
*/
|
|
616
|
+
function parseStrict(text) {
|
|
617
|
+
return JSON.parse(text);
|
|
618
|
+
}
|
|
619
|
+
//#endregion
|
|
620
|
+
//#region helpers/json/safeParse.ts
|
|
621
|
+
/**
|
|
622
|
+
* This file is part of helpers4.
|
|
623
|
+
* Copyright (C) 2025 baxyz
|
|
624
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
625
|
+
*/
|
|
626
|
+
function safeParse(text, fallback) {
|
|
627
|
+
try {
|
|
628
|
+
return parse(text);
|
|
629
|
+
} catch {
|
|
630
|
+
return arguments.length < 2 ? null : fallback;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
//#endregion
|
|
634
|
+
//#region helpers/json/safeParseLines.ts
|
|
635
|
+
/**
|
|
636
|
+
* This file is part of helpers4.
|
|
637
|
+
* Copyright (C) 2025 baxyz
|
|
638
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
639
|
+
*/
|
|
640
|
+
function safeParseLines(text, fallback, options) {
|
|
641
|
+
try {
|
|
642
|
+
return parseLines(text, options);
|
|
643
|
+
} catch {
|
|
644
|
+
return arguments.length < 2 ? null : fallback;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
//#endregion
|
|
648
|
+
//#region helpers/json/safeParseSequence.ts
|
|
649
|
+
/**
|
|
650
|
+
* This file is part of helpers4.
|
|
651
|
+
* Copyright (C) 2025 baxyz
|
|
652
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
653
|
+
*/
|
|
654
|
+
function safeParseSequence(text, fallback, options) {
|
|
655
|
+
try {
|
|
656
|
+
return parseSequence(text, options);
|
|
657
|
+
} catch {
|
|
658
|
+
return arguments.length < 2 ? null : fallback;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
//#endregion
|
|
662
|
+
//#region helpers/json/safeParseStrict.ts
|
|
663
|
+
/**
|
|
664
|
+
* This file is part of helpers4.
|
|
665
|
+
* Copyright (C) 2025 baxyz
|
|
666
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
667
|
+
*/
|
|
668
|
+
function safeParseStrict(text, fallback) {
|
|
669
|
+
try {
|
|
670
|
+
return parseStrict(text);
|
|
671
|
+
} catch {
|
|
672
|
+
return arguments.length < 2 ? null : fallback;
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
//#endregion
|
|
676
|
+
//#region helpers/json/_canonical.ts
|
|
677
|
+
/**
|
|
678
|
+
* This file is part of helpers4.
|
|
679
|
+
* Copyright (C) 2025 baxyz
|
|
680
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
681
|
+
*
|
|
682
|
+
* Internal canonical JSON serializer (RFC 8785, JSON Canonicalization Scheme) shared by
|
|
683
|
+
* stringify.ts, stringifyLines.ts and stringifySequence.ts. Not exported from the package
|
|
684
|
+
* barrel — tests live in _canonical.test.ts.
|
|
685
|
+
*/
|
|
686
|
+
/** A lone UTF-16 surrogate, which I-JSON (and so RFC 8785) forbids in strings and keys. @ignore */
|
|
687
|
+
var LONE_SURROGATE = /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/;
|
|
688
|
+
/** @ignore */
|
|
689
|
+
function serializeString(value) {
|
|
690
|
+
if (LONE_SURROGATE.test(value)) throw new RangeError(`stringify: canonical JSON can't hold a string with a lone surrogate: ${JSON.stringify(value)}`);
|
|
691
|
+
return JSON.stringify(value);
|
|
692
|
+
}
|
|
693
|
+
/**
|
|
694
|
+
* Serializes `holder[key]` the way `JSON.stringify` would — same `toJSON` calls, same
|
|
695
|
+
* unwrapping of `Number`/`String`/`Boolean` objects, same dropping of `undefined`, functions and
|
|
696
|
+
* symbols — but with the RFC 8785 rules on top: object keys sorted by UTF-16 code units, no
|
|
697
|
+
* whitespace, and an error instead of a silent `null` for `NaN`/`Infinity`. Numbers already
|
|
698
|
+
* follow the RFC as is: it reuses ECMAScript's own number-to-string algorithm.
|
|
699
|
+
*
|
|
700
|
+
* Returns `undefined` for a value `JSON.stringify` would skip (so an object member is dropped
|
|
701
|
+
* and an array item becomes `null`, as it would there).
|
|
702
|
+
* @ignore
|
|
703
|
+
*/
|
|
704
|
+
function serialize(holder, key, ancestors) {
|
|
705
|
+
let value = holder[key];
|
|
706
|
+
if (typeof value === "object" && value !== null || typeof value === "bigint") {
|
|
707
|
+
const toJSON = value.toJSON;
|
|
708
|
+
if (typeof toJSON === "function") value = toJSON.call(value, key);
|
|
709
|
+
}
|
|
710
|
+
if (typeof value === "object" && value !== null) {
|
|
711
|
+
const tag = Object.prototype.toString.call(value);
|
|
712
|
+
if (tag === "[object Number]") value = Number(value);
|
|
713
|
+
else if (tag === "[object String]") value = String(value);
|
|
714
|
+
else if (tag === "[object Boolean]") value = Boolean.prototype.valueOf.call(value);
|
|
715
|
+
}
|
|
716
|
+
switch (typeof value) {
|
|
717
|
+
case "boolean": return String(value);
|
|
718
|
+
case "number":
|
|
719
|
+
if (!Number.isFinite(value)) throw new RangeError(`stringify: canonical JSON can't hold ${value}`);
|
|
720
|
+
return JSON.stringify(value);
|
|
721
|
+
case "string": return serializeString(value);
|
|
722
|
+
case "bigint": throw new TypeError("stringify: a BigInt has no JSON representation");
|
|
723
|
+
case "object": break;
|
|
724
|
+
default: return;
|
|
725
|
+
}
|
|
726
|
+
if (value === null) return "null";
|
|
727
|
+
if (ancestors.includes(value)) throw new TypeError("stringify: converting a circular structure to JSON");
|
|
728
|
+
ancestors.push(value);
|
|
729
|
+
let result;
|
|
730
|
+
if (Array.isArray(value)) {
|
|
731
|
+
const items = value;
|
|
732
|
+
result = `[${value.map((_, index) => serialize(items, String(index), ancestors) ?? "null").join(",")}]`;
|
|
733
|
+
} else {
|
|
734
|
+
const object = value;
|
|
735
|
+
const members = [];
|
|
736
|
+
for (const member of Object.keys(object).toSorted()) {
|
|
737
|
+
const serialized = serialize(object, member, ancestors);
|
|
738
|
+
if (serialized !== void 0) members.push(`${serializeString(member)}:${serialized}`);
|
|
739
|
+
}
|
|
740
|
+
result = `{${members.join(",")}}`;
|
|
741
|
+
}
|
|
742
|
+
ancestors.pop();
|
|
743
|
+
return result;
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Serializes `value` as canonical JSON (RFC 8785), or returns `undefined` when `JSON.stringify`
|
|
747
|
+
* would (a top-level `undefined`, function or symbol).
|
|
748
|
+
* @ignore
|
|
749
|
+
*/
|
|
750
|
+
function stringifyCanonical(value) {
|
|
751
|
+
return serialize({ "": value }, "", []);
|
|
752
|
+
}
|
|
753
|
+
//#endregion
|
|
754
|
+
//#region helpers/json/stringify.ts
|
|
755
|
+
/**
|
|
756
|
+
* This file is part of helpers4.
|
|
757
|
+
* Copyright (C) 2025 baxyz
|
|
758
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
759
|
+
*/
|
|
760
|
+
/** @ignore */
|
|
761
|
+
var DEFAULT_INDENT = 2;
|
|
762
|
+
/** @ignore */
|
|
763
|
+
var MAX_INDENT = 10;
|
|
764
|
+
/**
|
|
765
|
+
* Serializes a value to JSON text — pretty-printed with 2 spaces by default, or canonical.
|
|
766
|
+
*
|
|
767
|
+
* **Formatting:**
|
|
768
|
+
* - default — indented with 2 spaces, like a hand-written config file
|
|
769
|
+
* - `{ indent: n }` — indented with `n` spaces (an integer from 0 to 10); `{ indent: 0 }` gives a
|
|
770
|
+
* compact single line, the same as `JSON.stringify(value)`
|
|
771
|
+
* - `'canonical'` — canonical JSON (RFC 8785, JSON Canonicalization Scheme): object keys sorted
|
|
772
|
+
* by UTF-16 code units, no whitespace at all, numbers in their shortest ECMAScript form. Two
|
|
773
|
+
* equal values always give the same bytes, so the output can be hashed, signed or compared.
|
|
774
|
+
* Canonical output can't be indented — the RFC fixes every byte.
|
|
775
|
+
*
|
|
776
|
+
* **Same as `JSON.stringify`:** `toJSON()` is called (a `Date` becomes its ISO string),
|
|
777
|
+
* `undefined`, functions and symbols are dropped from objects and become `null` in arrays, and
|
|
778
|
+
* `Number`/`String`/`Boolean` objects are unwrapped. Outside `'canonical'`, `NaN` and `±Infinity`
|
|
779
|
+
* silently become `null`, as there.
|
|
780
|
+
*
|
|
781
|
+
* **Throws** instead of returning `undefined` or dropping data silently:
|
|
782
|
+
* - a `TypeError` when `value` itself is `undefined`, a function or a symbol
|
|
783
|
+
* - a `TypeError` on a circular structure or a `BigInt` (like `JSON.stringify`)
|
|
784
|
+
* - with `'canonical'`, a `RangeError` on `NaN`, `±Infinity` or a string with a lone surrogate,
|
|
785
|
+
* which canonical JSON can't hold
|
|
786
|
+
* - a `RangeError` when `indent` isn't an integer from 0 to 10
|
|
787
|
+
*
|
|
788
|
+
* The output is strict JSON, so it is also valid JSONC and JSON5.
|
|
789
|
+
*
|
|
790
|
+
* @param value - The value to serialize
|
|
791
|
+
* @param formatting - `{ indent }` (default `{ indent: 2 }`) or `'canonical'` — see {@link StringifyFormatting}
|
|
792
|
+
* @returns The JSON text
|
|
793
|
+
* @throws {TypeError} When `value` can't be represented as JSON
|
|
794
|
+
* @throws {RangeError} On an invalid `indent`, or a value canonical JSON can't hold
|
|
795
|
+
* @see {@link parse} / {@link parseStrict} — the reverse operation
|
|
796
|
+
* @see {@link stringifyLines} — one value per line (NDJSON, JSON Lines)
|
|
797
|
+
* @see {@link stringifySequence} — JSON text sequences (RFC 7464)
|
|
798
|
+
* @example
|
|
799
|
+
* stringify({ b: 1, a: [true] })
|
|
800
|
+
* // => '{\n "b": 1,\n "a": [\n true\n ]\n}'
|
|
801
|
+
* @example
|
|
802
|
+
* stringify({ b: 1, a: [true] }, { indent: 0 }) // => '{"b":1,"a":[true]}'
|
|
803
|
+
* stringify({ b: 1, a: [true] }, 'canonical') // => '{"a":[true],"b":1}'
|
|
804
|
+
* @since next
|
|
805
|
+
*/
|
|
806
|
+
function stringify(value, formatting = {}) {
|
|
807
|
+
let text;
|
|
808
|
+
if (formatting === "canonical") text = stringifyCanonical(value);
|
|
809
|
+
else {
|
|
810
|
+
const { indent = DEFAULT_INDENT } = formatting;
|
|
811
|
+
if (!Number.isInteger(indent) || indent < 0 || indent > MAX_INDENT) throw new RangeError(`stringify: indent must be an integer from 0 to ${MAX_INDENT}, got ${indent}`);
|
|
812
|
+
text = JSON.stringify(value, null, indent);
|
|
813
|
+
}
|
|
814
|
+
if (text === void 0) throw new TypeError(`stringify: ${typeof value} has no JSON representation`);
|
|
815
|
+
return text;
|
|
816
|
+
}
|
|
817
|
+
//#endregion
|
|
818
|
+
//#region helpers/json/_serializeRecords.ts
|
|
819
|
+
/**
|
|
820
|
+
* This file is part of helpers4.
|
|
821
|
+
* Copyright (C) 2025 baxyz
|
|
822
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
823
|
+
*
|
|
824
|
+
* Internal helper shared by stringifyLines.ts and stringifySequence.ts. Not exported from the
|
|
825
|
+
* package barrel — tests live in _serializeRecords.test.ts.
|
|
826
|
+
*/
|
|
827
|
+
/**
|
|
828
|
+
* Serializes each value as one compact (or canonical) JSON text, for a format that holds one
|
|
829
|
+
* value per record. Throws on a value with no JSON representation rather than writing a record
|
|
830
|
+
* that would read back as something else.
|
|
831
|
+
* @ignore
|
|
832
|
+
*/
|
|
833
|
+
function serializeRecords(caller, values, formatting) {
|
|
834
|
+
if (!Array.isArray(values)) throw new TypeError(`${caller}: expected an array of values, got ${values === null ? "null" : typeof values}`);
|
|
835
|
+
return values.map((value, index) => {
|
|
836
|
+
const text = formatting === "canonical" ? stringifyCanonical(value) : JSON.stringify(value);
|
|
837
|
+
if (text === void 0) throw new TypeError(`${caller}: the value at index ${index} (${typeof value}) has no JSON representation`);
|
|
838
|
+
return text;
|
|
839
|
+
});
|
|
840
|
+
}
|
|
841
|
+
//#endregion
|
|
842
|
+
//#region helpers/json/stringifyLines.ts
|
|
843
|
+
/**
|
|
844
|
+
* This file is part of helpers4.
|
|
845
|
+
* Copyright (C) 2025 baxyz
|
|
846
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
847
|
+
*/
|
|
848
|
+
/**
|
|
849
|
+
* Serializes values as newline-delimited JSON — one compact JSON value per line, each followed
|
|
850
|
+
* by `\n`. The output is both NDJSON and JSON Lines (`.jsonl`), which are the same format.
|
|
851
|
+
*
|
|
852
|
+
* **Formatting:**
|
|
853
|
+
* - default — each value compact, like `JSON.stringify(value)`
|
|
854
|
+
* - `'canonical'` — each value as canonical JSON (RFC 8785): sorted keys, reproducible bytes, for
|
|
855
|
+
* hashing or diffing logs line by line
|
|
856
|
+
*
|
|
857
|
+
* Indentation isn't available: a line break inside a value would split it across lines. String
|
|
858
|
+
* values can't break a line either — `JSON.stringify` escapes `\n` and `\r` inside them.
|
|
859
|
+
*
|
|
860
|
+
* **Output:** every value is followed by `\n`, including the last one (NDJSON requires it, and
|
|
861
|
+
* JSON Lines recommends it, so files can be concatenated). An empty array gives `''`.
|
|
862
|
+
*
|
|
863
|
+
* **Throws** a `TypeError` when `values` isn't an array, or when one of them has no JSON
|
|
864
|
+
* representation (`undefined`, a function, a symbol, a `BigInt`, a circular structure) — rather
|
|
865
|
+
* than writing a line that would read back as something else. With `'canonical'`, also a
|
|
866
|
+
* `RangeError` on `NaN`, `±Infinity` or a lone surrogate (see {@link stringify}).
|
|
867
|
+
*
|
|
868
|
+
* @param values - The values to write, one per line
|
|
869
|
+
* @param formatting - `'canonical'` for canonical JSON lines; compact JSON by default
|
|
870
|
+
* @returns The newline-delimited JSON text
|
|
871
|
+
* @throws {TypeError} When `values` isn't an array, or a value has no JSON representation
|
|
872
|
+
* @throws {RangeError} With `'canonical'`, on a value canonical JSON can't hold
|
|
873
|
+
* @see {@link parseLines} — the reverse operation
|
|
874
|
+
* @see {@link stringifySequence} — JSON text sequences (RFC 7464)
|
|
875
|
+
* @see {@link stringify} — a single, possibly indented, JSON value
|
|
876
|
+
* @example
|
|
877
|
+
* stringifyLines([{ id: 1 }, { id: 2 }]) // => '{"id":1}\n{"id":2}\n'
|
|
878
|
+
* @example
|
|
879
|
+
* stringifyLines([{ b: 1, a: 2 }], 'canonical') // => '{"a":2,"b":1}\n'
|
|
880
|
+
* @since next
|
|
881
|
+
*/
|
|
882
|
+
function stringifyLines(values, formatting) {
|
|
883
|
+
return serializeRecords("stringifyLines", values, formatting).map((line) => `${line}\n`).join("");
|
|
884
|
+
}
|
|
885
|
+
//#endregion
|
|
886
|
+
//#region helpers/json/stringifySequence.ts
|
|
887
|
+
/**
|
|
888
|
+
* This file is part of helpers4.
|
|
889
|
+
* Copyright (C) 2025 baxyz
|
|
890
|
+
* SPDX-License-Identifier: LGPL-3.0-or-later
|
|
891
|
+
*/
|
|
892
|
+
/**
|
|
893
|
+
* Serializes values as a JSON text sequence (RFC 7464, `application/json-seq`): each value is
|
|
894
|
+
* written as the ASCII record separator (RS, U+001E), the compact JSON text, then `\n` — the
|
|
895
|
+
* exact framing the RFC requires of encoders, as `jq --seq` writes it.
|
|
896
|
+
*
|
|
897
|
+
* **Formatting:**
|
|
898
|
+
* - default — each value compact, like `JSON.stringify(value)`
|
|
899
|
+
* - `'canonical'` — each value as canonical JSON (RFC 8785): sorted keys, reproducible bytes
|
|
900
|
+
*
|
|
901
|
+
* Indentation isn't offered, for symmetry with {@link stringifyLines}: the trailing `\n` is what
|
|
902
|
+
* lets a reader detect a truncated top-level number, `true`, `false` or `null` (RFC 7464 §2.4).
|
|
903
|
+
*
|
|
904
|
+
* **Output:** an empty array gives `''`.
|
|
905
|
+
*
|
|
906
|
+
* **Throws** a `TypeError` when `values` isn't an array, or when one of them has no JSON
|
|
907
|
+
* representation (`undefined`, a function, a symbol, a `BigInt`, a circular structure). With
|
|
908
|
+
* `'canonical'`, also a `RangeError` on `NaN`, `±Infinity` or a lone surrogate (see
|
|
909
|
+
* {@link stringify}).
|
|
910
|
+
*
|
|
911
|
+
* @param values - The values to write, one per record
|
|
912
|
+
* @param formatting - `'canonical'` for canonical JSON records; compact JSON by default
|
|
913
|
+
* @returns The JSON text sequence
|
|
914
|
+
* @throws {TypeError} When `values` isn't an array, or a value has no JSON representation
|
|
915
|
+
* @throws {RangeError} With `'canonical'`, on a value canonical JSON can't hold
|
|
916
|
+
* @see {@link parseSequence} — the reverse operation
|
|
917
|
+
* @see {@link stringifyLines} — newline-delimited JSON (NDJSON, JSON Lines)
|
|
918
|
+
* @example
|
|
919
|
+
* stringifySequence([{ id: 1 }, 2]) // => '\u001E{"id":1}\n\u001E2\n'
|
|
920
|
+
* @since next
|
|
921
|
+
*/
|
|
922
|
+
function stringifySequence(values, formatting) {
|
|
923
|
+
return serializeRecords("stringifySequence", values, formatting).map((text) => `\u001E${text}\n`).join("");
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
export { parse, parseLines, parseSequence, parseStrict, safeParse, safeParseLines, safeParseSequence, safeParseStrict, stringify, stringifyLines, stringifySequence };
|
|
927
|
+
|
|
928
|
+
//# sourceMappingURL=index.js.map
|