@open-nav/core 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (133) hide show
  1. package/LICENSE +29 -0
  2. package/README.md +132 -0
  3. package/dist/constants.d.ts +49 -0
  4. package/dist/constants.d.ts.map +1 -0
  5. package/dist/constants.js +58 -0
  6. package/dist/constants.js.map +1 -0
  7. package/dist/crypto/hash.d.ts +16 -0
  8. package/dist/crypto/hash.d.ts.map +1 -0
  9. package/dist/crypto/hash.js +30 -0
  10. package/dist/crypto/hash.js.map +1 -0
  11. package/dist/crypto/node-provider.d.ts +9 -0
  12. package/dist/crypto/node-provider.d.ts.map +1 -0
  13. package/dist/crypto/node-provider.js +18 -0
  14. package/dist/crypto/node-provider.js.map +1 -0
  15. package/dist/crypto/provider.d.ts +28 -0
  16. package/dist/crypto/provider.d.ts.map +1 -0
  17. package/dist/crypto/provider.js +14 -0
  18. package/dist/crypto/provider.js.map +1 -0
  19. package/dist/crypto/request-id.d.ts +16 -0
  20. package/dist/crypto/request-id.d.ts.map +1 -0
  21. package/dist/crypto/request-id.js +59 -0
  22. package/dist/crypto/request-id.js.map +1 -0
  23. package/dist/crypto/signature.d.ts +40 -0
  24. package/dist/crypto/signature.d.ts.map +1 -0
  25. package/dist/crypto/signature.js +38 -0
  26. package/dist/crypto/signature.js.map +1 -0
  27. package/dist/crypto/token.d.ts +14 -0
  28. package/dist/crypto/token.d.ts.map +1 -0
  29. package/dist/crypto/token.js +82 -0
  30. package/dist/crypto/token.js.map +1 -0
  31. package/dist/crypto/web-provider.d.ts +18 -0
  32. package/dist/crypto/web-provider.d.ts.map +1 -0
  33. package/dist/crypto/web-provider.js +31 -0
  34. package/dist/crypto/web-provider.js.map +1 -0
  35. package/dist/errors.d.ts +75 -0
  36. package/dist/errors.d.ts.map +1 -0
  37. package/dist/errors.js +71 -0
  38. package/dist/errors.js.map +1 -0
  39. package/dist/generated/fault-codes.d.ts +1343 -0
  40. package/dist/generated/fault-codes.d.ts.map +1 -0
  41. package/dist/generated/fault-codes.js +1331 -0
  42. package/dist/generated/fault-codes.js.map +1 -0
  43. package/dist/generated/index.d.ts +4 -0
  44. package/dist/generated/index.d.ts.map +1 -0
  45. package/dist/generated/index.js +5 -0
  46. package/dist/generated/index.js.map +1 -0
  47. package/dist/generated/schema.d.ts +13 -0
  48. package/dist/generated/schema.d.ts.map +1 -0
  49. package/dist/generated/schema.js +3756 -0
  50. package/dist/generated/schema.js.map +1 -0
  51. package/dist/generated/types.d.ts +5580 -0
  52. package/dist/generated/types.d.ts.map +1 -0
  53. package/dist/generated/types.js +6 -0
  54. package/dist/generated/types.js.map +1 -0
  55. package/dist/index.d.ts +23 -0
  56. package/dist/index.d.ts.map +1 -0
  57. package/dist/index.js +23 -0
  58. package/dist/index.js.map +1 -0
  59. package/dist/invoice/payload.d.ts +40 -0
  60. package/dist/invoice/payload.d.ts.map +1 -0
  61. package/dist/invoice/payload.js +66 -0
  62. package/dist/invoice/payload.js.map +1 -0
  63. package/dist/money/decimal.d.ts +69 -0
  64. package/dist/money/decimal.d.ts.map +1 -0
  65. package/dist/money/decimal.js +190 -0
  66. package/dist/money/decimal.js.map +1 -0
  67. package/dist/money/summary.d.ts +60 -0
  68. package/dist/money/summary.d.ts.map +1 -0
  69. package/dist/money/summary.js +236 -0
  70. package/dist/money/summary.js.map +1 -0
  71. package/dist/time.d.ts +16 -0
  72. package/dist/time.d.ts.map +1 -0
  73. package/dist/time.js +41 -0
  74. package/dist/time.js.map +1 -0
  75. package/dist/validation/issue.d.ts +67 -0
  76. package/dist/validation/issue.d.ts.map +1 -0
  77. package/dist/validation/issue.js +40 -0
  78. package/dist/validation/issue.js.map +1 -0
  79. package/dist/validation/rules.d.ts +34 -0
  80. package/dist/validation/rules.d.ts.map +1 -0
  81. package/dist/validation/rules.js +290 -0
  82. package/dist/validation/rules.js.map +1 -0
  83. package/dist/validation/schema.d.ts +14 -0
  84. package/dist/validation/schema.d.ts.map +1 -0
  85. package/dist/validation/schema.js +231 -0
  86. package/dist/validation/schema.js.map +1 -0
  87. package/dist/validation/tax-number.d.ts +39 -0
  88. package/dist/validation/tax-number.d.ts.map +1 -0
  89. package/dist/validation/tax-number.js +79 -0
  90. package/dist/validation/tax-number.js.map +1 -0
  91. package/dist/validation/validate.d.ts +29 -0
  92. package/dist/validation/validate.d.ts.map +1 -0
  93. package/dist/validation/validate.js +33 -0
  94. package/dist/validation/validate.js.map +1 -0
  95. package/dist/xml/descriptor.d.ts +82 -0
  96. package/dist/xml/descriptor.d.ts.map +1 -0
  97. package/dist/xml/descriptor.js +32 -0
  98. package/dist/xml/descriptor.js.map +1 -0
  99. package/dist/xml/read.d.ts +25 -0
  100. package/dist/xml/read.d.ts.map +1 -0
  101. package/dist/xml/read.js +230 -0
  102. package/dist/xml/read.js.map +1 -0
  103. package/dist/xml/write.d.ts +28 -0
  104. package/dist/xml/write.d.ts.map +1 -0
  105. package/dist/xml/write.js +299 -0
  106. package/dist/xml/write.js.map +1 -0
  107. package/package.json +55 -0
  108. package/src/constants.ts +76 -0
  109. package/src/crypto/hash.ts +35 -0
  110. package/src/crypto/node-provider.ts +19 -0
  111. package/src/crypto/provider.ts +37 -0
  112. package/src/crypto/request-id.ts +63 -0
  113. package/src/crypto/signature.ts +58 -0
  114. package/src/crypto/token.ts +86 -0
  115. package/src/crypto/web-provider.ts +32 -0
  116. package/src/errors.ts +109 -0
  117. package/src/generated/fault-codes.ts +1338 -0
  118. package/src/generated/index.ts +4 -0
  119. package/src/generated/schema.ts +3760 -0
  120. package/src/generated/types.ts +5829 -0
  121. package/src/index.ts +22 -0
  122. package/src/invoice/payload.ts +102 -0
  123. package/src/money/decimal.ts +218 -0
  124. package/src/money/summary.ts +380 -0
  125. package/src/time.ts +44 -0
  126. package/src/validation/issue.ts +102 -0
  127. package/src/validation/rules.ts +548 -0
  128. package/src/validation/schema.ts +315 -0
  129. package/src/validation/tax-number.ts +96 -0
  130. package/src/validation/validate.ts +45 -0
  131. package/src/xml/descriptor.ts +112 -0
  132. package/src/xml/read.ts +303 -0
  133. package/src/xml/write.ts +391 -0
