@endevops/effect-codec-xml 0.0.1

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.
Files changed (61) hide show
  1. package/LICENSE +21 -0
  2. package/LICENSE-is-entities +21 -0
  3. package/LICENSE-is-xml-naming +21 -0
  4. package/README.md +415 -0
  5. package/dist/codec.d.ts +48 -0
  6. package/dist/codec.d.ts.map +1 -0
  7. package/dist/codec.js +63 -0
  8. package/dist/codec.js.map +1 -0
  9. package/dist/conventions.d.ts +88 -0
  10. package/dist/conventions.d.ts.map +1 -0
  11. package/dist/conventions.js +113 -0
  12. package/dist/conventions.js.map +1 -0
  13. package/dist/entities/entity-decoder.d.ts +333 -0
  14. package/dist/entities/entity-decoder.d.ts.map +1 -0
  15. package/dist/entities/entity-decoder.js +841 -0
  16. package/dist/entities/entity-decoder.js.map +1 -0
  17. package/dist/entities/entity-tables.js +16 -0
  18. package/dist/entities/entity-tables.js.map +1 -0
  19. package/dist/errors.d.ts +49 -0
  20. package/dist/errors.d.ts.map +1 -0
  21. package/dist/errors.js +48 -0
  22. package/dist/errors.js.map +1 -0
  23. package/dist/index.d.ts +11 -0
  24. package/dist/index.js +11 -0
  25. package/dist/namespaces.d.ts +101 -0
  26. package/dist/namespaces.d.ts.map +1 -0
  27. package/dist/namespaces.js +663 -0
  28. package/dist/namespaces.js.map +1 -0
  29. package/dist/naming.d.ts +149 -0
  30. package/dist/naming.d.ts.map +1 -0
  31. package/dist/naming.js +296 -0
  32. package/dist/naming.js.map +1 -0
  33. package/dist/parse.d.ts +75 -0
  34. package/dist/parse.d.ts.map +1 -0
  35. package/dist/parse.js +437 -0
  36. package/dist/parse.js.map +1 -0
  37. package/dist/render.d.ts +99 -0
  38. package/dist/render.d.ts.map +1 -0
  39. package/dist/render.js +509 -0
  40. package/dist/render.js.map +1 -0
  41. package/dist/xml-error.d.ts +172 -0
  42. package/dist/xml-error.d.ts.map +1 -0
  43. package/dist/xml-error.js +157 -0
  44. package/dist/xml-error.js.map +1 -0
  45. package/dist/xml-value.d.ts +42 -0
  46. package/dist/xml-value.d.ts.map +1 -0
  47. package/dist/xml-value.js +79 -0
  48. package/dist/xml-value.js.map +1 -0
  49. package/package.json +69 -0
  50. package/src/codec.ts +136 -0
  51. package/src/conventions.ts +145 -0
  52. package/src/entities/entity-decoder.ts +1248 -0
  53. package/src/entities/entity-tables.ts +18 -0
  54. package/src/errors.ts +55 -0
  55. package/src/index.ts +79 -0
  56. package/src/namespaces.ts +968 -0
  57. package/src/naming.ts +519 -0
  58. package/src/parse.ts +597 -0
  59. package/src/render.ts +708 -0
  60. package/src/xml-error.ts +168 -0
  61. package/src/xml-value.ts +108 -0
