ng-form-foundry-transformers 0.2.1 → 0.3.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/README.md +20 -4
- package/dist/core/bigint.d.ts +17 -0
- package/dist/core/bigint.js +30 -0
- package/dist/core/index.d.ts +2 -2
- package/dist/core/json-schema.d.ts +48 -9
- package/dist/core/json-schema.js +229 -16
- package/dist/core/schema.d.ts +48 -3
- package/dist/transformers/json/json-transformer.d.ts +10 -0
- package/dist/transformers/json/json-transformer.js +92 -2
- package/dist/transformers/yaml/revert.d.ts +5 -0
- package/dist/transformers/yaml/revert.js +73 -37
- package/dist/transformers/yaml/yaml-transformer.js +24 -2
- package/dist/transformers/yang/mapper.js +2 -1
- package/dist/transformers/yang/revert.js +1 -1
- package/dist/transformers/yang/rfc7951.d.ts +23 -4
- package/dist/transformers/yang/rfc7951.js +45 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,9 +69,14 @@ const out = yamlTransformer.toSource({ ...initialValue, port: 9090 }, binding);
|
|
|
69
69
|
// port: 9090
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
With a JSON Schema: `yamlTransformer.toSchema(yaml, { schema })
|
|
73
|
-
|
|
74
|
-
`
|
|
72
|
+
With a JSON Schema: `yamlTransformer.toSchema(yaml, { schema })`. The mapping
|
|
73
|
+
covers **draft 2020-12** (back-compatible with draft-07): `object` → nodeGroup (or
|
|
74
|
+
a `map` for `additionalProperties`/`patternProperties`), `array` → nodeGroupList /
|
|
75
|
+
leafList, `anyOf`/`oneOf` → choice (or a nullable leaf for `[T, null]`), `$ref` →
|
|
76
|
+
`$defs`/`definitions` resolved inline (local **or cross-file** via
|
|
77
|
+
`options.refDocuments`, matched by `$id`), `const` → a read-only leaf, and the string /
|
|
78
|
+
number constraints (`pattern`, `minLength`, `minimum`, `multipleOf`, `format`, …)
|
|
79
|
+
carried onto the leaves as validators.
|
|
75
80
|
|
|
76
81
|
## JSON transformer
|
|
77
82
|
|
|
@@ -176,10 +181,21 @@ npm test # node:test on the compiled output (no Python needed)
|
|
|
176
181
|
|
|
177
182
|
## Status
|
|
178
183
|
|
|
184
|
+
`0.3.0` — data-integrity release plus JSON Schema draft 2020-12. Fixes three
|
|
185
|
+
round-trip corruption bugs shipped in 0.2.1 (YAML maps with non-string keys
|
|
186
|
+
duplicated entries; integers beyond 2^53 lost precision — now carried
|
|
187
|
+
losslessly as strings, mirroring the YANG uint64 strategy; YANG identityref
|
|
188
|
+
resolved to the wrong module on a local-name collision). `jsonSchemaToNodeGroup`
|
|
189
|
+
grew from a draft-07 subset to draft 2020-12: `$ref`/`$defs`, cross-file `$ref`
|
|
190
|
+
by `$id` (`refDocuments` option), `anyOf`/`oneOf` → choice, `type: [T, 'null']`
|
|
191
|
+
→ nullable, `const` → read-only, `additionalProperties`/`patternProperties` →
|
|
192
|
+
map, and string/number constraints as validators.
|
|
193
|
+
|
|
179
194
|
`0.2.1` — first release under this name (formerly `yang-form-foundry`),
|
|
180
195
|
restructured into a transformer catalog. YANG, YAML, and JSON transformers are
|
|
181
196
|
complete; YAML and JSON share the format-agnostic form builders in `core` and are
|
|
182
|
-
JSON-Schema-driven or inferred.
|
|
197
|
+
JSON-Schema-driven or inferred. **Deprecated: contains the round-trip corruption
|
|
198
|
+
bugs fixed in 0.3.0.**
|
|
183
199
|
|
|
184
200
|
## License
|
|
185
201
|
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Big-integer handling shared by the data transformers.
|
|
3
|
+
*
|
|
4
|
+
* JSON and YAML both write integers as bare numeric literals, but a JavaScript
|
|
5
|
+
* `number` only holds integers up to 2^53 − 1 exactly — parse `9007199254740993`
|
|
6
|
+
* and you get `…992` back. To avoid silently corrupting such values, the
|
|
7
|
+
* transformers carry any out-of-range integer as a **string** in the form value
|
|
8
|
+
* (the same strategy the YANG adapter uses for `int64`/`uint64`/`decimal64`) and
|
|
9
|
+
* re-emit it verbatim as an unquoted number. These helpers are the shared
|
|
10
|
+
* predicates for that path.
|
|
11
|
+
*/
|
|
12
|
+
/** A string that is a base-10 integer literal (optionally signed), safe or not. */
|
|
13
|
+
export declare function isIntegerString(s: string): boolean;
|
|
14
|
+
/** A BigInt whose magnitude exceeds the safe range, so a `number` can't hold it. */
|
|
15
|
+
export declare function isUnsafeBigInt(b: bigint): boolean;
|
|
16
|
+
/** An integer literal whose magnitude a `number` can't hold without loss. */
|
|
17
|
+
export declare function isUnsafeIntegerString(s: string): boolean;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Big-integer handling shared by the data transformers.
|
|
4
|
+
*
|
|
5
|
+
* JSON and YAML both write integers as bare numeric literals, but a JavaScript
|
|
6
|
+
* `number` only holds integers up to 2^53 − 1 exactly — parse `9007199254740993`
|
|
7
|
+
* and you get `…992` back. To avoid silently corrupting such values, the
|
|
8
|
+
* transformers carry any out-of-range integer as a **string** in the form value
|
|
9
|
+
* (the same strategy the YANG adapter uses for `int64`/`uint64`/`decimal64`) and
|
|
10
|
+
* re-emit it verbatim as an unquoted number. These helpers are the shared
|
|
11
|
+
* predicates for that path.
|
|
12
|
+
*/
|
|
13
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
14
|
+
exports.isIntegerString = isIntegerString;
|
|
15
|
+
exports.isUnsafeBigInt = isUnsafeBigInt;
|
|
16
|
+
exports.isUnsafeIntegerString = isUnsafeIntegerString;
|
|
17
|
+
/** Largest integer a `number` represents exactly (2^53 − 1), as a BigInt. */
|
|
18
|
+
const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
|
|
19
|
+
/** A string that is a base-10 integer literal (optionally signed), safe or not. */
|
|
20
|
+
function isIntegerString(s) {
|
|
21
|
+
return /^-?\d+$/.test(s);
|
|
22
|
+
}
|
|
23
|
+
/** A BigInt whose magnitude exceeds the safe range, so a `number` can't hold it. */
|
|
24
|
+
function isUnsafeBigInt(b) {
|
|
25
|
+
return (b < 0n ? -b : b) > MAX_SAFE;
|
|
26
|
+
}
|
|
27
|
+
/** An integer literal whose magnitude a `number` can't hold without loss. */
|
|
28
|
+
function isUnsafeIntegerString(s) {
|
|
29
|
+
return isIntegerString(s) && isUnsafeBigInt(BigInt(s));
|
|
30
|
+
}
|
package/dist/core/index.d.ts
CHANGED
|
@@ -9,6 +9,6 @@ export type { Transformer, TransformResult, BindingMap } from './transformer';
|
|
|
9
9
|
export { TransformerRegistry } from './registry';
|
|
10
10
|
export { inferNodeGroup } from './infer';
|
|
11
11
|
export { jsonSchemaToNodeGroup } from './json-schema';
|
|
12
|
-
export type { JsonSchema } from './json-schema';
|
|
13
|
-
export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, LeafType, FormValue, } from './schema';
|
|
12
|
+
export type { JsonSchema, JsonSchemaOptions } from './json-schema';
|
|
13
|
+
export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, ChoiceCase, NodeMap, LeafType, FormValue, } from './schema';
|
|
14
14
|
export { CASE_KEY } from './schema';
|
|
@@ -1,26 +1,65 @@
|
|
|
1
1
|
import type { NodeGroup } from './schema';
|
|
2
2
|
/**
|
|
3
|
-
* The subset of JSON Schema (draft-07
|
|
4
|
-
* Only the keywords that shape a form are read;
|
|
5
|
-
*
|
|
6
|
-
*
|
|
3
|
+
* The subset of JSON Schema (draft 2020-12, back-compatible with draft-07) this
|
|
4
|
+
* transformer maps to a form. Only the keywords that shape a form are read;
|
|
5
|
+
* anything else is ignored. The mapping drives *structure, types, and
|
|
6
|
+
* constraints*; the library turns the constraints into validators, but no
|
|
7
|
+
* validation happens here.
|
|
7
8
|
*/
|
|
8
9
|
export interface JsonSchema {
|
|
10
|
+
$schema?: string;
|
|
11
|
+
$id?: string;
|
|
12
|
+
$ref?: string;
|
|
13
|
+
/** Draft 2020-12 reusable subschemas (draft-07 used `definitions`). */
|
|
14
|
+
$defs?: Record<string, JsonSchema>;
|
|
15
|
+
definitions?: Record<string, JsonSchema>;
|
|
9
16
|
type?: JsonSchemaType | JsonSchemaType[];
|
|
10
17
|
properties?: Record<string, JsonSchema>;
|
|
11
18
|
required?: string[];
|
|
19
|
+
additionalProperties?: boolean | JsonSchema;
|
|
20
|
+
patternProperties?: Record<string, JsonSchema>;
|
|
12
21
|
items?: JsonSchema;
|
|
22
|
+
anyOf?: JsonSchema[];
|
|
23
|
+
oneOf?: JsonSchema[];
|
|
13
24
|
enum?: unknown[];
|
|
25
|
+
const?: unknown;
|
|
14
26
|
title?: string;
|
|
27
|
+
description?: string;
|
|
15
28
|
default?: unknown;
|
|
29
|
+
pattern?: string;
|
|
30
|
+
minLength?: number;
|
|
31
|
+
maxLength?: number;
|
|
32
|
+
format?: string;
|
|
33
|
+
minimum?: number;
|
|
34
|
+
maximum?: number;
|
|
35
|
+
multipleOf?: number;
|
|
36
|
+
minProperties?: number;
|
|
37
|
+
maxProperties?: number;
|
|
38
|
+
minItems?: number;
|
|
39
|
+
maxItems?: number;
|
|
40
|
+
}
|
|
41
|
+
/** Options for {@link jsonSchemaToNodeGroup}. */
|
|
42
|
+
export interface JsonSchemaOptions {
|
|
43
|
+
/**
|
|
44
|
+
* Other schema documents a `$ref` may point at, matched by their `$id`. A
|
|
45
|
+
* cross-file ref like `/jsonschemas/common#/$defs/UeId` resolves into the
|
|
46
|
+
* document whose `$id` ends with `/jsonschemas/common`; refs *within* that
|
|
47
|
+
* document then resolve against it.
|
|
48
|
+
*/
|
|
49
|
+
refDocuments?: JsonSchema[];
|
|
16
50
|
}
|
|
17
51
|
type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';
|
|
18
52
|
/**
|
|
19
53
|
* Map a JSON Schema whose top level is an object into a root {@link NodeGroup}.
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
54
|
+
*
|
|
55
|
+
* Supports draft 2020-12: local and cross-document `$ref` (see
|
|
56
|
+
* {@link JsonSchemaOptions.refDocuments}) are inline-resolved; `object` →
|
|
57
|
+
* nodeGroup (or a {@link NodeMap} when the keys are open via
|
|
58
|
+
* `additionalProperties`/`patternProperties`); `anyOf`/`oneOf` → a {@link Choice}
|
|
59
|
+
* (or a nullable leaf for the `[T, null]` shape); `array` → nodeGroupList or
|
|
60
|
+
* leafList; a scalar (with `enum`, `const`, `type: [T, 'null']`, and the string /
|
|
61
|
+
* number constraints) → a typed leaf. `required` marks child leaves, `title`
|
|
62
|
+
* becomes the label, `default`/`const` carry a scalar value.
|
|
24
63
|
*/
|
|
25
|
-
export declare function jsonSchemaToNodeGroup(schema: JsonSchema, name?: string): NodeGroup;
|
|
64
|
+
export declare function jsonSchemaToNodeGroup(schema: JsonSchema, name?: string, options?: JsonSchemaOptions): NodeGroup;
|
|
26
65
|
export {};
|
package/dist/core/json-schema.js
CHANGED
|
@@ -4,54 +4,212 @@ exports.jsonSchemaToNodeGroup = jsonSchemaToNodeGroup;
|
|
|
4
4
|
const ROOT = '__root__';
|
|
5
5
|
/**
|
|
6
6
|
* Map a JSON Schema whose top level is an object into a root {@link NodeGroup}.
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
7
|
+
*
|
|
8
|
+
* Supports draft 2020-12: local and cross-document `$ref` (see
|
|
9
|
+
* {@link JsonSchemaOptions.refDocuments}) are inline-resolved; `object` →
|
|
10
|
+
* nodeGroup (or a {@link NodeMap} when the keys are open via
|
|
11
|
+
* `additionalProperties`/`patternProperties`); `anyOf`/`oneOf` → a {@link Choice}
|
|
12
|
+
* (or a nullable leaf for the `[T, null]` shape); `array` → nodeGroupList or
|
|
13
|
+
* leafList; a scalar (with `enum`, `const`, `type: [T, 'null']`, and the string /
|
|
14
|
+
* number constraints) → a typed leaf. `required` marks child leaves, `title`
|
|
15
|
+
* becomes the label, `default`/`const` carry a scalar value.
|
|
11
16
|
*/
|
|
12
|
-
function jsonSchemaToNodeGroup(schema, name = ROOT) {
|
|
13
|
-
const
|
|
17
|
+
function jsonSchemaToNodeGroup(schema, name = ROOT, options) {
|
|
18
|
+
const documents = [schema, ...(options?.refDocuments ?? [])];
|
|
19
|
+
const resolver = new RefResolver(schema, documents);
|
|
20
|
+
const { schema: root, scope } = resolver.resolve(schema);
|
|
21
|
+
const group = objectToNodeGroup(root, name, scope, root.title);
|
|
14
22
|
group.root = true;
|
|
15
23
|
return group;
|
|
16
24
|
}
|
|
17
|
-
|
|
25
|
+
/**
|
|
26
|
+
* Resolves `$ref` chains, following them across documents. A resolver is bound to
|
|
27
|
+
* one document (for `#/…` local refs); crossing into another document via a
|
|
28
|
+
* `<path>#/…` ref yields a resolver bound to *that* document, so the resolved
|
|
29
|
+
* schema's own local refs resolve correctly.
|
|
30
|
+
*/
|
|
31
|
+
class RefResolver {
|
|
32
|
+
constructor(doc, documents) {
|
|
33
|
+
this.doc = doc;
|
|
34
|
+
this.documents = documents;
|
|
35
|
+
}
|
|
36
|
+
resolve(schema) {
|
|
37
|
+
let current = schema;
|
|
38
|
+
let doc = this.doc;
|
|
39
|
+
const seen = new Set();
|
|
40
|
+
while (current && typeof current.$ref === 'string') {
|
|
41
|
+
const key = `${current.$ref}@${doc.$id ?? ''}`;
|
|
42
|
+
if (seen.has(key))
|
|
43
|
+
break; // cycle guard
|
|
44
|
+
seen.add(key);
|
|
45
|
+
const [docPath, fragment] = splitRef(current.$ref);
|
|
46
|
+
const targetDoc = docPath ? findDocument(this.documents, docPath) ?? doc : doc;
|
|
47
|
+
const target = resolveFragment(targetDoc, fragment);
|
|
48
|
+
if (!target)
|
|
49
|
+
break;
|
|
50
|
+
current = target;
|
|
51
|
+
doc = targetDoc;
|
|
52
|
+
}
|
|
53
|
+
return { schema: current, scope: doc === this.doc ? this : new RefResolver(doc, this.documents) };
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Split a `$ref` into its document part (URI without fragment) and its fragment. */
|
|
57
|
+
function splitRef(ref) {
|
|
58
|
+
const hash = ref.indexOf('#');
|
|
59
|
+
if (hash === -1)
|
|
60
|
+
return [ref || undefined, ''];
|
|
61
|
+
return [ref.slice(0, hash) || undefined, ref.slice(hash + 1)];
|
|
62
|
+
}
|
|
63
|
+
/** The document whose `$id` equals or ends with the ref's document path. */
|
|
64
|
+
function findDocument(documents, docPath) {
|
|
65
|
+
return documents.find((d) => typeof d.$id === 'string' && (d.$id === docPath || d.$id.endsWith(docPath)));
|
|
66
|
+
}
|
|
67
|
+
/** Walk a JSON Pointer fragment (`/$defs/UeId`) within a document. */
|
|
68
|
+
function resolveFragment(doc, fragment) {
|
|
69
|
+
if (!fragment || fragment === '/')
|
|
70
|
+
return doc;
|
|
71
|
+
let node = doc;
|
|
72
|
+
for (const raw of fragment.split('/')) {
|
|
73
|
+
if (raw === '')
|
|
74
|
+
continue;
|
|
75
|
+
const segment = raw.replace(/~1/g, '/').replace(/~0/g, '~');
|
|
76
|
+
if (node == null || typeof node !== 'object')
|
|
77
|
+
return undefined;
|
|
78
|
+
node = node[segment];
|
|
79
|
+
}
|
|
80
|
+
return node;
|
|
81
|
+
}
|
|
82
|
+
// --- objects / maps -----------------------------------------------------------
|
|
83
|
+
function objectToNodeGroup(schema, name, resolver, label) {
|
|
18
84
|
const required = new Set(schema.required ?? []);
|
|
19
85
|
const children = {};
|
|
20
86
|
for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
|
|
21
|
-
children[key] = schemaToNode(key, propSchema, required.has(key));
|
|
87
|
+
children[key] = schemaToNode(key, propSchema, required.has(key), resolver);
|
|
22
88
|
}
|
|
23
89
|
const group = { kind: 'nodeGroup', name, children };
|
|
24
90
|
if (label)
|
|
25
91
|
group.label = label;
|
|
26
92
|
return group;
|
|
27
93
|
}
|
|
28
|
-
|
|
94
|
+
/** An `object` with open keys — `additionalProperties: <schema>` or `patternProperties`, and no fixed `properties`. */
|
|
95
|
+
function isOpenMap(schema) {
|
|
96
|
+
if (schema.properties && Object.keys(schema.properties).length)
|
|
97
|
+
return false;
|
|
98
|
+
const ap = schema.additionalProperties;
|
|
99
|
+
const additionalIsSchema = ap != null && typeof ap === 'object';
|
|
100
|
+
const hasPattern = !!schema.patternProperties && Object.keys(schema.patternProperties).length > 0;
|
|
101
|
+
return additionalIsSchema || hasPattern;
|
|
102
|
+
}
|
|
103
|
+
function objectToMap(name, schema, resolver) {
|
|
104
|
+
let valueSchema = {};
|
|
105
|
+
let keyPattern;
|
|
106
|
+
const pattern = schema.patternProperties && Object.entries(schema.patternProperties)[0];
|
|
107
|
+
if (pattern) {
|
|
108
|
+
[keyPattern, valueSchema] = pattern;
|
|
109
|
+
}
|
|
110
|
+
else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
|
|
111
|
+
valueSchema = schema.additionalProperties;
|
|
112
|
+
}
|
|
113
|
+
const node = { kind: 'map', name, value: schemaToNode('value', valueSchema, false, resolver) };
|
|
114
|
+
if (schema.title)
|
|
115
|
+
node.label = schema.title;
|
|
116
|
+
if (keyPattern)
|
|
117
|
+
node.keyPattern = keyPattern;
|
|
118
|
+
if (typeof schema.minProperties === 'number')
|
|
119
|
+
node.minEntries = schema.minProperties;
|
|
120
|
+
if (typeof schema.maxProperties === 'number')
|
|
121
|
+
node.maxEntries = schema.maxProperties;
|
|
122
|
+
return node;
|
|
123
|
+
}
|
|
124
|
+
// --- anyOf / oneOf → choice ---------------------------------------------------
|
|
125
|
+
/** The `anyOf`/`oneOf` `[T, null]` shape collapses to a nullable leaf; anything else is a choice. */
|
|
126
|
+
function branchesToNode(name, schema, rawBranches, required, resolver) {
|
|
127
|
+
const branches = rawBranches.map((b) => resolver.resolve(b));
|
|
128
|
+
const nonNull = branches.filter((b) => b.schema.type !== 'null');
|
|
129
|
+
const sole = nonNull.length === 1 ? nonNull[0] : undefined;
|
|
130
|
+
if (sole && nonNull.length < branches.length && isScalarSchema(sole.schema)) {
|
|
131
|
+
const leaf = scalarLeaf(name, sole.schema, required);
|
|
132
|
+
leaf.nullable = true;
|
|
133
|
+
return leaf;
|
|
134
|
+
}
|
|
135
|
+
const cases = {};
|
|
136
|
+
const caseLabels = {};
|
|
137
|
+
branches.forEach(({ schema: branch, scope }, index) => {
|
|
138
|
+
const caseName = `case${index}`;
|
|
139
|
+
cases[caseName] = branchToCase(branch, scope);
|
|
140
|
+
if (branch.title)
|
|
141
|
+
caseLabels[caseName] = branch.title;
|
|
142
|
+
});
|
|
143
|
+
const choice = { kind: 'choice', name, cases };
|
|
144
|
+
if (schema.title)
|
|
145
|
+
choice.label = schema.title;
|
|
146
|
+
if (Object.keys(caseLabels).length)
|
|
147
|
+
choice.caseLabels = caseLabels;
|
|
148
|
+
return choice;
|
|
149
|
+
}
|
|
150
|
+
/** A resolved branch → a case body: a field record for an object branch, else a single node. */
|
|
151
|
+
function branchToCase(branch, scope) {
|
|
152
|
+
if (primaryType(branch) === 'object' && branch.properties) {
|
|
153
|
+
const required = new Set(branch.required ?? []);
|
|
154
|
+
const fields = {};
|
|
155
|
+
for (const [key, propSchema] of Object.entries(branch.properties)) {
|
|
156
|
+
fields[key] = schemaToNode(key, propSchema, required.has(key), scope);
|
|
157
|
+
}
|
|
158
|
+
return fields;
|
|
159
|
+
}
|
|
160
|
+
return schemaToNode('value', branch, false, scope);
|
|
161
|
+
}
|
|
162
|
+
// --- dispatch -----------------------------------------------------------------
|
|
163
|
+
function schemaToNode(name, rawSchema, required, resolver) {
|
|
164
|
+
const { schema, scope } = resolver.resolve(rawSchema);
|
|
165
|
+
const branches = schema.anyOf ?? schema.oneOf;
|
|
166
|
+
if (branches && branches.length) {
|
|
167
|
+
return branchesToNode(name, schema, branches, required, scope);
|
|
168
|
+
}
|
|
29
169
|
const type = primaryType(schema);
|
|
30
|
-
if (type === 'object') {
|
|
31
|
-
|
|
170
|
+
if (type === 'object' || (type === undefined && (schema.properties || isOpenMap(schema)))) {
|
|
171
|
+
if (isOpenMap(schema))
|
|
172
|
+
return objectToMap(name, schema, scope);
|
|
173
|
+
return objectToNodeGroup(schema, name, scope, schema.title);
|
|
32
174
|
}
|
|
33
175
|
if (type === 'array') {
|
|
34
|
-
const items = schema.items ?? {};
|
|
35
|
-
if (primaryType(items) === 'object') {
|
|
176
|
+
const { schema: items, scope: itemScope } = scope.resolve(schema.items ?? {});
|
|
177
|
+
if (primaryType(items) === 'object' && items.properties) {
|
|
36
178
|
const node = {
|
|
37
179
|
kind: 'nodeGroupList',
|
|
38
180
|
name,
|
|
39
|
-
type: objectToNodeGroup(items, name, items.title),
|
|
181
|
+
type: objectToNodeGroup(items, name, itemScope, items.title),
|
|
40
182
|
};
|
|
41
183
|
if (schema.title)
|
|
42
184
|
node.label = schema.title;
|
|
185
|
+
if (typeof schema.minItems === 'number')
|
|
186
|
+
node.minItems = schema.minItems;
|
|
187
|
+
if (typeof schema.maxItems === 'number')
|
|
188
|
+
node.maxItems = schema.maxItems;
|
|
43
189
|
return node;
|
|
44
190
|
}
|
|
45
191
|
const list = { kind: 'leafList', name, type: scalarType(items) };
|
|
46
192
|
if (schema.title)
|
|
47
193
|
list.label = schema.title;
|
|
194
|
+
if (typeof schema.minItems === 'number')
|
|
195
|
+
list.minItems = schema.minItems;
|
|
196
|
+
if (typeof schema.maxItems === 'number')
|
|
197
|
+
list.maxItems = schema.maxItems;
|
|
48
198
|
return list;
|
|
49
199
|
}
|
|
50
|
-
|
|
200
|
+
return scalarLeaf(name, schema, required);
|
|
201
|
+
}
|
|
202
|
+
// --- scalar leaves ------------------------------------------------------------
|
|
203
|
+
function scalarLeaf(name, schema, required) {
|
|
51
204
|
const leaf = { kind: 'leaf', name, type: scalarType(schema) };
|
|
52
205
|
if (schema.enum && schema.enum.length) {
|
|
53
206
|
leaf.type = 'enum';
|
|
54
|
-
leaf.enum = schema.enum.filter(
|
|
207
|
+
leaf.enum = schema.enum.filter(isEnumMember);
|
|
208
|
+
}
|
|
209
|
+
// `const` — a fixed, read-only value.
|
|
210
|
+
if ('const' in schema && isScalar(schema.const)) {
|
|
211
|
+
leaf.default = schema.const;
|
|
212
|
+
leaf.readOnly = true;
|
|
55
213
|
}
|
|
56
214
|
if (required)
|
|
57
215
|
leaf.required = true;
|
|
@@ -59,13 +217,41 @@ function schemaToNode(name, schema, required) {
|
|
|
59
217
|
leaf.default = schema.default;
|
|
60
218
|
if (schema.title)
|
|
61
219
|
leaf.label = schema.title;
|
|
220
|
+
if (schema.description)
|
|
221
|
+
leaf.description = schema.description;
|
|
222
|
+
if (isNullable(schema))
|
|
223
|
+
leaf.nullable = true;
|
|
224
|
+
if (leaf.type === 'string') {
|
|
225
|
+
if (typeof schema.pattern === 'string')
|
|
226
|
+
leaf.pattern = schema.pattern;
|
|
227
|
+
if (typeof schema.minLength === 'number')
|
|
228
|
+
leaf.minLength = schema.minLength;
|
|
229
|
+
if (typeof schema.maxLength === 'number')
|
|
230
|
+
leaf.maxLength = schema.maxLength;
|
|
231
|
+
const format = mapFormat(schema.format);
|
|
232
|
+
if (format)
|
|
233
|
+
leaf.format = format;
|
|
234
|
+
}
|
|
235
|
+
else if (leaf.type === 'number') {
|
|
236
|
+
if (primaryType(schema) === 'integer')
|
|
237
|
+
leaf.integer = true;
|
|
238
|
+
if (typeof schema.minimum === 'number')
|
|
239
|
+
leaf.min = schema.minimum;
|
|
240
|
+
if (typeof schema.maximum === 'number')
|
|
241
|
+
leaf.max = schema.maximum;
|
|
242
|
+
if (typeof schema.multipleOf === 'number')
|
|
243
|
+
leaf.multipleOf = schema.multipleOf;
|
|
244
|
+
}
|
|
62
245
|
return leaf;
|
|
63
246
|
}
|
|
247
|
+
// --- helpers ------------------------------------------------------------------
|
|
64
248
|
/** The first declared type, ignoring a `null` companion in a `[T, 'null']` union. */
|
|
65
249
|
function primaryType(schema) {
|
|
66
250
|
const t = schema.type;
|
|
67
251
|
if (Array.isArray(t))
|
|
68
252
|
return t.find((x) => x !== 'null');
|
|
253
|
+
if (t === undefined && schema.const !== undefined)
|
|
254
|
+
return constType(schema.const);
|
|
69
255
|
return t;
|
|
70
256
|
}
|
|
71
257
|
/** Map a JSON Schema scalar type to a form {@link LeafType} (default `string`). */
|
|
@@ -82,6 +268,33 @@ function scalarType(schema) {
|
|
|
82
268
|
return 'string';
|
|
83
269
|
}
|
|
84
270
|
}
|
|
271
|
+
function isScalarSchema(schema) {
|
|
272
|
+
const t = primaryType(schema);
|
|
273
|
+
return t === 'string' || t === 'number' || t === 'integer' || t === 'boolean' || (t === undefined && schema.enum != null);
|
|
274
|
+
}
|
|
275
|
+
function isNullable(schema) {
|
|
276
|
+
return Array.isArray(schema.type) && schema.type.includes('null');
|
|
277
|
+
}
|
|
278
|
+
function constType(value) {
|
|
279
|
+
if (typeof value === 'boolean')
|
|
280
|
+
return 'boolean';
|
|
281
|
+
if (typeof value === 'number')
|
|
282
|
+
return 'number';
|
|
283
|
+
if (typeof value === 'string')
|
|
284
|
+
return 'string';
|
|
285
|
+
return undefined;
|
|
286
|
+
}
|
|
287
|
+
/** Map a JSON Schema `format` to the leaf formats the library validates. */
|
|
288
|
+
function mapFormat(format) {
|
|
289
|
+
if (format === 'email' || format === 'idn-email')
|
|
290
|
+
return 'email';
|
|
291
|
+
if (format === 'uri' || format === 'iri' || format === 'url')
|
|
292
|
+
return 'uri';
|
|
293
|
+
return undefined;
|
|
294
|
+
}
|
|
295
|
+
function isEnumMember(v) {
|
|
296
|
+
return typeof v === 'string' || typeof v === 'number';
|
|
297
|
+
}
|
|
85
298
|
function isScalar(v) {
|
|
86
299
|
return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
|
|
87
300
|
}
|
package/dist/core/schema.d.ts
CHANGED
|
@@ -18,6 +18,27 @@ export interface Leaf {
|
|
|
18
18
|
default?: string | number | boolean;
|
|
19
19
|
/** Present when `type` is `'enum'`. */
|
|
20
20
|
enum?: (string | number)[];
|
|
21
|
+
/** Display labels for the enum options, positionally aligned with `enum`. */
|
|
22
|
+
enumLabel?: string[];
|
|
23
|
+
/** Reject values not matching this regex (JSON Schema `pattern`; unanchored). */
|
|
24
|
+
pattern?: string;
|
|
25
|
+
minLength?: number;
|
|
26
|
+
maxLength?: number;
|
|
27
|
+
/** Semantic string format (JSON Schema `format`). */
|
|
28
|
+
format?: 'email' | 'uri' | 'url';
|
|
29
|
+
/** Require a whole-number value (JSON Schema `type: 'integer'`). */
|
|
30
|
+
integer?: boolean;
|
|
31
|
+
/** Inclusive bounds (JSON Schema `minimum`/`maximum`). */
|
|
32
|
+
min?: number;
|
|
33
|
+
max?: number;
|
|
34
|
+
multipleOf?: number;
|
|
35
|
+
/** The value may be `null` (JSON Schema `type: [T, 'null']`). */
|
|
36
|
+
nullable?: boolean;
|
|
37
|
+
/** Optional scalar whose presence is itself data (on/off toggle). */
|
|
38
|
+
presence?: boolean;
|
|
39
|
+
/** Render read-only; with `default`, expresses a JSON Schema `const`. */
|
|
40
|
+
readOnly?: boolean;
|
|
41
|
+
description?: string;
|
|
21
42
|
}
|
|
22
43
|
export interface LeafList {
|
|
23
44
|
kind: 'leafList';
|
|
@@ -48,21 +69,45 @@ export interface NodeGroupList {
|
|
|
48
69
|
minItems?: number;
|
|
49
70
|
maxItems?: number;
|
|
50
71
|
}
|
|
72
|
+
/** One case body: a record of named fields, or a single node (a leaf-bodied case). */
|
|
73
|
+
export type ChoiceCase = Record<string, NodeType> | NodeType;
|
|
51
74
|
/**
|
|
52
75
|
* A discriminated selection: the user picks one `case`, and only that case's
|
|
53
76
|
* fields are present. In the form value a choice is an object
|
|
54
77
|
* `{ __case: <caseName>, ...that case's fields }`; the adapter flattens it to the
|
|
55
|
-
* inline YANG encoding on write-back.
|
|
78
|
+
* inline YANG encoding on write-back. Cases may be anonymous/auto-named (from a
|
|
79
|
+
* JSON Schema `anyOf`/`oneOf`); the active case is inferred from seed data.
|
|
56
80
|
*/
|
|
57
81
|
export interface Choice {
|
|
58
82
|
kind: 'choice';
|
|
59
83
|
name: string;
|
|
60
84
|
label?: string;
|
|
61
|
-
cases: Record<string,
|
|
85
|
+
cases: Record<string, ChoiceCase>;
|
|
86
|
+
/** Display labels for cases, keyed by case name. */
|
|
87
|
+
caseLabels?: Record<string, string>;
|
|
62
88
|
default?: string;
|
|
63
89
|
mandatory?: boolean;
|
|
64
90
|
}
|
|
65
|
-
|
|
91
|
+
/**
|
|
92
|
+
* An open, arbitrary-keyed record: unlike {@link NodeGroup} (a fixed key set), a
|
|
93
|
+
* map's keys are runtime data and every value conforms to one `value` schema.
|
|
94
|
+
* Maps JSON Schema `additionalProperties: <schema>` / `patternProperties`.
|
|
95
|
+
*/
|
|
96
|
+
export interface NodeMap {
|
|
97
|
+
kind: 'map';
|
|
98
|
+
name: string;
|
|
99
|
+
label?: string;
|
|
100
|
+
description?: string;
|
|
101
|
+
/** The schema every entry's value conforms to. */
|
|
102
|
+
value: NodeType;
|
|
103
|
+
keyLabel?: string;
|
|
104
|
+
/** `patternProperties`: entry keys must match this regex. */
|
|
105
|
+
keyPattern?: string;
|
|
106
|
+
minEntries?: number;
|
|
107
|
+
maxEntries?: number;
|
|
108
|
+
presence?: boolean;
|
|
109
|
+
}
|
|
110
|
+
export type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | Choice | NodeMap;
|
|
66
111
|
/** The form-value key that records which case of a {@link Choice} is active. */
|
|
67
112
|
export declare const CASE_KEY = "__case";
|
|
68
113
|
/** A plain, nested value object produced/consumed by a rendered form. */
|
|
@@ -17,6 +17,12 @@ export interface JsonFormat {
|
|
|
17
17
|
indent: number;
|
|
18
18
|
/** Whether the source ended with a trailing newline. */
|
|
19
19
|
trailingNewline: boolean;
|
|
20
|
+
/**
|
|
21
|
+
* Paths (as `JSON.stringify`'d key/index arrays) that held an integer literal
|
|
22
|
+
* too large for a JS `number`. Their form value is a string; `toSource` emits
|
|
23
|
+
* them back as unquoted numbers so precision survives the round-trip.
|
|
24
|
+
*/
|
|
25
|
+
bigInts: string[];
|
|
20
26
|
}
|
|
21
27
|
/**
|
|
22
28
|
* A JSON {@link Transformer}: turn a JSON config document into a form and write
|
|
@@ -30,6 +36,10 @@ export interface JsonFormat {
|
|
|
30
36
|
* preserved for untouched keys. For JSON **with comments** (JSONC), parse and
|
|
31
37
|
* edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
|
|
32
38
|
* instead — `JSON.parse` rejects comments.
|
|
39
|
+
*
|
|
40
|
+
* Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
|
|
41
|
+
* as strings in the form value and re-emitted verbatim as unquoted numbers (the
|
|
42
|
+
* same strategy the YANG adapter uses for `uint64`).
|
|
33
43
|
*/
|
|
34
44
|
export declare const jsonTransformer: {
|
|
35
45
|
id: string;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.jsonTransformer = void 0;
|
|
4
4
|
const infer_1 = require("../../core/infer");
|
|
5
5
|
const json_schema_1 = require("../../core/json-schema");
|
|
6
|
+
const bigint_1 = require("../../core/bigint");
|
|
6
7
|
/**
|
|
7
8
|
* A JSON {@link Transformer}: turn a JSON config document into a form and write
|
|
8
9
|
* the edited value back to JSON. The form is built by the same format-agnostic
|
|
@@ -15,22 +16,33 @@ const json_schema_1 = require("../../core/json-schema");
|
|
|
15
16
|
* preserved for untouched keys. For JSON **with comments** (JSONC), parse and
|
|
16
17
|
* edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
|
|
17
18
|
* instead — `JSON.parse` rejects comments.
|
|
19
|
+
*
|
|
20
|
+
* Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
|
|
21
|
+
* as strings in the form value and re-emitted verbatim as unquoted numbers (the
|
|
22
|
+
* same strategy the YANG adapter uses for `uint64`).
|
|
18
23
|
*/
|
|
19
24
|
exports.jsonTransformer = {
|
|
20
25
|
id: 'json',
|
|
21
26
|
toSchema(source, options) {
|
|
22
|
-
const
|
|
27
|
+
const bigInts = [];
|
|
28
|
+
const parsed = JSON.parse(source, bigIntReviver);
|
|
29
|
+
const data = collectBigInts(parsed ?? {}, [], bigInts);
|
|
23
30
|
const schema = options?.schema
|
|
24
31
|
? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName)
|
|
25
32
|
: (0, infer_1.inferNodeGroup)(data, options?.rootName);
|
|
26
33
|
const binding = {
|
|
27
34
|
indent: detectIndent(source),
|
|
28
35
|
trailingNewline: source.endsWith('\n'),
|
|
36
|
+
bigInts,
|
|
29
37
|
};
|
|
30
38
|
return { schema, binding, initialValue: data };
|
|
31
39
|
},
|
|
32
40
|
toSource(value, binding) {
|
|
33
|
-
const
|
|
41
|
+
const paths = new Set(binding.bigInts);
|
|
42
|
+
const prepared = paths.size ? markBigInts(value, [], paths) : value;
|
|
43
|
+
let text = JSON.stringify(prepared, null, binding.indent);
|
|
44
|
+
if (paths.size)
|
|
45
|
+
text = text.replace(BIGINT_MARK_RE, '$1');
|
|
34
46
|
return binding.trailingNewline ? text + '\n' : text;
|
|
35
47
|
},
|
|
36
48
|
};
|
|
@@ -39,3 +51,81 @@ function detectIndent(source) {
|
|
|
39
51
|
const match = source.match(/\n( +)\S/);
|
|
40
52
|
return match && match[1] ? match[1].length : 2;
|
|
41
53
|
}
|
|
54
|
+
// --- big-integer preservation -------------------------------------------------
|
|
55
|
+
/** A key/index path key, collision-free across separator characters. */
|
|
56
|
+
function pathKey(path) {
|
|
57
|
+
return JSON.stringify(path);
|
|
58
|
+
}
|
|
59
|
+
/** Placeholder wrapping a big-int value through `JSON.stringify`, stripped after. */
|
|
60
|
+
const BIGINT_MARK = '@@nff-bigint@@';
|
|
61
|
+
const BIGINT_MARK_RE = new RegExp(`"${BIGINT_MARK}(-?\\d+)${BIGINT_MARK}"`, 'g');
|
|
62
|
+
/** An out-of-range integer literal, captured verbatim from the source. */
|
|
63
|
+
class BigIntLiteral {
|
|
64
|
+
constructor(digits) {
|
|
65
|
+
this.digits = digits;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* A `JSON.parse` reviver that captures integer literals too large for a `number`.
|
|
70
|
+
* The parsed `value` is already lossy, so the exact digits come from the reviver
|
|
71
|
+
* `context.source` (the raw literal text; available on Node 21+). Genuine strings
|
|
72
|
+
* never reach here as numbers, so a quoted big-digit value stays a plain string.
|
|
73
|
+
*
|
|
74
|
+
* On a runtime without `context.source` (Node < 21) the exact digits can't be
|
|
75
|
+
* recovered, so an out-of-range integer throws with an actionable message rather
|
|
76
|
+
* than being silently rounded. Safe integers and floats are unaffected there.
|
|
77
|
+
*/
|
|
78
|
+
function bigIntReviver(_key, value, context) {
|
|
79
|
+
if (typeof value !== 'number')
|
|
80
|
+
return value;
|
|
81
|
+
if (context && typeof context.source === 'string') {
|
|
82
|
+
return (0, bigint_1.isUnsafeIntegerString)(context.source) ? new BigIntLiteral(context.source) : value;
|
|
83
|
+
}
|
|
84
|
+
if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
|
|
85
|
+
throw new RangeError(`JSON integer ${value} exceeds the safe range (2^53) and its exact value cannot be ` +
|
|
86
|
+
`recovered on this runtime. Use Node ≥ 21 for lossless big-integer support, or ` +
|
|
87
|
+
`quote the value as a string in the source.`);
|
|
88
|
+
}
|
|
89
|
+
return value;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Replace each {@link BigIntLiteral} with its digit string in the form value, and
|
|
93
|
+
* record its path so `toSource` can re-emit it unquoted. Walks objects and arrays.
|
|
94
|
+
*/
|
|
95
|
+
function collectBigInts(node, path, out) {
|
|
96
|
+
if (node instanceof BigIntLiteral) {
|
|
97
|
+
out.push(pathKey(path));
|
|
98
|
+
return node.digits;
|
|
99
|
+
}
|
|
100
|
+
if (Array.isArray(node))
|
|
101
|
+
return node.map((v, i) => collectBigInts(v, [...path, i], out));
|
|
102
|
+
if (isPlainObject(node)) {
|
|
103
|
+
const res = {};
|
|
104
|
+
for (const key of Object.keys(node))
|
|
105
|
+
res[key] = collectBigInts(node[key], [...path, key], out);
|
|
106
|
+
return res;
|
|
107
|
+
}
|
|
108
|
+
return node;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Wrap the string value at each recorded big-int path in a placeholder so
|
|
112
|
+
* `JSON.stringify` emits it as a (quoted) string that {@link BIGINT_MARK_RE} then
|
|
113
|
+
* unquotes. A value the user has edited to a non-integer stays a normal string.
|
|
114
|
+
*/
|
|
115
|
+
function markBigInts(node, path, paths) {
|
|
116
|
+
if (typeof node === 'string') {
|
|
117
|
+
return paths.has(pathKey(path)) && (0, bigint_1.isIntegerString)(node) ? `${BIGINT_MARK}${node}${BIGINT_MARK}` : node;
|
|
118
|
+
}
|
|
119
|
+
if (Array.isArray(node))
|
|
120
|
+
return node.map((v, i) => markBigInts(v, [...path, i], paths));
|
|
121
|
+
if (isPlainObject(node)) {
|
|
122
|
+
const res = {};
|
|
123
|
+
for (const key of Object.keys(node))
|
|
124
|
+
res[key] = markBigInts(node[key], [...path, key], paths);
|
|
125
|
+
return res;
|
|
126
|
+
}
|
|
127
|
+
return node;
|
|
128
|
+
}
|
|
129
|
+
function isPlainObject(v) {
|
|
130
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
131
|
+
}
|
|
@@ -9,5 +9,10 @@ import { type Document } from 'yaml';
|
|
|
9
9
|
* comments. Counterpart of the schema/inference in {@link import('./infer')} and
|
|
10
10
|
* {@link import('./json-schema')}. Callers should clone the document first if the
|
|
11
11
|
* original must be preserved.
|
|
12
|
+
*
|
|
13
|
+
* Map keys are matched by the *same string form* `doc.toJS()` produced when the
|
|
14
|
+
* form value was built, so non-string keys (a `80:` port map, a `true:` flag)
|
|
15
|
+
* reconcile against their original typed key nodes instead of being appended as
|
|
16
|
+
* duplicate string-keyed pairs.
|
|
12
17
|
*/
|
|
13
18
|
export declare function applyValueToDocument(doc: Document, value: unknown): void;
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.applyValueToDocument = applyValueToDocument;
|
|
4
4
|
const yaml_1 = require("yaml");
|
|
5
|
+
const bigint_1 = require("../../core/bigint");
|
|
5
6
|
/**
|
|
6
7
|
* Apply an edited form value onto a parsed YAML {@link Document} in place,
|
|
7
8
|
* preserving comments and formatting on every node that survives the edit.
|
|
@@ -12,62 +13,97 @@ const yaml_1 = require("yaml");
|
|
|
12
13
|
* comments. Counterpart of the schema/inference in {@link import('./infer')} and
|
|
13
14
|
* {@link import('./json-schema')}. Callers should clone the document first if the
|
|
14
15
|
* original must be preserved.
|
|
16
|
+
*
|
|
17
|
+
* Map keys are matched by the *same string form* `doc.toJS()` produced when the
|
|
18
|
+
* form value was built, so non-string keys (a `80:` port map, a `true:` flag)
|
|
19
|
+
* reconcile against their original typed key nodes instead of being appended as
|
|
20
|
+
* duplicate string-keyed pairs.
|
|
15
21
|
*/
|
|
16
22
|
function applyValueToDocument(doc, value) {
|
|
17
23
|
if (doc.contents == null) {
|
|
18
24
|
doc.contents = doc.createNode(value);
|
|
19
25
|
return;
|
|
20
26
|
}
|
|
21
|
-
|
|
27
|
+
doc.contents = applyToNode(doc, doc.contents, value);
|
|
22
28
|
}
|
|
23
|
-
|
|
24
|
-
|
|
29
|
+
/**
|
|
30
|
+
* Reconcile the parsed `node` at one position with its edited `value`, returning
|
|
31
|
+
* the node to store there. A scalar is mutated in place so its comments survive;
|
|
32
|
+
* a map/sequence is reconciled child-by-child; a shape change (or a brand-new
|
|
33
|
+
* position) allocates a fresh node.
|
|
34
|
+
*/
|
|
35
|
+
function applyToNode(doc, node, value) {
|
|
25
36
|
if (isPlainObject(value)) {
|
|
26
37
|
if (!(0, yaml_1.isMap)(node))
|
|
27
|
-
return
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
if (!keys.has(key))
|
|
31
|
-
node.delete(key);
|
|
32
|
-
}
|
|
33
|
-
for (const key of keys)
|
|
34
|
-
applyAt(doc, [...path, key], value[key]);
|
|
35
|
-
return;
|
|
38
|
+
return doc.createNode(value);
|
|
39
|
+
reconcileMap(doc, node, value);
|
|
40
|
+
return node;
|
|
36
41
|
}
|
|
37
42
|
if (Array.isArray(value)) {
|
|
38
43
|
if (!(0, yaml_1.isSeq)(node))
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
for (let i = 0; i < value.length; i++) {
|
|
43
|
-
if (i < node.items.length)
|
|
44
|
-
applyAt(doc, [...path, i], value[i]);
|
|
45
|
-
else
|
|
46
|
-
node.items.push(doc.createNode(value[i]));
|
|
47
|
-
}
|
|
48
|
-
return;
|
|
44
|
+
return doc.createNode(value);
|
|
45
|
+
reconcileSeq(doc, node, value);
|
|
46
|
+
return node;
|
|
49
47
|
}
|
|
50
48
|
// scalar (string / number / boolean / null)
|
|
51
|
-
if ((0, yaml_1.isScalar)(node))
|
|
52
|
-
node.value = value;
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
if ((0, yaml_1.isScalar)(node)) {
|
|
50
|
+
node.value = coerceScalarValue(value, node.value);
|
|
51
|
+
return node;
|
|
52
|
+
}
|
|
53
|
+
return doc.createNode(value);
|
|
55
54
|
}
|
|
56
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Update a map node against the value object: drop pairs whose key is gone,
|
|
57
|
+
* recurse into the ones that remain (matched by their `toJS` key string, so
|
|
58
|
+
* typed keys line up), and append a fresh pair for each genuinely new key.
|
|
59
|
+
*/
|
|
60
|
+
function reconcileMap(doc, node, value) {
|
|
61
|
+
node.items = node.items.filter((pair) => keyString(pair.key) in value);
|
|
62
|
+
for (const key of Object.keys(value)) {
|
|
63
|
+
const pair = node.items.find((p) => keyString(p.key) === key);
|
|
64
|
+
if (pair) {
|
|
65
|
+
pair.value = applyToNode(doc, pair.value, value[key]);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
node.items.push(doc.createPair(key, value[key]));
|
|
69
|
+
}
|
|
60
70
|
}
|
|
61
|
-
|
|
62
|
-
|
|
71
|
+
}
|
|
72
|
+
/** Update a sequence node: shrink/grow to the value's length, recurse per item. */
|
|
73
|
+
function reconcileSeq(doc, node, value) {
|
|
74
|
+
while (node.items.length > value.length)
|
|
75
|
+
node.items.pop();
|
|
76
|
+
for (let i = 0; i < value.length; i++) {
|
|
77
|
+
if (i < node.items.length) {
|
|
78
|
+
node.items[i] = applyToNode(doc, node.items[i], value[i]);
|
|
79
|
+
}
|
|
80
|
+
else {
|
|
81
|
+
node.items.push(doc.createNode(value[i]));
|
|
82
|
+
}
|
|
63
83
|
}
|
|
64
84
|
}
|
|
65
|
-
|
|
66
|
-
|
|
85
|
+
/**
|
|
86
|
+
* The value to store on a scalar node. An out-of-range integer is carried in the
|
|
87
|
+
* form value as a string (a `number` would lose precision); when the node it
|
|
88
|
+
* lands on already held a BigInt — i.e. the source had an unquoted integer there
|
|
89
|
+
* — restore the BigInt so it re-emits as a bare number rather than a quoted
|
|
90
|
+
* string. Everything else passes through unchanged (a genuine string key stays a
|
|
91
|
+
* string node, so it keeps its quotes).
|
|
92
|
+
*/
|
|
93
|
+
function coerceScalarValue(value, current) {
|
|
94
|
+
if (typeof current === 'bigint' && typeof value === 'string' && (0, bigint_1.isIntegerString)(value)) {
|
|
95
|
+
return BigInt(value);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
67
98
|
}
|
|
68
|
-
/**
|
|
69
|
-
|
|
70
|
-
|
|
99
|
+
/**
|
|
100
|
+
* The string a key node takes as a JS object key, matching `doc.toJS()`: the
|
|
101
|
+
* scalar value stringified, with `null`/`undefined` collapsing to `''` (the
|
|
102
|
+
* empty key `toJS` uses for a `null:` key).
|
|
103
|
+
*/
|
|
104
|
+
function keyString(key) {
|
|
105
|
+
const v = (0, yaml_1.isScalar)(key) ? key.value : key;
|
|
106
|
+
return v == null ? '' : String(v);
|
|
71
107
|
}
|
|
72
108
|
function isPlainObject(v) {
|
|
73
109
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
@@ -4,6 +4,7 @@ exports.yamlTransformer = void 0;
|
|
|
4
4
|
const yaml_1 = require("yaml");
|
|
5
5
|
const infer_1 = require("../../core/infer");
|
|
6
6
|
const json_schema_1 = require("../../core/json-schema");
|
|
7
|
+
const bigint_1 = require("../../core/bigint");
|
|
7
8
|
const revert_1 = require("./revert");
|
|
8
9
|
/**
|
|
9
10
|
* A YAML {@link Transformer}: turn a YAML config document into a form and write
|
|
@@ -20,8 +21,11 @@ const revert_1 = require("./revert");
|
|
|
20
21
|
exports.yamlTransformer = {
|
|
21
22
|
id: 'yaml',
|
|
22
23
|
toSchema(source, options) {
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
// Parse integers as BigInt so the document nodes keep full precision; the
|
|
25
|
+
// revert re-emits them verbatim. The form value can't carry a BigInt, so
|
|
26
|
+
// out-of-range integers become strings there (safe ones become plain numbers).
|
|
27
|
+
const doc = (0, yaml_1.parseDocument)(source, { intAsBigInt: true });
|
|
28
|
+
const data = (normalizeBigInts(doc.toJS()) ?? {});
|
|
25
29
|
const schema = options?.schema
|
|
26
30
|
? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName)
|
|
27
31
|
: (0, infer_1.inferNodeGroup)(data, options?.rootName);
|
|
@@ -35,3 +39,21 @@ exports.yamlTransformer = {
|
|
|
35
39
|
// `satisfies` verifies conformance to the catalog contract while keeping the
|
|
36
40
|
// concrete sync return types, so direct callers need no `await`.
|
|
37
41
|
};
|
|
42
|
+
/**
|
|
43
|
+
* Replace every BigInt from an `intAsBigInt` parse with a form-value scalar: a
|
|
44
|
+
* plain `number` when it fits the safe range, otherwise its decimal string (so
|
|
45
|
+
* precision survives). Walks maps and sequences; leaves other values untouched.
|
|
46
|
+
*/
|
|
47
|
+
function normalizeBigInts(value) {
|
|
48
|
+
if (typeof value === 'bigint')
|
|
49
|
+
return (0, bigint_1.isUnsafeBigInt)(value) ? value.toString() : Number(value);
|
|
50
|
+
if (Array.isArray(value))
|
|
51
|
+
return value.map(normalizeBigInts);
|
|
52
|
+
if (value && typeof value === 'object') {
|
|
53
|
+
const out = {};
|
|
54
|
+
for (const key of Object.keys(value))
|
|
55
|
+
out[key] = normalizeBigInts(value[key]);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
return value;
|
|
59
|
+
}
|
|
@@ -42,7 +42,8 @@ function mapNode(node) {
|
|
|
42
42
|
leaf.required = true;
|
|
43
43
|
if (node.default !== undefined)
|
|
44
44
|
leaf.default = node.default;
|
|
45
|
-
const
|
|
45
|
+
const identities = node.type.identities;
|
|
46
|
+
const options = node.type.enums ?? identities?.map((i) => (0, rfc7951_1.identityToken)(i.module, i.name, identities));
|
|
46
47
|
if (options)
|
|
47
48
|
leaf.enum = [...options];
|
|
48
49
|
return leaf;
|
|
@@ -88,7 +88,7 @@ function decodeLeaf(node, raw) {
|
|
|
88
88
|
return group;
|
|
89
89
|
}
|
|
90
90
|
case 'identityref':
|
|
91
|
-
return typeof raw === 'string' ? (0, rfc7951_1.
|
|
91
|
+
return typeof raw === 'string' ? (0, rfc7951_1.wireToFormIdentity)(raw, node.module, node.type) : raw;
|
|
92
92
|
default:
|
|
93
93
|
return raw;
|
|
94
94
|
}
|
|
@@ -17,14 +17,32 @@ export declare function isStringEncoded(base: YangBase): boolean;
|
|
|
17
17
|
* so it never reaches this function.
|
|
18
18
|
*/
|
|
19
19
|
export declare function toFormLeafType(t: YangType): LeafType;
|
|
20
|
+
type Identity = {
|
|
21
|
+
name: string;
|
|
22
|
+
module: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* The token an identityref takes in the *form* (dropdown option and form value):
|
|
26
|
+
* the bare identity name, or a fully-qualified `module:name` when the bare name
|
|
27
|
+
* is ambiguous — the same local name is derived from more than one module.
|
|
28
|
+
* Keeping ambiguous identities qualified lets {@link qualifyIdentity} recover the
|
|
29
|
+
* exact identity on write-back; unambiguous ones stay bare for a clean UI.
|
|
30
|
+
*/
|
|
31
|
+
export declare function identityToken(module: string, name: string, identities: Identity[]): string;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve an RFC 7951 identityref *wire* value to its form {@link identityToken}.
|
|
34
|
+
* A qualified `module:name` names that exact identity; a bare name is an identity
|
|
35
|
+
* in the leaf's own module (RFC 7951 §6.8).
|
|
36
|
+
*/
|
|
37
|
+
export declare function wireToFormIdentity(wire: string, leafModule: string, t: YangType): string;
|
|
20
38
|
/**
|
|
21
39
|
* RFC 7951 identityref value (§6.8): `module:identity` when the identity is
|
|
22
40
|
* defined in a different module than the referencing leaf, otherwise the bare
|
|
23
|
-
* identity name.
|
|
41
|
+
* identity name. The incoming `token` is the form value — bare, or `module:name`
|
|
42
|
+
* when the local name is ambiguous (see {@link identityToken}) — so the exact
|
|
43
|
+
* identity is recovered even when two modules derive the same name.
|
|
24
44
|
*/
|
|
25
|
-
export declare function qualifyIdentity(
|
|
26
|
-
/** The local identity name, dropping any `module:` prefix. */
|
|
27
|
-
export declare function bareIdentity(value: string): string;
|
|
45
|
+
export declare function qualifyIdentity(token: string, leafModule: string, t: YangType): string;
|
|
28
46
|
/**
|
|
29
47
|
* RFC 7951 member name for a node (§4). A name is qualified `module:name` when
|
|
30
48
|
* the node is at the top level (no parent module) or its module differs from its
|
|
@@ -36,3 +54,4 @@ export declare function splitQualified(member: string): {
|
|
|
36
54
|
module?: string;
|
|
37
55
|
name: string;
|
|
38
56
|
};
|
|
57
|
+
export {};
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.isStringEncoded = isStringEncoded;
|
|
4
4
|
exports.toFormLeafType = toFormLeafType;
|
|
5
|
+
exports.identityToken = identityToken;
|
|
6
|
+
exports.wireToFormIdentity = wireToFormIdentity;
|
|
5
7
|
exports.qualifyIdentity = qualifyIdentity;
|
|
6
|
-
exports.bareIdentity = bareIdentity;
|
|
7
8
|
exports.qualifiedName = qualifiedName;
|
|
8
9
|
exports.splitQualified = splitQualified;
|
|
9
10
|
/**
|
|
@@ -51,19 +52,54 @@ function toFormLeafType(t) {
|
|
|
51
52
|
return 'number';
|
|
52
53
|
return 'string';
|
|
53
54
|
}
|
|
55
|
+
/** True when more than one derived identity shares this local name. */
|
|
56
|
+
function nameIsAmbiguous(identities, name) {
|
|
57
|
+
return identities.filter((i) => i.name === name).length > 1;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The token an identityref takes in the *form* (dropdown option and form value):
|
|
61
|
+
* the bare identity name, or a fully-qualified `module:name` when the bare name
|
|
62
|
+
* is ambiguous — the same local name is derived from more than one module.
|
|
63
|
+
* Keeping ambiguous identities qualified lets {@link qualifyIdentity} recover the
|
|
64
|
+
* exact identity on write-back; unambiguous ones stay bare for a clean UI.
|
|
65
|
+
*/
|
|
66
|
+
function identityToken(module, name, identities) {
|
|
67
|
+
return nameIsAmbiguous(identities, name) ? `${module}:${name}` : name;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Resolve an RFC 7951 identityref *wire* value to its form {@link identityToken}.
|
|
71
|
+
* A qualified `module:name` names that exact identity; a bare name is an identity
|
|
72
|
+
* in the leaf's own module (RFC 7951 §6.8).
|
|
73
|
+
*/
|
|
74
|
+
function wireToFormIdentity(wire, leafModule, t) {
|
|
75
|
+
const identities = t.identities ?? [];
|
|
76
|
+
const id = resolveIdentity(wire, leafModule, identities);
|
|
77
|
+
return id ? identityToken(id.module, id.name, identities) : splitQualified(wire).name;
|
|
78
|
+
}
|
|
54
79
|
/**
|
|
55
80
|
* RFC 7951 identityref value (§6.8): `module:identity` when the identity is
|
|
56
81
|
* defined in a different module than the referencing leaf, otherwise the bare
|
|
57
|
-
* identity name.
|
|
82
|
+
* identity name. The incoming `token` is the form value — bare, or `module:name`
|
|
83
|
+
* when the local name is ambiguous (see {@link identityToken}) — so the exact
|
|
84
|
+
* identity is recovered even when two modules derive the same name.
|
|
58
85
|
*/
|
|
59
|
-
function qualifyIdentity(
|
|
60
|
-
const
|
|
61
|
-
|
|
86
|
+
function qualifyIdentity(token, leafModule, t) {
|
|
87
|
+
const identities = t.identities ?? [];
|
|
88
|
+
const { module, name } = splitQualified(token);
|
|
89
|
+
const id = resolveIdentity(token, leafModule, identities);
|
|
90
|
+
const idModule = id ? id.module : module ?? leafModule;
|
|
91
|
+
return idModule !== leafModule ? `${idModule}:${name}` : name;
|
|
62
92
|
}
|
|
63
|
-
/**
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
93
|
+
/**
|
|
94
|
+
* The specific identity a bare-or-qualified reference points at: matched on
|
|
95
|
+
* module + name when qualified (or when the leaf's own module has that name),
|
|
96
|
+
* else the first identity with that local name.
|
|
97
|
+
*/
|
|
98
|
+
function resolveIdentity(ref, leafModule, identities) {
|
|
99
|
+
const { module, name } = splitQualified(ref);
|
|
100
|
+
const owner = module ?? leafModule;
|
|
101
|
+
return identities.find((i) => i.name === name && i.module === owner)
|
|
102
|
+
?? identities.find((i) => i.name === name);
|
|
67
103
|
}
|
|
68
104
|
/**
|
|
69
105
|
* RFC 7951 member name for a node (§4). A name is qualified `module:name` when
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ng-form-foundry-transformers",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Framework-agnostic Node + TypeScript catalog of source-format transformers: turn a YANG model (and more) into an ng-form-foundry schema and revert the edited form value back to the source format.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ng-form-foundry",
|