@@ -0,0 +1,303 @@
1
+ import { XMLParser } from 'fast-xml-parser';
2
+ import { NavValidationError, type ValidationIssue } from '../errors.js';
3
+ import { ROOTS, TYPES } from '../generated/schema.js';
4
+ import {
5
+ NAMESPACE_URIS,
6
+ XS_PRIMITIVES,
7
+ type ComplexDescriptor,
8
+ type NsKey,
9
+ type PrimitiveKind,
10
+ } from './descriptor.js';
11
+
12
+ const URI_TO_NS: Record<string, NsKey> = Object.fromEntries(
13
+ Object.entries(NAMESPACE_URIS).map(([key, uri]) => [uri, key as NsKey]),
14
+ ) as Record<string, NsKey>;
15
+
16
+ /** A node as produced by fast-xml-parser in `preserveOrder` mode. */
17
+ type OrderedNode = Record<string, unknown>;
18
+
19
+ const parser = new XMLParser({
20
+ preserveOrder: true,
21
+ ignoreAttributes: false,
22
+ attributeNamePrefix: '@',
23
+ trimValues: true,
24
+ // Values are converted according to the schema, not guessed from their
25
+ // shape: an invoice number of "2021000123" must stay a string.
26
+ parseTagValue: false,
27
+ parseAttributeValue: false,
28
+ });
29
+
30
+ export interface ParseOptions {
31
+ /**
32
+ * What to do with elements the schema does not declare.
33
+ *
34
+ * `error` (the default) surfaces them, which is what you want for documents
35
+ * you produced yourself. Use `ignore` when reading responses from a NAV
36
+ * deployment that may be ahead of the vendored schema.
37
+ */
38
+ unknownElements?: 'error' | 'ignore';
39
+ }
40
+
41
+ export interface ParsedDocument {
42
+ /** Root element name, e.g. `ManageInvoiceResponse`. */
43
+ root: string;
44
+ value: unknown;
45
+ }
46
+
47
+ /** Namespace prefixes in scope, inherited down the tree. */
48
+ class Scope {
49
+ constructor(
50
+ private readonly byPrefix: Map<string, string> = new Map(),
51
+ readonly defaultUri: string | undefined = undefined,
52
+ ) {}
53
+
54
+ extend(attributes: Record<string, string>): Scope {
55
+ let next: Map<string, string> | undefined;
56
+ let defaultUri = this.defaultUri;
57
+ for (const [name, value] of Object.entries(attributes)) {
58
+ if (name === '@xmlns') {
59
+ defaultUri = value;
60
+ } else if (name.startsWith('@xmlns:')) {
61
+ next ??= new Map(this.byPrefix);
62
+ next.set(name.slice('@xmlns:'.length), value);
63
+ }
64
+ }
65
+ if (!next && defaultUri === this.defaultUri) return this;
66
+ return new Scope(next ?? this.byPrefix, defaultUri);
67
+ }
68
+
69
+ resolve(prefix: string): string | undefined {
70
+ return prefix === '' ? this.defaultUri : this.byPrefix.get(prefix);
71
+ }
72
+ }
73
+
74
+ function tagOf(node: OrderedNode): string {
75
+ const tag = Object.keys(node).find((key) => key !== ':@');
76
+ if (tag === undefined) throw new NavValidationError('Malformed XML node');
77
+ return tag;
78
+ }
79
+
80
+ function childrenOf(node: OrderedNode): OrderedNode[] {
81
+ const value = node[tagOf(node)];
82
+ return Array.isArray(value) ? (value as OrderedNode[]) : [];
83
+ }
84
+
85
+ function attrsOf(node: OrderedNode): Record<string, string> {
86
+ return (node[':@'] as Record<string, string> | undefined) ?? {};
87
+ }
88
+
89
+ function textOf(node: OrderedNode): string {
90
+ return childrenOf(node)
91
+ .filter((child) => tagOf(child) === '#text')
92
+ .map((child) => String(child['#text']))
93
+ .join('');
94
+ }
95
+
96
+ function elementChildren(node: OrderedNode): OrderedNode[] {
97
+ return childrenOf(node).filter((child) => {
98
+ const tag = tagOf(child);
99
+ return tag !== '#text' && tag !== '#comment' && !tag.startsWith('?');
100
+ });
101
+ }
102
+
103
+ function splitName(qname: string): { prefix: string; local: string } {
104
+ const index = qname.indexOf(':');
105
+ return index === -1
106
+ ? { prefix: '', local: qname }
107
+ : { prefix: qname.slice(0, index), local: qname.slice(index + 1) };
108
+ }
109
+
110
+ /**
111
+ * Parse a NAV document into the shape of its generated type.
112
+ *
113
+ * The root element name decides which descriptor is used, so responses and
114
+ * invoice payloads both go through the same path.
115
+ */
116
+ export function parseDocument(xml: string, options: ParseOptions = {}): ParsedDocument {
117
+ const document = parser.parse(xml) as OrderedNode[];
118
+ const rootNode = document.find((node) => {
119
+ const tag = tagOf(node);
120
+ return tag !== '#text' && tag !== '#comment' && !tag.startsWith('?');
121
+ });
122
+ if (!rootNode) {
123
+ throw new NavValidationError('No root element found in document');
124
+ }
125
+
126
+ const { local } = splitName(tagOf(rootNode));
127
+ const root = ROOTS[local];
128
+ if (!root) {
129
+ throw new NavValidationError(`Unknown document root ${local}`, [
130
+ {
131
+ path: '',
132
+ code: 'UNKNOWN_ROOT',
133
+ message: `expected one of ${Object.keys(ROOTS).join(', ')}`,
134
+ },
135
+ ]);
136
+ }
137
+
138
+ const issues: ValidationIssue[] = [];
139
+ const scope = new Scope().extend(attrsOf(rootNode));
140
+ const value = readComplex(rootNode, root.type, scope, local, issues, options);
141
+ if (issues.length > 0) {
142
+ throw new NavValidationError(`Cannot parse ${local}`, issues);
143
+ }
144
+
145
+ return { root: local, value };
146
+ }
147
+
148
+ /** Parse a document whose root is known, returning the generated type. */
149
+ export function parseDocumentAs<T>(xml: string, rootName: string, options?: ParseOptions): T {
150
+ const parsed = parseDocument(xml, options);
151
+ if (parsed.root !== rootName) {
152
+ throw new NavValidationError(`Expected a ${rootName} document but found ${parsed.root}`);
153
+ }
154
+ return parsed.value as T;
155
+ }
156
+
157
+ function readComplex(
158
+ node: OrderedNode,
159
+ typeKey: string,
160
+ scope: Scope,
161
+ path: string,
162
+ issues: ValidationIssue[],
163
+ options: ParseOptions,
164
+ ): unknown {
165
+ const descriptor = TYPES[typeKey];
166
+ if (!descriptor) {
167
+ issues.push({
168
+ path,
169
+ code: 'UNKNOWN_TYPE',
170
+ message: `${typeKey} is not in the generated schema`,
171
+ });
172
+ return undefined;
173
+ }
174
+
175
+ if (descriptor.kind === 'simple') {
176
+ return readPrimitive(descriptor.primitive, textOf(node), path, issues);
177
+ }
178
+
179
+ if (descriptor.content === 'simple') {
180
+ return readSimpleContent(node, descriptor, path, issues);
181
+ }
182
+
183
+ const result: Record<string, unknown> = {};
184
+ const localScope = scope.extend(attrsOf(node));
185
+
186
+ for (const child of elementChildren(node)) {
187
+ const { prefix, local } = splitName(tagOf(child));
188
+ const childScope = localScope.extend(attrsOf(child));
189
+ const uri = childScope.resolve(prefix);
190
+ const ns = uri === undefined ? undefined : URI_TO_NS[uri];
191
+
192
+ const field = descriptor.fields.find(
193
+ (candidate) => candidate.name === local && (ns === undefined || candidate.ns === ns),
194
+ );
195
+ if (!field) {
196
+ if (options.unknownElements !== 'ignore') {
197
+ issues.push({
198
+ path: path ? `${path}.${local}` : local,
199
+ code: 'UNKNOWN_ELEMENT',
200
+ message: `is not declared on ${descriptor.name}`,
201
+ });
202
+ }
203
+ continue;
204
+ }
205
+
206
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
207
+ const primitive = XS_PRIMITIVES[field.type];
208
+ const value = primitive
209
+ ? readPrimitive(primitive, textOf(child), fieldPath, issues)
210
+ : readComplex(child, field.type, childScope, fieldPath, issues, options);
211
+
212
+ if (field.repeated) {
213
+ result[field.name] ??= [] as unknown[];
214
+ (result[field.name] as unknown[]).push(value);
215
+ } else {
216
+ result[field.name] = value;
217
+ }
218
+ }
219
+
220
+ return result;
221
+ }
222
+
223
+ function readSimpleContent(
224
+ node: OrderedNode,
225
+ descriptor: ComplexDescriptor,
226
+ path: string,
227
+ issues: ValidationIssue[],
228
+ ): unknown {
229
+ const contentDescriptor = descriptor.contentType ? TYPES[descriptor.contentType] : undefined;
230
+ const kind: PrimitiveKind =
231
+ XS_PRIMITIVES[descriptor.contentType ?? ''] ??
232
+ (contentDescriptor?.kind === 'simple' ? contentDescriptor.primitive : 'string');
233
+
234
+ const result: Record<string, unknown> = {
235
+ value: readPrimitive(kind, textOf(node), `${path}.value`, issues),
236
+ };
237
+
238
+ const attributes = attrsOf(node);
239
+ for (const attribute of descriptor.attributes ?? []) {
240
+ const raw = attributes[`@${attribute.name}`];
241
+ if (raw === undefined) {
242
+ if (attribute.required) {
243
+ issues.push({
244
+ path: `${path}@${attribute.name}`,
245
+ code: 'REQUIRED',
246
+ message: 'attribute is required',
247
+ });
248
+ }
249
+ continue;
250
+ }
251
+ const attributeDescriptor = TYPES[attribute.type];
252
+ const attributeKind: PrimitiveKind =
253
+ XS_PRIMITIVES[attribute.type] ??
254
+ (attributeDescriptor?.kind === 'simple' ? attributeDescriptor.primitive : 'string');
255
+ result[attribute.name] = readPrimitive(attributeKind, raw, `${path}@${attribute.name}`, issues);
256
+ }
257
+
258
+ return result;
259
+ }
260
+
261
+ function readPrimitive(
262
+ kind: PrimitiveKind,
263
+ raw: string,
264
+ path: string,
265
+ issues: ValidationIssue[],
266
+ ): unknown {
267
+ switch (kind) {
268
+ case 'boolean':
269
+ if (raw === 'true' || raw === '1') return true;
270
+ if (raw === 'false' || raw === '0') return false;
271
+ issues.push({ path, code: 'EXPECTED_BOOLEAN', message: `expected a boolean, got "${raw}"` });
272
+ return undefined;
273
+
274
+ case 'integer': {
275
+ if (!/^-?\d+$/.test(raw)) {
276
+ issues.push({
277
+ path,
278
+ code: 'EXPECTED_INTEGER',
279
+ message: `expected an integer, got "${raw}"`,
280
+ });
281
+ return undefined;
282
+ }
283
+ const parsed = Number(raw);
284
+ if (!Number.isSafeInteger(parsed)) {
285
+ issues.push({
286
+ path,
287
+ code: 'INTEGER_OUT_OF_RANGE',
288
+ message: `${raw} exceeds the safe integer range`,
289
+ });
290
+ return undefined;
291
+ }
292
+ return parsed;
293
+ }
294
+
295
+ case 'base64Binary':
296
+ // XML permits whitespace inside base64; NAV's own samples wrap it.
297
+ return raw.replace(/\s+/g, '');
298
+
299
+ default:
300
+ // Decimals stay strings deliberately, see the generator's TS_PRIMITIVES.
301
+ return raw;
302
+ }
303
+ }
@@ -0,0 +1,391 @@
1
+ import { NavValidationError, type ValidationIssue } from '../errors.js';
2
+ import { ROOTS, TYPES } from '../generated/schema.js';
3
+ import {
4
+ NAMESPACE_URIS,
5
+ XS_PRIMITIVES,
6
+ type ComplexDescriptor,
7
+ type Descriptor,
8
+ type NsKey,
9
+ type PrimitiveKind,
10
+ } from './descriptor.js';
11
+
12
+ /** Prefixes used when a namespace is not the document's default. */
13
+ export const NS_PREFIXES: Record<NsKey, string> = {
14
+ common: 'common',
15
+ base: 'base',
16
+ data: 'data',
17
+ api: 'api',
18
+ annul: 'annul',
19
+ metrics: 'metrics',
20
+ };
21
+
22
+ export interface SerializeOptions {
23
+ /**
24
+ * Indentation for pretty printing, or `false` for a single line.
25
+ *
26
+ * Defaults to `false`: these documents go over the wire, and for
27
+ * `manageInvoice` the invoice payload is hashed and base64 encoded, so
28
+ * there is nothing to be gained from whitespace. Pass `'\t'` or two spaces
29
+ * when a human has to read the result.
30
+ */
31
+ indent?: string | false;
32
+ /** Emit the XML declaration. Defaults to `true`. */
33
+ declaration?: boolean;
34
+ }
35
+
36
+ /** Intermediate tree, built before rendering so used namespaces are known. */
37
+ interface Element {
38
+ ns: NsKey;
39
+ name: string;
40
+ attributes: Array<[string, string]>;
41
+ text?: string;
42
+ children: Element[];
43
+ }
44
+
45
+ /**
46
+ * Serialise a document from its root element name.
47
+ *
48
+ * Element order, namespaces and cardinality all come from the generated
49
+ * descriptors, so the output follows the XSD's sequence order — which is what
50
+ * NAV validates against.
51
+ *
52
+ * @param rootName root element name, e.g. `InvoiceData` or `ManageInvoiceRequest`
53
+ * @param value the document, shaped like the generated type of that root
54
+ */
55
+ export function serializeDocument(
56
+ rootName: string,
57
+ value: unknown,
58
+ options: SerializeOptions = {},
59
+ ): string {
60
+ const root = ROOTS[rootName];
61
+ if (!root) {
62
+ throw new NavValidationError(`Unknown document root ${rootName}`, [
63
+ {
64
+ path: '',
65
+ code: 'UNKNOWN_ROOT',
66
+ message: `expected one of ${Object.keys(ROOTS).join(', ')}`,
67
+ },
68
+ ]);
69
+ }
70
+
71
+ const issues: ValidationIssue[] = [];
72
+ const element = buildElement(root.name, root.ns, root.type, value, rootName, issues);
73
+ if (issues.length > 0) {
74
+ throw new NavValidationError(`Cannot serialise ${rootName}`, issues);
75
+ }
76
+
77
+ const used = new Set<NsKey>();
78
+ collectNamespaces(element, used);
79
+ used.delete(root.ns);
80
+
81
+ const declarations: Array<[string, string]> = [
82
+ ...[...used]
83
+ .sort()
84
+ .map((ns): [string, string] => [`xmlns:${NS_PREFIXES[ns]}`, NAMESPACE_URIS[ns]]),
85
+ ['xmlns', NAMESPACE_URIS[root.ns]],
86
+ ];
87
+
88
+ const indent = options.indent ?? false;
89
+ const body = render(element, root.ns, declarations, indent, 0);
90
+ const declaration = options.declaration === false ? '' : '<?xml version="1.0" encoding="UTF-8"?>';
91
+ if (!declaration) return body;
92
+ return indent === false ? `${declaration}${body}` : `${declaration}\n${body}`;
93
+ }
94
+
95
+ function collectNamespaces(element: Element, used: Set<NsKey>): void {
96
+ used.add(element.ns);
97
+ for (const child of element.children) collectNamespaces(child, used);
98
+ }
99
+
100
+ function descriptorFor(typeKey: string, path: string): Descriptor {
101
+ const descriptor = TYPES[typeKey];
102
+ if (!descriptor) {
103
+ throw new NavValidationError(`Unknown type ${typeKey}`, [
104
+ { path, code: 'UNKNOWN_TYPE', message: `not present in the generated schema` },
105
+ ]);
106
+ }
107
+ return descriptor;
108
+ }
109
+
110
+ function buildElement(
111
+ name: string,
112
+ ns: NsKey,
113
+ typeKey: string,
114
+ value: unknown,
115
+ path: string,
116
+ issues: ValidationIssue[],
117
+ ): Element {
118
+ const primitive = XS_PRIMITIVES[typeKey];
119
+ if (primitive) {
120
+ return {
121
+ ns,
122
+ name,
123
+ attributes: [],
124
+ text: formatPrimitive(primitive, value, path, issues),
125
+ children: [],
126
+ };
127
+ }
128
+
129
+ const descriptor = descriptorFor(typeKey, path);
130
+
131
+ if (descriptor.kind === 'simple') {
132
+ return {
133
+ ns,
134
+ name,
135
+ attributes: [],
136
+ text: formatPrimitive(descriptor.primitive, value, path, issues),
137
+ children: [],
138
+ };
139
+ }
140
+
141
+ if (descriptor.content === 'simple') {
142
+ return buildSimpleContentElement(name, ns, descriptor, value, path, issues);
143
+ }
144
+
145
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
146
+ issues.push({
147
+ path,
148
+ code: 'EXPECTED_OBJECT',
149
+ message: `expected an object, got ${describe(value)}`,
150
+ });
151
+ return { ns, name, attributes: [], children: [] };
152
+ }
153
+
154
+ const record = value as Record<string, unknown>;
155
+ const children: Element[] = [];
156
+
157
+ for (const field of descriptor.fields) {
158
+ const fieldValue = record[field.name];
159
+ const fieldPath = path ? `${path}.${field.name}` : field.name;
160
+
161
+ if (fieldValue === undefined || fieldValue === null) {
162
+ if (!field.optional) {
163
+ issues.push({ path: fieldPath, code: 'REQUIRED', message: 'is required' });
164
+ }
165
+ continue;
166
+ }
167
+
168
+ if (field.repeated) {
169
+ if (!Array.isArray(fieldValue)) {
170
+ issues.push({
171
+ path: fieldPath,
172
+ code: 'EXPECTED_ARRAY',
173
+ message: `is repeatable and expects an array, got ${describe(fieldValue)}`,
174
+ });
175
+ continue;
176
+ }
177
+ if (field.maxOccurs !== undefined && fieldValue.length > field.maxOccurs) {
178
+ issues.push({
179
+ path: fieldPath,
180
+ code: 'MAX_OCCURS',
181
+ message: `allows at most ${field.maxOccurs} entries, got ${fieldValue.length}`,
182
+ });
183
+ }
184
+ for (const [index, item] of fieldValue.entries()) {
185
+ children.push(
186
+ buildElement(field.name, field.ns, field.type, item, `${fieldPath}.${index}`, issues),
187
+ );
188
+ }
189
+ continue;
190
+ }
191
+
192
+ if (Array.isArray(fieldValue)) {
193
+ issues.push({
194
+ path: fieldPath,
195
+ code: 'UNEXPECTED_ARRAY',
196
+ message: 'occurs at most once and does not accept an array',
197
+ });
198
+ continue;
199
+ }
200
+
201
+ children.push(buildElement(field.name, field.ns, field.type, fieldValue, fieldPath, issues));
202
+ }
203
+
204
+ const unknown = Object.keys(record).filter(
205
+ (key) => !descriptor.fields.some((field) => field.name === key),
206
+ );
207
+ for (const key of unknown) {
208
+ issues.push({
209
+ path: path ? `${path}.${key}` : key,
210
+ code: 'UNKNOWN_FIELD',
211
+ message: `is not part of ${descriptor.name}`,
212
+ });
213
+ }
214
+
215
+ return { ns, name, attributes: [], children };
216
+ }
217
+
218
+ function buildSimpleContentElement(
219
+ name: string,
220
+ ns: NsKey,
221
+ descriptor: ComplexDescriptor,
222
+ value: unknown,
223
+ path: string,
224
+ issues: ValidationIssue[],
225
+ ): Element {
226
+ if (typeof value !== 'object' || value === null) {
227
+ issues.push({
228
+ path,
229
+ code: 'EXPECTED_OBJECT',
230
+ message: `expected an object with a value and attributes, got ${describe(value)}`,
231
+ });
232
+ return { ns, name, attributes: [], children: [] };
233
+ }
234
+
235
+ const record = value as Record<string, unknown>;
236
+ const contentKind = primitiveOf(descriptor.contentType, path);
237
+ const attributes: Array<[string, string]> = [];
238
+
239
+ for (const attribute of descriptor.attributes ?? []) {
240
+ const attributeValue = record[attribute.name];
241
+ if (attributeValue === undefined || attributeValue === null) {
242
+ if (attribute.required) {
243
+ issues.push({
244
+ path: `${path}@${attribute.name}`,
245
+ code: 'REQUIRED',
246
+ message: 'attribute is required',
247
+ });
248
+ }
249
+ continue;
250
+ }
251
+ attributes.push([
252
+ attribute.name,
253
+ formatPrimitive(
254
+ primitiveOf(attribute.type, path),
255
+ attributeValue,
256
+ `${path}@${attribute.name}`,
257
+ issues,
258
+ ),
259
+ ]);
260
+ }
261
+
262
+ return {
263
+ ns,
264
+ name,
265
+ attributes,
266
+ text: formatPrimitive(contentKind, record['value'], `${path}.value`, issues),
267
+ children: [],
268
+ };
269
+ }
270
+
271
+ function primitiveOf(typeKey: string | undefined, path: string): PrimitiveKind {
272
+ if (!typeKey) {
273
+ throw new NavValidationError('Missing type', [
274
+ { path, code: 'UNKNOWN_TYPE', message: 'simple content without a declared type' },
275
+ ]);
276
+ }
277
+ const primitive = XS_PRIMITIVES[typeKey];
278
+ if (primitive) return primitive;
279
+ const descriptor = descriptorFor(typeKey, path);
280
+ if (descriptor.kind !== 'simple') {
281
+ throw new NavValidationError('Unexpected complex type', [
282
+ { path, code: 'UNKNOWN_TYPE', message: `${typeKey} is not a simple type` },
283
+ ]);
284
+ }
285
+ return descriptor.primitive;
286
+ }
287
+
288
+ function formatPrimitive(
289
+ kind: PrimitiveKind,
290
+ value: unknown,
291
+ path: string,
292
+ issues: ValidationIssue[],
293
+ ): string {
294
+ switch (kind) {
295
+ case 'boolean':
296
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
297
+ if (value === 'true' || value === 'false') return value;
298
+ issues.push({
299
+ path,
300
+ code: 'EXPECTED_BOOLEAN',
301
+ message: `expected a boolean, got ${describe(value)}`,
302
+ });
303
+ return '';
304
+
305
+ case 'integer':
306
+ if (typeof value === 'number') {
307
+ if (!Number.isInteger(value)) {
308
+ issues.push({
309
+ path,
310
+ code: 'EXPECTED_INTEGER',
311
+ message: `expected an integer, got ${value}`,
312
+ });
313
+ return '';
314
+ }
315
+ return String(value);
316
+ }
317
+ if (typeof value === 'string' && /^-?\d+$/.test(value)) return value;
318
+ issues.push({
319
+ path,
320
+ code: 'EXPECTED_INTEGER',
321
+ message: `expected an integer, got ${describe(value)}`,
322
+ });
323
+ return '';
324
+
325
+ case 'decimal':
326
+ // Strings are the intended representation: they survive 18 significant
327
+ // digits and rates like 0.27 without a floating point detour.
328
+ if (typeof value === 'string' && /^-?\d+(\.\d+)?$/.test(value)) return value;
329
+ if (typeof value === 'number' && Number.isFinite(value)) return String(value);
330
+ issues.push({
331
+ path,
332
+ code: 'EXPECTED_DECIMAL',
333
+ message: `expected a decimal, got ${describe(value)}`,
334
+ });
335
+ return '';
336
+
337
+ default:
338
+ if (typeof value === 'string') return value;
339
+ if (typeof value === 'number' || typeof value === 'boolean') return String(value);
340
+ issues.push({
341
+ path,
342
+ code: 'EXPECTED_STRING',
343
+ message: `expected a string, got ${describe(value)}`,
344
+ });
345
+ return '';
346
+ }
347
+ }
348
+
349
+ function describe(value: unknown): string {
350
+ if (value === null) return 'null';
351
+ if (Array.isArray(value)) return 'an array';
352
+ return typeof value;
353
+ }
354
+
355
+ function render(
356
+ element: Element,
357
+ defaultNs: NsKey,
358
+ extraAttributes: Array<[string, string]>,
359
+ indent: string | false,
360
+ depth: number,
361
+ ): string {
362
+ const tag =
363
+ element.ns === defaultNs ? element.name : `${NS_PREFIXES[element.ns]}:${element.name}`;
364
+ const attributes = [...element.attributes, ...extraAttributes]
365
+ .map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`)
366
+ .join('');
367
+
368
+ const pad = indent === false ? '' : indent.repeat(depth);
369
+ const newline = indent === false ? '' : '\n';
370
+
371
+ if (element.children.length === 0) {
372
+ const text = element.text ?? '';
373
+ if (text === '') return `${pad}<${tag}${attributes}/>`;
374
+ return `${pad}<${tag}${attributes}>${escapeText(text)}</${tag}>`;
375
+ }
376
+
377
+ const children = element.children
378
+ .map((child) => render(child, defaultNs, [], indent, depth + 1))
379
+ .join(newline);
380
+ return `${pad}<${tag}${attributes}>${newline}${children}${newline}${pad}</${tag}>`;
381
+ }
382
+
383
+ function escapeText(value: string): string {
384
+ return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
385
+ }
386
+
387
+ function escapeAttribute(value: string): string {
388
+ return escapeText(value)
389
+ .replace(/"/g, '&quot;')
390
+ .replace(/[\n\r\t]/g, (char) => (char === '\n' ? '&#10;' : char === '\r' ? '&#13;' : '&#9;'));
391
+ }