@cofold/yaml 0.2.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/src/load.ts ADDED
@@ -0,0 +1,78 @@
1
+ import type { LoadedYaml, YamlLoadOptions } from "./types/load.js";
2
+ import { readFile as readLocal } from "node:fs/promises";
3
+ import { resolve } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+ import { parseYaml } from "./yaml.js";
6
+
7
+ /** Parse YAML or JSON and optionally resolve references relative to each containing document. */
8
+ export async function loadYaml(source: string, options: YamlLoadOptions = {}): Promise<LoadedYaml> {
9
+ const root = /^(?:https?:|file:)/u.test(source) ? new URL(source) : pathToFileURL(resolve(source));
10
+ const cache = new Map<string, Promise<unknown>>();
11
+ const diagnostics: LoadedYaml["diagnostics"] = [];
12
+ const maxBytes = options.maxBytes ?? 16 * 1024 * 1024;
13
+ const read = (url: URL): Promise<unknown> => {
14
+ const address = new URL(url); address.hash = "";
15
+ const key = address.href;
16
+ const prior = cache.get(key); if (prior) return prior;
17
+ if (cache.size >= (options.maxDocuments ?? 1024)) throw new Error("Reference loading exceeds maxDocuments");
18
+ const pending = (async () => {
19
+ let text: string;
20
+ if (address.protocol === "file:") text = await (options.readFile ?? ((path) => readLocal(path, "utf8")))(fileURLToPath(address));
21
+ else if (address.protocol === "https:" || address.protocol === "http:") {
22
+ const response = await (options.fetch ?? globalThis.fetch)(address, { signal: AbortSignal.timeout(options.timeoutMs ?? 30_000), redirect: "error" });
23
+ if (!response.ok) throw new Error(`${address.href} returned HTTP ${response.status}`);
24
+ const reader = response.body?.getReader();
25
+ const chunks: Uint8Array[] = []; let size = 0;
26
+ if (reader) { for (;;) { const part = await reader.read(); if (part.done) break; size += part.value.byteLength;
27
+ if (size > maxBytes) { await reader.cancel(); throw new Error(`${address.href} exceeds maxBytes`); } chunks.push(part.value); } }
28
+ text = Buffer.concat(chunks).toString("utf8");
29
+ } else throw new Error(`Unsupported reference protocol ${address.protocol}`);
30
+ if (Buffer.byteLength(text) > maxBytes) throw new Error(`${address.href} exceeds maxBytes`);
31
+ return address.pathname.endsWith(".json") ? JSON.parse(text) as unknown : parseYaml(text, { ...options, source: address.href });
32
+ })();
33
+ cache.set(key, pending); return pending;
34
+ };
35
+ const pointer = (data: unknown, hash: string): unknown => {
36
+ if (!hash || hash === "#") return data;
37
+ const path = decodeURIComponent(hash.slice(1));
38
+ if (!path.startsWith("/")) throw new Error(`Reference fragment ${hash} must be a JSON Pointer`);
39
+ let value = data;
40
+ for (const part of path.slice(1).split("/")) {
41
+ const key = part.replaceAll("~1", "/").replaceAll("~0", "~");
42
+ if (value === null || typeof value !== "object" || !Object.hasOwn(value, key)) throw new Error(`Reference ${hash} does not exist`);
43
+ value = (value as Record<string, unknown>)[key];
44
+ }
45
+ return value;
46
+ };
47
+ const visit = async (value: unknown, base: URL, chain: string[], depth: number): Promise<unknown> => {
48
+ if (depth > (options.maxDepth ?? 128)) throw new Error(`Reference nesting exceeds maxDepth: ${chain.join(" -> ")}`);
49
+ if (Array.isArray(value)) { const out: unknown[] = []; for (const entry of value) out.push(await visit(entry, base, chain, depth + 1)); return out; }
50
+ if (value === null || typeof value !== "object") return value;
51
+ const object = value as Record<string, unknown>;
52
+ if (typeof object["$ref"] === "string" && options.refs !== undefined) {
53
+ const target = new URL(object["$ref"], base);
54
+ const external = target.href.split("#")[0] !== base.href.split("#")[0];
55
+ if (external && target.protocol === "file:" && (options.refs.local !== true || base.protocol !== "file:")) throw new Error(`Local reference is not allowed: ${target.href}`);
56
+ if (external && /^https?:$/u.test(target.protocol) && options.refs.remote !== true) throw new Error(`Remote reference is not allowed: ${target.href}`);
57
+ if (chain.includes(target.href)) {
58
+ if (options.refs.circular === "preserve") return { $ref: target.href };
59
+ throw new Error(`Circular reference: ${[...chain, target.href].join(" -> ")}`);
60
+ }
61
+ let result: unknown;
62
+ try { result = await visit(pointer(await read(target), target.hash), target, [...chain, target.href], depth + 1); }
63
+ catch (error) {
64
+ if (options.refs.unresolved !== "preserve") throw error;
65
+ diagnostics.push({ reference: target.href, message: error instanceof Error ? error.message : String(error) });
66
+ return { $ref: target.href };
67
+ }
68
+ const siblings = Object.entries(object).filter(([key]) => key !== "$ref");
69
+ if (siblings.some(([key]) => !["summary", "description"].includes(key))) throw new Error(`Unsupported $ref siblings at ${base.href}; constraints must be in the referenced schema`);
70
+ return siblings.length && result !== null && typeof result === "object" ? { ...result, ...Object.fromEntries(siblings) } : result;
71
+ }
72
+ const out: Record<string, unknown> = {};
73
+ for (const [key, entry] of Object.entries(object)) Object.defineProperty(out, key, { value: await visit(entry, base, chain, depth + 1), enumerable: true });
74
+ return out;
75
+ };
76
+ const data = await visit(pointer(await read(root), root.hash), root, [], 0);
77
+ return { data, sources: [...cache.keys()], diagnostics };
78
+ }
@@ -0,0 +1,11 @@
1
+ import type { YamlParseOptions } from "./parse.js";
2
+
3
+ export interface YamlLoadOptions extends YamlParseOptions {
4
+ refs?: { local?: boolean; remote?: boolean; circular?: "error" | "preserve"; unresolved?: "error" | "preserve" };
5
+ readFile?(path: string): Promise<string> | string;
6
+ fetch?: typeof globalThis.fetch;
7
+ timeoutMs?: number;
8
+ maxDocuments?: number;
9
+ }
10
+
11
+ export interface LoadedYaml { data: unknown; sources: string[]; diagnostics: { reference: string; message: string }[] }
@@ -0,0 +1,8 @@
1
+ export interface YamlParseOptions {
2
+ /** A filename or URL used in diagnostics; the parser does not read it. */
3
+ source?: string;
4
+ /** Bound input size before parsing. Default: 16 MiB. */
5
+ maxBytes?: number;
6
+ /** Bound nesting before recursive parsing. Default: 128. */
7
+ maxDepth?: number;
8
+ }
package/src/yaml.ts ADDED
@@ -0,0 +1,463 @@
1
+ import type { YamlParseOptions } from "./types/parse.js";
2
+
3
+ /**
4
+ * @cofold/yaml reads a documented YAML subset as plain data, without I/O or dependencies.
5
+ * Unsupported syntax is rejected rather than interpreted approximately.
6
+ * File loading, document composition, and reference resolution belong to callers.
7
+ */
8
+
9
+ export class YamlError extends Error {
10
+ public readonly line: number;
11
+ public readonly source: string | undefined;
12
+ public readonly detail: string;
13
+
14
+ public constructor(message: string, line: number, source?: string) {
15
+ super(`${message} (${source === undefined ? "" : `${source}: `}line ${line})`);
16
+ this.name = "YamlError";
17
+ this.line = line;
18
+ this.source = source;
19
+ this.detail = message;
20
+ }
21
+ }
22
+
23
+ /** One significant line: its depth, its content without the comment, and where it came from. */
24
+ interface Line {
25
+ indent: number;
26
+ text: string;
27
+ number: number;
28
+ }
29
+
30
+ /** A cursor over one line's text, for the flow collections and the quoted scalars. */
31
+ interface Reader {
32
+ text: string;
33
+ at: number;
34
+ line: number;
35
+ }
36
+
37
+ /** What a plain scalar may not start with, because each one means a feature this does not read. */
38
+ const REFUSED: readonly { start: string; what: string }[] = [
39
+ { start: "&", what: "anchors" },
40
+ { start: "*", what: "aliases" },
41
+ { start: "!", what: "tags" },
42
+ { start: "|", what: "block scalars" },
43
+ { start: ">", what: "folded scalars" },
44
+ { start: "%", what: "directives" },
45
+ { start: "`", what: "reserved indicators" },
46
+ { start: "@", what: "reserved indicators" },
47
+ ];
48
+
49
+ /** The document as data. Mappings become objects, sequences arrays, scalars themselves. */
50
+ export function parseYaml(text: string, options: YamlParseOptions = {}): unknown {
51
+ try {
52
+ if (Buffer.byteLength(text, "utf8") > (options.maxBytes ?? 16 * 1024 * 1024)) {
53
+ throw new YamlError("YAML exceeds maxBytes", 1);
54
+ }
55
+ return parse(text, options.maxDepth ?? 128);
56
+ } catch (error) {
57
+ if (error instanceof YamlError && options.source !== undefined) {
58
+ throw new YamlError(error.detail, error.line, options.source);
59
+ }
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function parse(text: string, maxDepth: number): unknown {
65
+ const lines = scan(text);
66
+ const levels: number[] = [];
67
+ for (const line of lines) {
68
+ while (levels.length && levels.at(-1)! >= line.indent) levels.pop();
69
+ levels.push(line.indent);
70
+ let flowDepth = 0;
71
+ let quote = "";
72
+ for (let i = 0; i < line.text.length; i++) {
73
+ const char = line.text[i]!;
74
+ if (quote) { if (char === "\\" && quote === '"') i++; else if (char === quote) quote = ""; continue; }
75
+ if (char === '"' || char === "'") quote = char;
76
+ else if (char === "[" || char === "{") { if (++flowDepth + levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number); }
77
+ else if (char === "]" || char === "}") flowDepth--;
78
+ }
79
+ if (levels.length > maxDepth) throw new YamlError("YAML exceeds maxDepth", line.number);
80
+ }
81
+ if (lines.length === 0) return null;
82
+ const first = lines[0]!;
83
+ if (lines.length === 1 && keyAt(first.text) === -1 && !isItem(first)) {
84
+ return first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number);
85
+ }
86
+ const [value, at] = block(lines, 0, first.indent);
87
+ if (at < lines.length) {
88
+ throw new YamlError("This line is outside the document above it", lines[at]!.number);
89
+ }
90
+ return value;
91
+ }
92
+
93
+ /**
94
+ * Text as the lines that carry meaning.
95
+ *
96
+ * Comments and blank lines are dropped here rather than in the parser, so
97
+ * nothing below has to ask whether a `#` is a comment or part of a value.
98
+ */
99
+ function scan(text: string): Line[] {
100
+ const lines: Line[] = [];
101
+ let opened = false;
102
+
103
+ blockScalars(text).forEach((raw, index) => {
104
+ const number = index + 1;
105
+ const stripped = stripComment(raw);
106
+ const trimmed = stripped.trim();
107
+ if (trimmed === "") return;
108
+
109
+ if (trimmed === "---") {
110
+ if (opened || lines.length > 0) throw new YamlError("This parser reads one document per file", number);
111
+ opened = true;
112
+ return;
113
+ }
114
+ if (trimmed === "...") throw new YamlError("This parser reads one document per file", number);
115
+
116
+ const indent = stripped.length - stripped.trimStart().length;
117
+ if (stripped.slice(0, indent).includes("\t")) {
118
+ throw new YamlError("YAML is indented with spaces, and this line uses a tab", number);
119
+ }
120
+ lines.push({ indent, text: trimmed, number });
121
+ });
122
+
123
+ return lines;
124
+ }
125
+
126
+ /** Turn block scalars into quoted values while retaining physical line numbers. */
127
+ function blockScalars(text: string): string[] {
128
+ const lines = text.replace(/\r\n?/gu, "\n").split("\n");
129
+ if (lines.at(-1) === "") lines.pop();
130
+ for (let i = 0; i < lines.length; i++) {
131
+ const raw = stripComment(lines[i]!);
132
+ const match = /^(.*?(?::\s+|-\s+))([|>])([1-9][+-]?|[+-][1-9]?|)\s*$/u.exec(raw);
133
+ if (!match) continue;
134
+ const parent = raw.length - raw.trimStart().length;
135
+ const modifier = match[3]!;
136
+ const explicit = /[1-9]/u.exec(modifier);
137
+ let end = i + 1;
138
+ while (end < lines.length && (lines[end]!.trim() === "" || lines[end]!.search(/\S/u) > parent)) end++;
139
+ const held = lines.slice(i + 1, end);
140
+ const nonempty = held.find((line) => line.trim() !== "");
141
+ const indent = explicit ? parent + Number(explicit[0]) : nonempty?.search(/\S/u) ?? parent + 1;
142
+ const parts = held.map((line, at) => {
143
+ if (!line.trim()) return "";
144
+ if (line.search(/\S/u) < indent) throw new YamlError("Block scalar indentation is inconsistent", i + at + 2);
145
+ return line.slice(indent);
146
+ });
147
+ let value = "";
148
+ for (let j = 0; j < parts.length; j++) {
149
+ const current = parts[j]!;
150
+ const next = parts[j + 1];
151
+ value += current;
152
+ if (match[2] === "|" || next === undefined || current.startsWith(" ") || next.startsWith(" ")) value += "\n";
153
+ else if (current !== "" && next !== "") value += " ";
154
+ else if (current === "" || (next === "" && parts.slice(j + 1).every((part) => part === ""))) value += "\n";
155
+ }
156
+ if (!modifier.includes("+")) value = value.replace(/\n+$/u, "") + (modifier.includes("-") || !parts.some((part) => part !== "") ? "" : "\n");
157
+ lines[i] = match[1]! + JSON.stringify(value);
158
+ for (let j = i + 1; j < end; j++) lines[j] = "";
159
+ i = end - 1;
160
+ }
161
+ return lines;
162
+ }
163
+
164
+ /** A `#` that opens a comment: one at the start of the line or after a space, outside quotes. */
165
+ function stripComment(raw: string): string {
166
+ let quote: string | null = null;
167
+ for (let at = 0; at < raw.length; at += 1) {
168
+ const char = raw[at]!;
169
+ if (quote !== null) {
170
+ if (char === "\\" && quote === '"') { at += 1; continue; }
171
+ if (char === quote) quote = null;
172
+ continue;
173
+ }
174
+ if ((char === '"' || char === "'") && (at === 0 || /[\s[{:,]/u.test(raw[at - 1]!))) { quote = char; continue; }
175
+ if (char === "#" && (at === 0 || /\s/u.test(raw[at - 1]!))) return raw.slice(0, at);
176
+ }
177
+ return raw;
178
+ }
179
+
180
+ function isItem(line: Line): boolean {
181
+ return line.text === "-" || line.text.startsWith("- ");
182
+ }
183
+
184
+ function block(lines: Line[], at: number, indent: number): [unknown, number] {
185
+ const first = lines[at]!;
186
+ if (!isItem(first) && keyAt(first.text) === -1) {
187
+ return [first.text.startsWith("[") || first.text.startsWith("{") ? parseFlow(first.text, first.number) : scalar(first.text, first.number), at + 1];
188
+ }
189
+ return isItem(lines[at]!) ? sequence(lines, at, indent) : mapping(lines, at, indent);
190
+ }
191
+
192
+ function sequence(lines: Line[], at: number, indent: number): [unknown[], number] {
193
+ const items: unknown[] = [];
194
+ let cursor = at;
195
+
196
+ while (cursor < lines.length && lines[cursor]!.indent === indent && isItem(lines[cursor]!)) {
197
+ const line = lines[cursor]!;
198
+ const body = line.text === "-" ? "" : line.text.slice(2);
199
+ const rest = body.trim();
200
+
201
+ if (rest === "") {
202
+ const next = lines[cursor + 1];
203
+ if (next !== undefined && next.indent > indent) {
204
+ const [value, after] = block(lines, cursor + 1, next.indent);
205
+ items.push(value);
206
+ cursor = after;
207
+ } else {
208
+ items.push(null);
209
+ cursor += 1;
210
+ }
211
+ continue;
212
+ }
213
+
214
+ if (rest.startsWith("[") || rest.startsWith("{")) {
215
+ items.push(parseFlow(rest, line.number));
216
+ cursor += 1;
217
+ continue;
218
+ }
219
+
220
+ if (keyAt(rest) === -1) {
221
+ items.push(scalar(rest, line.number));
222
+ cursor += 1;
223
+ continue;
224
+ }
225
+
226
+ /*
227
+ * `- key: value` opens a mapping whose first key sits on the dash's own
228
+ * line. The line is restated at the column that key actually starts in, so
229
+ * the following keys - indented to that column - read as the same mapping.
230
+ */
231
+ const offset = indent + 2 + (body.length - body.trimStart().length);
232
+ lines[cursor] = { indent: offset, text: rest, number: line.number };
233
+ const [value, after] = mapping(lines, cursor, offset);
234
+ items.push(value);
235
+ cursor = after;
236
+ }
237
+
238
+ return [items, cursor];
239
+ }
240
+
241
+ function mapping(lines: Line[], at: number, indent: number): [Record<string, unknown>, number] {
242
+ const map: Record<string, unknown> = {};
243
+ let cursor = at;
244
+
245
+ while (cursor < lines.length && lines[cursor]!.indent === indent) {
246
+ const line = lines[cursor]!;
247
+ if (isItem(line)) break;
248
+
249
+ const colon = keyAt(line.text);
250
+ if (colon === -1) {
251
+ throw new YamlError(`"${line.text}" is not a mapping entry: a key is written "name: value"`, line.number);
252
+ }
253
+ const key = String(scalar(line.text.slice(0, colon).trim(), line.number));
254
+ if (key === "<<") throw new YamlError("This parser does not read merge keys", line.number);
255
+ if (Object.hasOwn(map, key)) throw new YamlError(`${key} is given twice`, line.number);
256
+
257
+ const rest = line.text.slice(colon + 1).trim();
258
+ if (rest !== "") {
259
+ Object.defineProperty(map, key, { value: rest.startsWith("[") || rest.startsWith("{")
260
+ ? parseFlow(rest, line.number) : scalar(rest, line.number), enumerable: true, writable: true, configurable: true });
261
+ cursor += 1;
262
+ continue;
263
+ }
264
+
265
+ /*
266
+ * A key with nothing after it opens a block, which is either indented under
267
+ * it or - for a sequence, which YAML lets sit at the key's own column - at
268
+ * the same depth.
269
+ */
270
+ const next = lines[cursor + 1];
271
+ if (next !== undefined && (next.indent > indent || (next.indent === indent && isItem(next)))) {
272
+ const [value, after] = block(lines, cursor + 1, next.indent);
273
+ Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true });
274
+ cursor = after;
275
+ continue;
276
+ }
277
+ Object.defineProperty(map, key, { value: null, enumerable: true, writable: true, configurable: true });
278
+ cursor += 1;
279
+ }
280
+
281
+ if (cursor < lines.length && lines[cursor]!.indent > indent) {
282
+ throw new YamlError("This line is indented further than anything above it opened", lines[cursor]!.number);
283
+ }
284
+ return [map, cursor];
285
+ }
286
+
287
+ /**
288
+ * Where a key ends: a colon followed by a space or the end of the line.
289
+ *
290
+ * The space is what keeps `endpoint: http://host/path` one key and one value
291
+ * rather than two keys, and it is the same rule YAML itself uses.
292
+ */
293
+ function keyAt(text: string): number {
294
+ let quote: string | null = null;
295
+ let depth = 0;
296
+ for (let at = 0; at < text.length; at += 1) {
297
+ const char = text[at]!;
298
+ if (quote !== null) {
299
+ if (char === "\\" && quote === '"') { at += 1; continue; }
300
+ if (char === quote) quote = null;
301
+ continue;
302
+ }
303
+ if ((char === '"' || char === "'") && (at === 0 || /[\s[{:,]/u.test(text[at - 1]!))) { quote = char; continue; }
304
+ if (char === "[" || char === "{") { depth += 1; continue; }
305
+ if (char === "]" || char === "}") { depth -= 1; continue; }
306
+ if (char === ":" && depth === 0 && (text[at + 1] === undefined || text[at + 1] === " ")) return at;
307
+ }
308
+ return -1;
309
+ }
310
+
311
+ /** One scalar: quoted text as written, or a plain word read as null, a boolean, a number or text. */
312
+ function scalar(text: string, line: number): unknown {
313
+ if (text === "") return null;
314
+
315
+ if (text.startsWith('"') || text.startsWith("'")) {
316
+ const reader: Reader = { text, at: 0, line };
317
+ const value = quotedFrom(reader);
318
+ skipSpace(reader);
319
+ if (reader.at < text.length) throw new YamlError("There is text after the closing quote", line);
320
+ return value;
321
+ }
322
+
323
+ for (const { start, what } of REFUSED) {
324
+ if (text.startsWith(start)) {
325
+ throw new YamlError(`This parser does not read ${what}; quote the value if it is text`, line);
326
+ }
327
+ }
328
+
329
+ if (text === "~" || text === "null") return null;
330
+ if (text === "true") return true;
331
+ if (text === "false") return false;
332
+ if (/^-?\d+$/u.test(text)) return Number(text);
333
+ if (/^-?(?:\d+\.\d*|\.\d+)(?:[eE][-+]?\d+)?$/u.test(text)) return Number(text);
334
+ if (/^-?\d+[eE][-+]?\d+$/u.test(text)) return Number(text);
335
+ return text;
336
+ }
337
+
338
+ function parseFlow(text: string, line: number): unknown {
339
+ const reader: Reader = { text, at: 0, line };
340
+ const value = flowValue(reader);
341
+ skipSpace(reader);
342
+ if (reader.at < text.length) throw new YamlError("There is text after the flow collection", line);
343
+ return value;
344
+ }
345
+
346
+ function skipSpace(reader: Reader): void {
347
+ while (reader.at < reader.text.length && /\s/u.test(reader.text[reader.at]!)) reader.at += 1;
348
+ }
349
+
350
+ function flowValue(reader: Reader): unknown {
351
+ skipSpace(reader);
352
+ const char = reader.text[reader.at];
353
+ if (char === undefined) throw new YamlError("A flow collection ends with a value missing", reader.line);
354
+ if (char === "[") return flowSequence(reader);
355
+ if (char === "{") return flowMapping(reader);
356
+ if (char === '"' || char === "'") return quotedFrom(reader);
357
+
358
+ const start = reader.at;
359
+ while (reader.at < reader.text.length && !",]}".includes(reader.text[reader.at]!)) reader.at += 1;
360
+ return scalar(reader.text.slice(start, reader.at).trim(), reader.line);
361
+ }
362
+
363
+ function flowSequence(reader: Reader): unknown[] {
364
+ reader.at += 1;
365
+ const items: unknown[] = [];
366
+ skipSpace(reader);
367
+ if (reader.text[reader.at] === "]") { reader.at += 1; return items; }
368
+
369
+ for (;;) {
370
+ items.push(flowValue(reader));
371
+ skipSpace(reader);
372
+ const char = reader.text[reader.at];
373
+ if (char === "]") { reader.at += 1; return items; }
374
+ if (char !== ",") throw new YamlError("A flow sequence separates values with , and ends with ]", reader.line);
375
+ reader.at += 1;
376
+ skipSpace(reader);
377
+ if (reader.text[reader.at] === "]") { reader.at += 1; return items; }
378
+ }
379
+ }
380
+
381
+ function flowMapping(reader: Reader): Record<string, unknown> {
382
+ reader.at += 1;
383
+ const map: Record<string, unknown> = {};
384
+ skipSpace(reader);
385
+ if (reader.text[reader.at] === "}") { reader.at += 1; return map; }
386
+
387
+ for (;;) {
388
+ skipSpace(reader);
389
+ const opener = reader.text[reader.at];
390
+ const key = opener === '"' || opener === "'" ? quotedFrom(reader) : plainKey(reader);
391
+ skipSpace(reader);
392
+ let value: unknown = null;
393
+ if (reader.text[reader.at] === ":") {
394
+ reader.at += 1;
395
+ skipSpace(reader);
396
+ if (reader.text[reader.at] !== "," && reader.text[reader.at] !== "}") value = flowValue(reader);
397
+ } else if (reader.text[reader.at] !== "," && reader.text[reader.at] !== "}") {
398
+ throw new YamlError("A flow mapping entry is written name: value", reader.line);
399
+ }
400
+ if (Object.hasOwn(map, key)) throw new YamlError(`${key} is given twice`, reader.line);
401
+ Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true });
402
+
403
+ skipSpace(reader);
404
+ const char = reader.text[reader.at];
405
+ if (char === "}") { reader.at += 1; return map; }
406
+ if (char !== ",") throw new YamlError("A flow mapping separates entries with , and ends with }", reader.line);
407
+ reader.at += 1;
408
+ skipSpace(reader);
409
+ if (reader.text[reader.at] === "}") { reader.at += 1; return map; }
410
+ }
411
+ }
412
+
413
+ function plainKey(reader: Reader): string {
414
+ const start = reader.at;
415
+ while (reader.at < reader.text.length && !":,}".includes(reader.text[reader.at]!)) reader.at += 1;
416
+ const key = reader.text.slice(start, reader.at).trim();
417
+ if (key === "") throw new YamlError("A flow mapping entry needs a name", reader.line);
418
+ return key;
419
+ }
420
+
421
+ /** A quoted scalar from the cursor. Single quotes are literal but for `''`; double quotes escape. */
422
+ function quotedFrom(reader: Reader): string {
423
+ const quote = reader.text[reader.at]!;
424
+ reader.at += 1;
425
+ let out = "";
426
+
427
+ while (reader.at < reader.text.length) {
428
+ const char = reader.text[reader.at]!;
429
+
430
+ if (char === quote) {
431
+ if (quote === "'" && reader.text[reader.at + 1] === "'") {
432
+ out += "'";
433
+ reader.at += 2;
434
+ continue;
435
+ }
436
+ reader.at += 1;
437
+ return out;
438
+ }
439
+
440
+ if (char === "\\" && quote === '"') {
441
+ const escape = reader.text[reader.at + 1];
442
+ reader.at += 2;
443
+ if (escape === "n") { out += "\n"; continue; }
444
+ if (escape === "t") { out += "\t"; continue; }
445
+ if (escape === "r") { out += "\r"; continue; }
446
+ if (escape === "0") { out += "\0"; continue; }
447
+ if (escape === '"' || escape === "\\" || escape === "/" || escape === "'") { out += escape; continue; }
448
+ if (escape === "u") {
449
+ const digits = reader.text.slice(reader.at, reader.at + 4);
450
+ if (!/^[0-9a-fA-F]{4}$/u.test(digits)) throw new YamlError("\\u needs four hex digits", reader.line);
451
+ out += String.fromCharCode(Number.parseInt(digits, 16));
452
+ reader.at += 4;
453
+ continue;
454
+ }
455
+ throw new YamlError(`\\${escape ?? ""} is not an escape this parser reads`, reader.line);
456
+ }
457
+
458
+ out += char;
459
+ reader.at += 1;
460
+ }
461
+
462
+ throw new YamlError("A quoted value is not closed", reader.line);
463
+ }