@@ -0,0 +1,99 @@
1
+ import { XmlVersion } from "./naming.js";
2
+ import { XmlRenderError } from "./errors.js";
3
+ import { NameMode } from "./conventions.js";
4
+ import { XmlValue } from "./xml-value.js";
5
+ import { Effect } from "effect";
6
+ //#region src/render.d.ts
7
+ /**
8
+ * @description Options for {@link renderXml}.
9
+ */
10
+ export interface XmlRenderOptions {
11
+ /**
12
+ * @description Name of the root element.
13
+ *
14
+ * @default 'root'\
15
+ * A codec passes the name it took from the schema's `identifier` annotation when the caller did not set one.
16
+ */
17
+ readonly rootName?: string | undefined;
18
+ /**
19
+ * @description Element name used for the members of a document whose root value is an array.
20
+ *
21
+ * @default 'item'
22
+ */
23
+ readonly itemName?: string | undefined;
24
+ /**
25
+ * @description Indent nested elements on their own lines.
26
+ *
27
+ * @default false
28
+ */
29
+ readonly format?: boolean | undefined;
30
+ /**
31
+ * @description The string one indent level is made of. Defaults to two spaces.
32
+ */
33
+ readonly indent?: string | undefined;
34
+ /**
35
+ * @description Write an element with no attributes, text or children as `<a/>` rather than `<a></a>`.
36
+ *
37
+ * @default true
38
+ */
39
+ readonly suppressEmptyNode?: boolean | undefined;
40
+ /**
41
+ * @description Sort an element's keys so the same value always renders to the same bytes.
42
+ *
43
+ * @default false\
44
+ * Which keeps declaration order.\ Worth turning on for snapshot tests, where key order is otherwise the only thing that can make two equal values differ.
45
+ */
46
+ readonly sortKeys?: boolean | undefined;
47
+ /**
48
+ * @description What to do with a field name that is not a legal XML name.
49
+ *
50
+ * @default 'repair'.
51
+ */
52
+ readonly name?: NameMode | undefined;
53
+ /**
54
+ * @description XML version to validate names against.
55
+ *
56
+ * @default '1.0'
57
+ */
58
+ readonly xmlVersion?: XmlVersion | undefined;
59
+ /**
60
+ * @description How deep to nest before giving up. Guards against a value that nests without end taking the stack with it.
61
+ *
62
+ * @default 256
63
+ */
64
+ readonly maxDepth?: number | undefined;
65
+ }
66
+ /**
67
+ * @description Escapes a value for use as character data.
68
+ *
69
+ * @param value - The text to escape.
70
+ *
71
+ * @returns The text with the XML-unsafe characters replaced by predefined entities, or the very same string when there is nothing to escape.
72
+ */
73
+ export declare const escapeText: (value: string) => string;
74
+ /**
75
+ * @description Escapes a value for use inside a double-quoted attribute.
76
+ *
77
+ * @param value - The text to escape.
78
+ *
79
+ * @returns The escaped text, with the whitespace that XML would otherwise normalize spelled as character references.
80
+ */
81
+ export declare const escapeAttribute: (value: string) => string;
82
+ /**
83
+ * @description Renders an {@link XmlValue} as an XML document.\
84
+ * A record becomes an element:
85
+ *
86
+ * - `@`-prefixed keys become attributes, the reserved `#text` key becomes character data, and every other key becomes a child element.
87
+ * - An array repeats its name — a document whose root value is an array wraps it in the root element and names each member `itemName`.
88
+ * - A string is character data. The walk is synchronous, and what can go wrong is reported by throwing an {@link XmlRenderError}; {@link renderXml}
89
+ * folds that into the effect's typed error channel. A caller not already in an `Effect` runs it with `Effect.runSync`, which throws the failure it
90
+ * produced.
91
+ *
92
+ * @param value - The value to render.
93
+ * @param options - Root name, formatting, empty-element and name-resolution settings.
94
+ *
95
+ * @returns An effect producing the XML document as a string.
96
+ */
97
+ export declare const renderXml: (value: XmlValue, options?: XmlRenderOptions) => Effect.Effect<string, XmlRenderError>;
98
+ //#endregion
99
+ //# sourceMappingURL=render.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"render.d.ts","names":[],"sources":["../src/render.ts"],"mappings":";;;;;;;;;iBA+EiB;;;;;;;WAON;;;;;;WAOA;;;;;;WAOA;;;;WAKA;;;;;;WAOA;;;;;;;WAQA;;;;;;WAOA,OAAO;;;;;;WAOP,aAAa;;;;;;WAOb;;;;;;;;;qBAiHE,aAAU;;;;;;;;qBASV,kBAAe;;;;;;;;;;;;;;;;qBAmDf,YAAS,OAAW,UAAQ,UAAW,qBAAwB,OAAO,eAAe"}
package/dist/render.js ADDED
@@ -0,0 +1,509 @@
1
+ import { XmlRenderError } from "./errors.js";
2
+ import { TEXT_KEY, attributeName, isAttributeKey, isTextKey, resolveNameSync } from "./conventions.js";
3
+ import { Effect, Predicate, Result } from "effect";
4
+ //#region src/render.ts
5
+ /**
6
+ * @description The five characters XML predefines an entity for, and the names to write for them. Written out rather than referenced from the entity decoder
7
+ * because the table is indexed by character code below.
8
+ */
9
+ const XML_PREDEFINED = {
10
+ 34: "&quot;",
11
+ 38: "&amp;",
12
+ 39: "&apos;",
13
+ 60: "&lt;",
14
+ 62: "&gt;"
15
+ };
16
+ /**
17
+ * @description The character references for the whitespace XML normalizes inside an attribute value. A parser replaces a literal newline, carriage return or tab
18
+ * in an attribute with a space, so a value that has to survive a round trip has to spell them as references. They are in the attribute table and not
19
+ * the text one: in character data they are content, and only an attribute value is normalized.
20
+ */
21
+ const ATTRIBUTE_WHITESPACE = {
22
+ 9: "&#9;",
23
+ 10: "&#10;",
24
+ 13: "&#13;"
25
+ };
26
+ /**
27
+ * @description The replacement for each ASCII character that needs one, and `undefined` for the ones that do not. Indexed by character code and 128 long, so the
28
+ * check is one comparison and one array read with no string search in it.
29
+ *
30
+ * @param extra - Characters to escape in addition to the five predefines.
31
+ *
32
+ * @returns The lookup table.
33
+ */
34
+ const buildTable = (extra) => {
35
+ const table = Array.from({ length: 128 }).fill(void 0);
36
+ for (const [code, entity] of Object.entries({
37
+ ...XML_PREDEFINED,
38
+ ...extra
39
+ })) table[Number(code)] = entity;
40
+ return table;
41
+ };
42
+ const TEXT_TABLE = buildTable({});
43
+ const ATTRIBUTE_TABLE = buildTable(ATTRIBUTE_WHITESPACE);
44
+ /**
45
+ * @description The characters each table escapes, as a pattern rather than as a set of replacement passes. Finding the first one with a pattern is what makes
46
+ * clean text cheap: V8 compiles a single character class into a scan that is several times faster than a JavaScript loop reading the same string a
47
+ * code unit at a time, and clean text is most text. `render 20k of clean text` in `bench/codec.bench.ts` is the row that says so — a hand-written
48
+ * loop over the same twenty thousand characters is roughly two and a half times slower. Neither pattern is global, so `exec` ignores `lastIndex` and
49
+ * always starts at the beginning. One module-level instance of each is therefore safe to reuse, and nothing has to be reset between calls.
50
+ */
51
+ const TEXT_UNSAFE = /[<>&"']/;
52
+ const ATTRIBUTE_UNSAFE = /[<>&"'\n\r\t]/;
53
+ /**
54
+ * @description Builds the name resolver for one render. Every element and every attribute name goes through here, and a document repeats names: a thousand
55
+ * `<item>` elements, or the same `id` on every row. A validator that runs a regex per occurrence pays that cost a thousand times for one answer, so
56
+ * the first result is remembered and the rest are lookups. It also keeps the mode and version in one place, which is what stops a caller from
57
+ * resolving a name with different settings than the render it is part of. A cache miss calls {@link resolveNameSync}, which throws an
58
+ * {@link XmlParseError} in `'error'` mode; {@link renderXml} catches it and reports it as an {@link XmlRenderError}.
59
+ *
60
+ * @param options - Resolved render options.
61
+ *
62
+ * @returns A function from field name to the legal XML name.
63
+ */
64
+ const makeNamer = (options) => {
65
+ const cache = /* @__PURE__ */ new Map();
66
+ return (name) => {
67
+ const hit = cache.get(name);
68
+ if (hit !== void 0) return hit;
69
+ const resolved = resolveNameSync(name, {
70
+ mode: options.name,
71
+ xmlVersion: options.xmlVersion
72
+ });
73
+ cache.set(name, resolved);
74
+ return resolved;
75
+ };
76
+ };
77
+ /**
78
+ * @description A boolean option's value, with an absent one read as the default. The three boolean options are spelled through here rather than through a `??` of
79
+ * their own, so the table below reads as a list of what each option _is_ instead of a list of nine separate decisions about what an omitted option
80
+ * means — and so a reader looking for "which options are on by default" finds three words rather than three mixes of `?? true` and `?? false` to
81
+ * read.
82
+ *
83
+ * @param value - The option as the caller wrote it, or `undefined` when the caller left it out.
84
+ * @param fallback - The value to use when the caller left it out.
85
+ *
86
+ * @returns The option's value.
87
+ */
88
+ const flag = (value, fallback) => value ?? fallback;
89
+ /**
90
+ * @description The indent for a given depth, built the first time a render reaches that depth and kept. A document of a few thousand elements on several lines
91
+ * each would otherwise call `repeat` once per line and allocate the same handful of strings thousands of times over.
92
+ *
93
+ * @param indent - The string one level of indentation is made of.
94
+ *
95
+ * @returns A function from depth to the indent for that depth.
96
+ */
97
+ const makeLineAt = (indent) => {
98
+ const lines = [""];
99
+ return (depth) => {
100
+ const line = lines[depth];
101
+ if (line !== void 0) return line;
102
+ const built = indent.repeat(depth);
103
+ lines[depth] = built;
104
+ return built;
105
+ };
106
+ };
107
+ /**
108
+ * @description Applies the defaults to one call's options, and builds the two things a render needs that are not options: the memoized name resolver and the
109
+ * memoized indent lines.
110
+ *
111
+ * @param options - The options as the caller wrote them.
112
+ *
113
+ * @returns Every option a render reads, defaulted, with the resolver and the indent lines attached.
114
+ */
115
+ const resolveOptions = (options) => {
116
+ const resolved = {
117
+ rootName: options.rootName ?? "root",
118
+ itemName: options.itemName ?? "item",
119
+ format: flag(options.format, false),
120
+ indent: options.indent ?? " ",
121
+ suppressEmptyNode: flag(options.suppressEmptyNode, true),
122
+ sortKeys: flag(options.sortKeys, false),
123
+ name: options.name ?? "repair",
124
+ xmlVersion: options.xmlVersion ?? "1.0",
125
+ maxDepth: options.maxDepth ?? 256
126
+ };
127
+ return {
128
+ ...resolved,
129
+ namer: makeNamer(resolved),
130
+ lineAt: makeLineAt(resolved.indent)
131
+ };
132
+ };
133
+ /**
134
+ * @description Escapes a value for use as character data.
135
+ *
136
+ * @param value - The text to escape.
137
+ *
138
+ * @returns The text with the XML-unsafe characters replaced by predefined entities, or the very same string when there is nothing to escape.
139
+ */
140
+ const escapeText = (value) => escape(value, TEXT_UNSAFE, TEXT_TABLE);
141
+ /**
142
+ * @description Escapes a value for use inside a double-quoted attribute.
143
+ *
144
+ * @param value - The text to escape.
145
+ *
146
+ * @returns The escaped text, with the whitespace that XML would otherwise normalize spelled as character references.
147
+ */
148
+ const escapeAttribute = (value) => escape(value, ATTRIBUTE_UNSAFE, ATTRIBUTE_TABLE);
149
+ /**
150
+ * @description Replaces every character the table has an entry for, in one pass over the string. The pattern finds the first character that needs replacing, and a
151
+ * string with none is handed straight back — which is the common case, and the one the pattern is there to make fast. From there the rest of the
152
+ * string is copied in runs between the replacements rather than a character at a time, so the cost is one pattern scan, one copy, and one
153
+ * concatenation per replacement, rather than a whole pass per character class. Only ASCII is looked up. XML carries every other character natively,
154
+ * and a code unit above 127 has no entity an XML parser is required to know.
155
+ *
156
+ * @param value - The text to escape.
157
+ * @param pattern - Matches the first character that needs replacing.
158
+ * @param table - The replacement for each ASCII character that needs one.
159
+ *
160
+ * @returns The escaped text, or `value` itself when there is nothing to escape.
161
+ */
162
+ const escape = (value, pattern, table) => {
163
+ const found = pattern.exec(value);
164
+ if (found === null) return value;
165
+ const length = value.length;
166
+ const start = found.index;
167
+ let out = value.slice(0, start);
168
+ let copied = start;
169
+ for (let index = start; index < length; index++) {
170
+ const code = value.charCodeAt(index);
171
+ const entity = code < 128 ? table[code] : void 0;
172
+ if (entity !== void 0) {
173
+ out += value.slice(copied, index) + entity;
174
+ copied = index + 1;
175
+ }
176
+ }
177
+ return copied === length ? out : out + value.slice(copied);
178
+ };
179
+ /**
180
+ * @description Renders an {@link XmlValue} as an XML document.\
181
+ * A record becomes an element:
182
+ *
183
+ * - `@`-prefixed keys become attributes, the reserved `#text` key becomes character data, and every other key becomes a child element.
184
+ * - An array repeats its name — a document whose root value is an array wraps it in the root element and names each member `itemName`.
185
+ * - A string is character data. The walk is synchronous, and what can go wrong is reported by throwing an {@link XmlRenderError}; {@link renderXml}
186
+ * folds that into the effect's typed error channel. A caller not already in an `Effect` runs it with `Effect.runSync`, which throws the failure it
187
+ * produced.
188
+ *
189
+ * @param value - The value to render.
190
+ * @param options - Root name, formatting, empty-element and name-resolution settings.
191
+ *
192
+ * @returns An effect producing the XML document as a string.
193
+ */
194
+ const renderXml = (value, options = {}) => Effect.suspend(() => Effect.fromResult(renderResult(value, options)));
195
+ /**
196
+ * @description Runs the synchronous walk and folds the one failure it reports into a {@link Result}, which {@link renderXml} turns back into an `Effect`. Kept
197
+ * separate so the walk itself can throw without the public API ever throwing.
198
+ *
199
+ * @param value - The value to render.
200
+ * @param options - The options as the caller wrote them.
201
+ *
202
+ * @returns The document, or the failure to report.
203
+ */
204
+ const renderResult = (value, options) => {
205
+ try {
206
+ return Result.succeed(render(value, options));
207
+ } catch (cause) {
208
+ return Result.fail(toRenderError(cause));
209
+ }
210
+ };
211
+ /**
212
+ * @description Reports a failure the synchronous walk threw in the render's own error type. The walk only throws an {@link XmlRenderError} of its own or an
213
+ * {@link XmlParseError} from the name resolver; the latter carries the message the spec asserts on, so it is carried across rather than replaced.
214
+ *
215
+ * @param cause - Whatever was thrown.
216
+ *
217
+ * @returns The failure to report.
218
+ */
219
+ const toRenderError = (cause) => {
220
+ if (cause instanceof XmlRenderError) return cause;
221
+ if (Predicate.isError(cause)) return new XmlRenderError({ message: cause.message });
222
+ return new XmlRenderError({ message: String(cause) });
223
+ };
224
+ /**
225
+ * @description The synchronous walk behind {@link renderXml}.
226
+ *
227
+ * @param value - The value to render.
228
+ * @param options - The options as the caller wrote them.
229
+ *
230
+ * @returns The XML document as a string.
231
+ *
232
+ * @throws {XmlRenderError} When the value nests past `maxDepth`, or the name resolver refuses a field name.
233
+ */
234
+ const render = (value, options) => {
235
+ const resolved = resolveOptions(options);
236
+ const out = [];
237
+ if (Array.isArray(value)) {
238
+ const tag = resolved.namer(resolved.rootName);
239
+ out.push("<", tag, ">");
240
+ for (const member of value) renderElement(out, resolved.itemName, member, 1, resolved);
241
+ if (resolved.format) out.push("\n");
242
+ out.push("</", tag, ">");
243
+ } else renderElement(out, resolved.rootName, value, 0, resolved);
244
+ if (resolved.format) out.push("\n");
245
+ return out.join("");
246
+ };
247
+ /**
248
+ * @description Renders one named element and its subtree. The value an {@link XmlValue} holds decides which of the four shapes below it takes — a repeated run of
249
+ * children, character data, an absent field, or a record — and each of those is written by a function of its own, so this one is the dispatch rather
250
+ * than the document.
251
+ *
252
+ * @param out - The chunk buffer to append to.
253
+ * @param name - The element name, not yet resolved.
254
+ * @param value - The element's value.
255
+ * @param depth - Current nesting depth, for indentation and the depth cap.
256
+ * @param options - Resolved render options.
257
+ */
258
+ const renderElement = (out, name, value, depth, options) => {
259
+ assertWithinDepth(depth, options);
260
+ if (Array.isArray(value)) {
261
+ renderRepeated(out, name, value, depth, options);
262
+ return;
263
+ }
264
+ if (options.format && depth > 0) openLine(out, depth, options);
265
+ const tag = options.namer(name);
266
+ if (Predicate.isString(value) || Predicate.isUndefined(value)) {
267
+ renderLeaf(out, tag, value, options);
268
+ return;
269
+ }
270
+ if (!Predicate.isObject(value)) return;
271
+ renderRecord(out, tag, value, depth, options);
272
+ };
273
+ /**
274
+ * @description Refuses to walk deeper than the render allows. A value can nest without end, and every one of those levels costs a stack frame here, so the cap is
275
+ * checked on the way down rather than trusted to the caller.
276
+ *
277
+ * @param depth - The depth about to be written.
278
+ * @param options - Resolved render options.
279
+ *
280
+ * @throws {XmlRenderError} When the depth is past the cap.
281
+ */
282
+ const assertWithinDepth = (depth, options) => {
283
+ if (depth > options.maxDepth) throw new XmlRenderError({ message: `XML nesting exceeded maxDepth (${options.maxDepth}). Raise the limit if the document is legitimately this deep.` });
284
+ };
285
+ /**
286
+ * @description Renders a repeated run of children under one name: `tags: ['a', 'b']` renders `<tags>a</tags><tags>b</tags>`, not one element wrapping both. The
287
+ * name is already the element's own, so a name only has to be supplied where no name is available. Checked before the line break and the name are
288
+ * taken, because the array itself is not an element: opening a line for it as well as for each of its members would leave a blank line where the
289
+ * array was.
290
+ *
291
+ * @param out - The chunk buffer to append to.
292
+ * @param name - The element name, not yet resolved.
293
+ * @param members - The children to write, one after another.
294
+ * @param depth - The depth the run sits at.
295
+ * @param options - Resolved render options.
296
+ */
297
+ const renderRepeated = (out, name, members, depth, options) => {
298
+ if (members.length === 0) {
299
+ if (options.format && depth > 0) openLine(out, depth, options);
300
+ writeEmpty(out, options.namer(name), options);
301
+ return;
302
+ }
303
+ for (const member of members) renderElement(out, name, member, depth, options);
304
+ };
305
+ /**
306
+ * @description Renders an element whose value is character data, or nothing. An empty string is character data that happens to be empty, and an element holding
307
+ * none of it is the same element as one holding nothing at all — as is an `undefined` element, which is an absent one. The renderer is handed values
308
+ * that never went through the schema — a caller building a document by hand — so the absent case is reachable, and an empty element is the honest
309
+ * rendering of both.
310
+ *
311
+ * @param out - The chunk buffer to append to.
312
+ * @param tag - The element's name, already resolved.
313
+ * @param value - The element's character data, or `undefined` for an absent element.
314
+ * @param options - Resolved render options.
315
+ */
316
+ const renderLeaf = (out, tag, value, options) => {
317
+ if (Predicate.isUndefined(value) || value === "") {
318
+ writeEmpty(out, tag, options);
319
+ return;
320
+ }
321
+ out.push("<", tag, ">", escapeText(value), "</", tag, ">");
322
+ };
323
+ /**
324
+ * @description Renders an element holding a record: the attributes gathered from its `@` keys, the character data from its `#text` key, and its remaining keys as
325
+ * child elements.
326
+ *
327
+ * @param out - The chunk buffer to append to.
328
+ * @param tag - The element's name, already resolved.
329
+ * @param record - The element's value.
330
+ * @param depth - The depth the element sits at.
331
+ * @param options - Resolved render options.
332
+ */
333
+ const renderRecord = (out, tag, record, depth, options) => {
334
+ const fields = collectFields(record, options);
335
+ const text = textOf(record);
336
+ const children = fields.children;
337
+ if (children === void 0 && text === "") {
338
+ writeEmpty(out, tag, options, fields.attributes);
339
+ return;
340
+ }
341
+ out.push("<", tag, fields.attributes, ">");
342
+ if (text !== "") {
343
+ if (children !== void 0 && options.format) openLine(out, depth + 1, options);
344
+ out.push(escapeText(text));
345
+ }
346
+ if (children !== void 0) {
347
+ writeChildren(out, record, children, depth, options);
348
+ if (options.format) openLine(out, depth, options);
349
+ }
350
+ out.push("</", tag, ">");
351
+ };
352
+ /**
353
+ * @description One pass over a record's keys, collecting all three roles at once: the attributes are rendered as they are found, the child names are set aside for
354
+ * the pass that writes them, and the text key is left to {@link textOf}. A pass for the attributes, a pass for the children and an index for the text
355
+ * instead walks the keys three times and allocates the key array twice, which on a document of a few thousand elements is thousands of allocations
356
+ * for nothing. Sorting is off by default, and the default path is the one that matters, so the attributes are built as they are found and there is
357
+ * nothing to sort. When it is on, the attribute keys are collected instead and rendered afterwards in sorted order, which costs an array per element
358
+ * and buys output that does not depend on the order the fields happened to be declared in.
359
+ *
360
+ * @param record - The element's value.
361
+ * @param options - Resolved render options.
362
+ *
363
+ * @returns The element's rendered attributes and its child names.
364
+ */
365
+ const collectFields = (record, options) => {
366
+ const keys = Object.keys(record);
367
+ let attributes = "";
368
+ let children;
369
+ const sortAttributes = options.sortKeys ? [] : void 0;
370
+ for (let i = 0; i < keys.length; i++) {
371
+ const key = keys[i];
372
+ const child = record[key];
373
+ if (child === void 0) continue;
374
+ if (isAttributeKey(key)) {
375
+ if (sortAttributes === void 0) attributes += renderAttribute(key, child, options);
376
+ else sortAttributes.push(key);
377
+ continue;
378
+ }
379
+ if (!isTextKey(key)) (children ??= []).push(key);
380
+ }
381
+ if (sortAttributes !== void 0) {
382
+ sortAttributes.sort();
383
+ attributes += sortedAttributes(sortAttributes, record, options);
384
+ children?.sort();
385
+ }
386
+ return {
387
+ attributes,
388
+ children
389
+ };
390
+ };
391
+ /**
392
+ * @description The attributes named by `keys`, rendered in the order given. Only reached when the render was asked to sort keys, where the names are collected
393
+ * during the key pass and written here so their order does not follow the order the fields were declared in.
394
+ *
395
+ * @param keys - The attribute keys to write, in the order to write them.
396
+ * @param record - The element's value, to read the attribute values out of.
397
+ * @param options - Resolved render options.
398
+ *
399
+ * @returns The rendered attributes, each with its leading space.
400
+ */
401
+ const sortedAttributes = (keys, record, options) => {
402
+ let attributes = "";
403
+ for (const key of keys) attributes += renderAttribute(key, record[key], options);
404
+ return attributes;
405
+ };
406
+ /**
407
+ * @description One attribute, written whole. The leading space is part of it so the caller can concatenate attributes and the opening tag without a separator of
408
+ * its own.
409
+ *
410
+ * @param key - The attribute's key, with or without its `@` prefix.
411
+ * @param value - The attribute's value.
412
+ * @param options - Resolved render options.
413
+ *
414
+ * @returns The attribute, ready to write inside the opening tag.
415
+ */
416
+ const renderAttribute = (key, value, options) => {
417
+ return " " + options.namer(attributeName(key)) + "=\"" + escapeAttribute(attributeText(value)) + "\"";
418
+ };
419
+ /**
420
+ * @description Writes an element's children, by name and in the order their keys were found. The names are what the key pass kept; the values are read back out of
421
+ * the record here, because keeping both would mean a second array per element.
422
+ *
423
+ * @param out - The chunk buffer to append to.
424
+ * @param record - The element's value.
425
+ * @param children - The child names, in the order to write them.
426
+ * @param depth - The depth the parent sits at; its children are one deeper.
427
+ * @param options - Resolved render options.
428
+ */
429
+ const writeChildren = (out, record, children, depth, options) => {
430
+ for (let i = 0; i < children.length; i++) {
431
+ const key = children[i];
432
+ const child = record[key];
433
+ if (child === void 0) continue;
434
+ renderElement(out, key, child, depth + 1, options);
435
+ }
436
+ };
437
+ /**
438
+ * @description Starts a new line at the given depth, when pretty-printing.
439
+ *
440
+ * @param out - The chunk buffer to append to.
441
+ * @param depth - The depth the line sits at.
442
+ * @param options - Resolved render options.
443
+ */
444
+ const openLine = (out, depth, options) => {
445
+ out.push("\n");
446
+ out.push(options.lineAt(depth));
447
+ };
448
+ /**
449
+ * @description Writes an element with no content, in whichever of the two forms the options ask for. Every path that produces an element with nothing in it goes
450
+ * through here, so the self-closing decision is made in exactly one place. That matters because "nothing in it" arrives four different ways — an
451
+ * empty string, an absent value, an empty array, and a record whose fields are all absent — and four separate decisions are four chances for one of
452
+ * them to write the long form by accident.
453
+ *
454
+ * @param out - The chunk buffer to append to.
455
+ * @param tag - The element's name, already resolved.
456
+ * @param options - Resolved render options.
457
+ * @param attributes - The element's rendered attributes, if it has any. Defaults to none.
458
+ */
459
+ const writeEmpty = (out, tag, options, attributes = "") => {
460
+ if (options.suppressEmptyNode) out.push("<", tag, attributes, "/>");
461
+ else out.push("<", tag, attributes, "></", tag, ">");
462
+ };
463
+ /**
464
+ * @description An element's character data, with the reserved text key read off its value.
465
+ *
466
+ * @param record - The element's value.
467
+ *
468
+ * @returns The text to write between the tags, or `''` when the element has none.
469
+ */
470
+ const textOf = (record) => {
471
+ const text = record[TEXT_KEY];
472
+ if (Predicate.isUndefined(text)) return "";
473
+ return Predicate.isString(text) ? text : renderScalar(text);
474
+ };
475
+ /**
476
+ * @description An attribute value as the character data it is written as. A bare string is the only sensible shape, since an attribute holds nothing else. A
477
+ * non-string is stringified rather than rejected: the schema is what enforces the field's type, and rejecting here would duplicate that check with a
478
+ * different error and a message that names neither the field nor the document.
479
+ *
480
+ * @param value - The attribute's value.
481
+ *
482
+ * @returns The text to escape and write between the quotes.
483
+ */
484
+ const attributeText = (value) => {
485
+ if (Predicate.isString(value)) return value;
486
+ if (Predicate.isUndefined(value)) return "";
487
+ return renderScalar(value);
488
+ };
489
+ /**
490
+ * @description Renders a leaf that is not a string as the character data an XML document can hold. A schema-derived value never reaches here —
491
+ * `Schema.toCodecStringTree` has already turned every scalar into a string — so this is for values a caller built by hand. A value with no sensible
492
+ * text form is rendered as nothing rather than as `[object Object]`, which would silently write a document that parses back to something else.
493
+ *
494
+ * @param value - The leaf to render.
495
+ *
496
+ * @returns The leaf's textual form.
497
+ */
498
+ const renderScalar = (value) => {
499
+ if (Predicate.isNull(value)) return "null";
500
+ try {
501
+ return JSON.stringify(value) ?? "";
502
+ } catch {
503
+ return "";
504
+ }
505
+ };
506
+ //#endregion
507
+ export { escapeAttribute, escapeText, renderXml };
508
+
509
+ //# sourceMappingURL=render.js.map