@helpers4/json 0.0.0-stage → 3.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/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 3.1.3
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 3.1.3
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 3.1.3
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 3.1.3
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 3.1.3
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 3.1.3
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 3.1.3
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