ng-form-foundry-transformers 0.5.0 → 0.5.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.
- package/README.md +17 -0
- package/dist/core/infer.d.ts +3 -2
- package/dist/core/infer.js +7 -4
- package/dist/core/merge-inferred.d.ts +20 -0
- package/dist/core/merge-inferred.js +46 -0
- package/dist/core/schema-keys.d.ts +33 -0
- package/dist/core/schema-keys.js +57 -0
- package/dist/transformers/json/json-transformer.d.ts +35 -7
- package/dist/transformers/json/json-transformer.js +75 -11
- package/dist/transformers/libconfig/libconfig-transformer.d.ts +28 -2
- package/dist/transformers/libconfig/libconfig-transformer.js +27 -4
- package/dist/transformers/libconfig/revert.d.ts +14 -2
- package/dist/transformers/libconfig/revert.js +30 -13
- package/dist/transformers/libconfig/schema.d.ts +11 -0
- package/dist/transformers/libconfig/schema.js +60 -0
- package/dist/transformers/yaml/index.d.ts +1 -1
- package/dist/transformers/yaml/revert.d.ts +8 -1
- package/dist/transformers/yaml/revert.js +43 -11
- package/dist/transformers/yaml/yaml-transformer.d.ts +32 -5
- package/dist/transformers/yaml/yaml-transformer.js +26 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -155,6 +155,15 @@ enabled, so the output validates against the source schema (opt out with
|
|
|
155
155
|
`schemaOptions: { optionalPresence: false }`); a required property mapping to a
|
|
156
156
|
choice is marked `mandatory`.
|
|
157
157
|
|
|
158
|
+
**The schema may cover any slice of the document.** Keys it does not mention
|
|
159
|
+
are governed by `unknownKeys` (YAML, JSON, and libconfig alike):
|
|
160
|
+
`'preserve'` (default) keeps them verbatim on save — a partial schema edits
|
|
161
|
+
its fields without erasing the rest of a real-world config; `'drop'` deletes
|
|
162
|
+
them — for an intentionally complete schema (sanitizing, template
|
|
163
|
+
enforcement); `'edit'` surfaces them as editable fields typed by the
|
|
164
|
+
document's own values, merged under the JSON Schema in document order, so
|
|
165
|
+
nothing in the file is invisible.
|
|
166
|
+
|
|
158
167
|
## JSON transformer
|
|
159
168
|
|
|
160
169
|
Same as YAML, for **JSON config files** — `jsonTransformer.toSchema(json, { schema? })`
|
|
@@ -292,6 +301,14 @@ npm test # node:test on the compiled output (no Python needed)
|
|
|
292
301
|
|
|
293
302
|
## Status
|
|
294
303
|
|
|
304
|
+
`0.5.1` — **partial JSON Schemas edit safely**: the new `unknownKeys` option
|
|
305
|
+
(YAML, JSON, libconfig) governs keys the schema does not cover — `'preserve'`
|
|
306
|
+
(default) keeps them verbatim on save, `'drop'` deletes them for
|
|
307
|
+
intentionally complete schemas, `'edit'` surfaces them as editable fields
|
|
308
|
+
typed by the document's own values. The YAML binding is now
|
|
309
|
+
`YamlBinding { doc, schema? }` instead of the bare parsed document (bindings
|
|
310
|
+
are opaque; code that hands them straight back to `toSource` is unaffected).
|
|
311
|
+
|
|
295
312
|
`0.5.0` — the **libconfig transformer is stable**: the one-time beta warning
|
|
296
313
|
is gone (and with it the `resetLibconfigBetaWarning` helper), and the format's
|
|
297
314
|
guarantees and known limitations are documented in the transformers guide.
|
package/dist/core/infer.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { NodeGroup } from './schema';
|
|
2
2
|
/**
|
|
3
|
-
* Infer a root {@link NodeGroup} from a parsed data object
|
|
4
|
-
*
|
|
3
|
+
* Infer a root {@link NodeGroup} from a parsed data object — the whole form
|
|
4
|
+
* when no JSON Schema is given, and the layer `mergeInferred` overlays under
|
|
5
|
+
* `unknownKeys: 'edit'`. Structure and types come from the values themselves:
|
|
5
6
|
*
|
|
6
7
|
* - object → nodeGroup (recursed)
|
|
7
8
|
* - array of objects → nodeGroupList (item schema = union of the items' keys)
|
package/dist/core/infer.js
CHANGED
|
@@ -3,8 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.inferNodeGroup = inferNodeGroup;
|
|
4
4
|
const ROOT = '__root__';
|
|
5
5
|
/**
|
|
6
|
-
* Infer a root {@link NodeGroup} from a parsed data object
|
|
7
|
-
*
|
|
6
|
+
* Infer a root {@link NodeGroup} from a parsed data object — the whole form
|
|
7
|
+
* when no JSON Schema is given, and the layer `mergeInferred` overlays under
|
|
8
|
+
* `unknownKeys: 'edit'`. Structure and types come from the values themselves:
|
|
8
9
|
*
|
|
9
10
|
* - object → nodeGroup (recursed)
|
|
10
11
|
* - array of objects → nodeGroupList (item schema = union of the items' keys)
|
|
@@ -21,7 +22,9 @@ function inferNodeGroup(data, name = ROOT) {
|
|
|
21
22
|
return group;
|
|
22
23
|
}
|
|
23
24
|
function objectToNodeGroup(data, name) {
|
|
24
|
-
|
|
25
|
+
// Null prototype: data keys are arbitrary, and assigning a `__proto__` key
|
|
26
|
+
// onto a plain object would silently set the record's prototype instead.
|
|
27
|
+
const children = Object.create(null);
|
|
25
28
|
for (const [key, value] of Object.entries(data)) {
|
|
26
29
|
children[key] = inferNode(key, value);
|
|
27
30
|
}
|
|
@@ -45,7 +48,7 @@ function inferArray(name, items) {
|
|
|
45
48
|
}
|
|
46
49
|
/** A representative object with every key seen across `items`, each mapped to the first non-null sample. */
|
|
47
50
|
function unionKeys(items) {
|
|
48
|
-
const merged =
|
|
51
|
+
const merged = Object.create(null);
|
|
49
52
|
for (const item of items) {
|
|
50
53
|
for (const [key, value] of Object.entries(item)) {
|
|
51
54
|
if (!(key in merged) || (merged[key] == null && value != null))
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema union for `unknownKeys: 'edit'`: overlay a user-supplied JSON
|
|
3
|
+
* Schema's {@link NodeGroup} onto the schema *inferred from the document
|
|
4
|
+
* itself*, so covered keys keep their typed, validated, labeled nodes while
|
|
5
|
+
* every key the JSON Schema does not mention still renders — editable, typed
|
|
6
|
+
* by inference (see `core/infer.ts` and the per-format inference in the
|
|
7
|
+
* transformers). Counterpart of `core/schema-keys.ts`, which handles the
|
|
8
|
+
* *preserve-invisibly* answer to the same question.
|
|
9
|
+
*/
|
|
10
|
+
import type { NodeGroup } from './schema';
|
|
11
|
+
/**
|
|
12
|
+
* Merge per key, recursively. Document order wins for placement: the
|
|
13
|
+
* inferred children mirror the source document, so present keys render in
|
|
14
|
+
* file order with schema-only keys (e.g. presence-able additions) appended
|
|
15
|
+
* after, in schema order. Where both sides describe a key, the schema wins —
|
|
16
|
+
* except that groups and group-lists merge structurally, so a partially
|
|
17
|
+
* covered subtree keeps its uncovered fields at any depth. Group metadata
|
|
18
|
+
* (name, label, presence, bounds) comes from the schema side.
|
|
19
|
+
*/
|
|
20
|
+
export declare function mergeInferred(schema: NodeGroup, inferred: NodeGroup): NodeGroup;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.mergeInferred = mergeInferred;
|
|
4
|
+
/**
|
|
5
|
+
* Merge per key, recursively. Document order wins for placement: the
|
|
6
|
+
* inferred children mirror the source document, so present keys render in
|
|
7
|
+
* file order with schema-only keys (e.g. presence-able additions) appended
|
|
8
|
+
* after, in schema order. Where both sides describe a key, the schema wins —
|
|
9
|
+
* except that groups and group-lists merge structurally, so a partially
|
|
10
|
+
* covered subtree keeps its uncovered fields at any depth. Group metadata
|
|
11
|
+
* (name, label, presence, bounds) comes from the schema side.
|
|
12
|
+
*/
|
|
13
|
+
function mergeInferred(schema, inferred) {
|
|
14
|
+
const children = Object.create(null);
|
|
15
|
+
for (const [name, inferredChild] of Object.entries(inferred.children)) {
|
|
16
|
+
const schemaChild = schema.children[name];
|
|
17
|
+
children[name] = schemaChild ? mergeNode(schemaChild, inferredChild) : inferredChild;
|
|
18
|
+
}
|
|
19
|
+
for (const [name, schemaChild] of Object.entries(schema.children)) {
|
|
20
|
+
if (!(name in children))
|
|
21
|
+
children[name] = schemaChild;
|
|
22
|
+
}
|
|
23
|
+
return { ...schema, children };
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* One key described by both sides: groups and group-lists merge recursively;
|
|
27
|
+
* everything else — leaves, choices, maps, and shape disagreements — takes
|
|
28
|
+
* the schema node, which is authoritative wherever it speaks. One inferred
|
|
29
|
+
* fact does carry over: a leaf's `radix` display hint, which only the
|
|
30
|
+
* document knows (JSON Schema has no radix vocabulary).
|
|
31
|
+
*/
|
|
32
|
+
function mergeNode(schema, inferred) {
|
|
33
|
+
if (schema.kind === 'nodeGroup' && inferred.kind === 'nodeGroup') {
|
|
34
|
+
return mergeInferred(schema, inferred);
|
|
35
|
+
}
|
|
36
|
+
if (schema.kind === 'nodeGroupList' && inferred.kind === 'nodeGroupList') {
|
|
37
|
+
return { ...schema, type: mergeInferred(schema.type, inferred.type) };
|
|
38
|
+
}
|
|
39
|
+
if ((schema.kind === 'leaf' || schema.kind === 'leafList') &&
|
|
40
|
+
schema.kind === inferred.kind &&
|
|
41
|
+
inferred.radix &&
|
|
42
|
+
!schema.radix) {
|
|
43
|
+
return { ...schema, radix: inferred.radix };
|
|
44
|
+
}
|
|
45
|
+
return schema;
|
|
46
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schema-born key resolution for partial-schema reverts.
|
|
3
|
+
*
|
|
4
|
+
* A transformer given a JSON Schema builds its form from the produced
|
|
5
|
+
* {@link NodeGroup} — so `serializeForm` can only ever emit keys that schema
|
|
6
|
+
* describes. When the edited value is written back onto the original
|
|
7
|
+
* document, the value is authoritative for those **schema-born** paths only;
|
|
8
|
+
* every other key was never carried by the form and must be preserved.
|
|
9
|
+
* These helpers answer "which keys can the value legitimately speak for?" at
|
|
10
|
+
* each document level, mirroring the wire encoding of
|
|
11
|
+
* ng-form-foundry's `serializeForm`/`toWireValue`.
|
|
12
|
+
*/
|
|
13
|
+
import type { NodeGroup, NodeType } from './schema';
|
|
14
|
+
/**
|
|
15
|
+
* The schema-born key set of one document level, as `name → child schema`
|
|
16
|
+
* for recursion. `undefined` means "no schema in play": every key is
|
|
17
|
+
* value-authoritative. A key absent from a defined map is not schema-born
|
|
18
|
+
* and must be preserved verbatim.
|
|
19
|
+
*/
|
|
20
|
+
export type SchemaKeys = Map<string, NodeType | undefined> | undefined;
|
|
21
|
+
/**
|
|
22
|
+
* The keys `serializeForm` can emit for a schema node standing at a document
|
|
23
|
+
* object/group, with each key's schema for recursion. A nodeGroup
|
|
24
|
+
* contributes its children by key — a choice child included, since the wire
|
|
25
|
+
* value keeps a choice at its own key (`toWireValue` strips only the case
|
|
26
|
+
* discriminator). Standing at a choice, the keys are the union of every
|
|
27
|
+
* case's fields: the form only emits the active case, so the other cases'
|
|
28
|
+
* keys behave like any non-emitted schema key. A map is open-keyed — every
|
|
29
|
+
* key is schema-born and entries recurse with the map's value schema.
|
|
30
|
+
*/
|
|
31
|
+
export declare function childrenOf(node: NodeType): SchemaKeys;
|
|
32
|
+
/** The per-item schema when `node` describes a list of groups, else undefined. */
|
|
33
|
+
export declare function itemSchemaOf(node: NodeType | undefined): NodeGroup | undefined;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.childrenOf = childrenOf;
|
|
4
|
+
exports.itemSchemaOf = itemSchemaOf;
|
|
5
|
+
/**
|
|
6
|
+
* The keys `serializeForm` can emit for a schema node standing at a document
|
|
7
|
+
* object/group, with each key's schema for recursion. A nodeGroup
|
|
8
|
+
* contributes its children by key — a choice child included, since the wire
|
|
9
|
+
* value keeps a choice at its own key (`toWireValue` strips only the case
|
|
10
|
+
* discriminator). Standing at a choice, the keys are the union of every
|
|
11
|
+
* case's fields: the form only emits the active case, so the other cases'
|
|
12
|
+
* keys behave like any non-emitted schema key. A map is open-keyed — every
|
|
13
|
+
* key is schema-born and entries recurse with the map's value schema.
|
|
14
|
+
*/
|
|
15
|
+
function childrenOf(node) {
|
|
16
|
+
switch (node.kind) {
|
|
17
|
+
case 'nodeGroup':
|
|
18
|
+
return new Map(Object.entries(node.children));
|
|
19
|
+
case 'choice': {
|
|
20
|
+
const out = new Map();
|
|
21
|
+
for (const [name, field] of caseEntries(node))
|
|
22
|
+
out.set(name, field);
|
|
23
|
+
return out;
|
|
24
|
+
}
|
|
25
|
+
case 'map':
|
|
26
|
+
return new OpenKeys(node.value);
|
|
27
|
+
default:
|
|
28
|
+
return undefined; // leaf/leafList/list shapes carry no key semantics
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** The per-item schema when `node` describes a list of groups, else undefined. */
|
|
32
|
+
function itemSchemaOf(node) {
|
|
33
|
+
return node?.kind === 'nodeGroupList' ? node.type : undefined;
|
|
34
|
+
}
|
|
35
|
+
/** Field name → schema across every case of a choice (leaf-bodied cases too). */
|
|
36
|
+
function* caseEntries(choice) {
|
|
37
|
+
for (const body of Object.values(choice.cases)) {
|
|
38
|
+
if ('kind' in body)
|
|
39
|
+
yield [body.name, body];
|
|
40
|
+
else
|
|
41
|
+
for (const [name, field] of Object.entries(body))
|
|
42
|
+
yield [name, field];
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** A `SchemaKeys` map matching every key — a map node's open dictionary. */
|
|
46
|
+
class OpenKeys extends Map {
|
|
47
|
+
constructor(valueSchema) {
|
|
48
|
+
super();
|
|
49
|
+
this.valueSchema = valueSchema;
|
|
50
|
+
}
|
|
51
|
+
has(_key) {
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
get(_key) {
|
|
55
|
+
return this.valueSchema;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FormValue, Thesaurus } from '../../core/schema';
|
|
1
|
+
import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
|
|
2
2
|
import type { TransformResult } from '../../core/transformer';
|
|
3
3
|
import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
|
|
4
4
|
/** Options for {@link jsonTransformer}'s `toSchema`. */
|
|
@@ -19,6 +19,18 @@ export interface JsonOptions {
|
|
|
19
19
|
* case-insensitively; never paths.
|
|
20
20
|
*/
|
|
21
21
|
thesaurus?: Thesaurus;
|
|
22
|
+
/**
|
|
23
|
+
* Schema-driven mode only: what happens to keys the JSON Schema does not
|
|
24
|
+
* cover. `'preserve'` (default) keeps them — the form never carried them,
|
|
25
|
+
* so a partial schema edits its slice without erasing the rest. `'drop'`
|
|
26
|
+
* makes the edited value authoritative for the whole document, deleting
|
|
27
|
+
* uncovered keys — for consumers whose schema is intentionally complete.
|
|
28
|
+
* `'edit'` surfaces them instead: uncovered keys render as editable fields
|
|
29
|
+
* typed by the data (the inferred schema merged under the JSON Schema), so
|
|
30
|
+
* nothing is invisible and the value covers the whole document. Ignored
|
|
31
|
+
* without a `schema`.
|
|
32
|
+
*/
|
|
33
|
+
unknownKeys?: 'preserve' | 'drop' | 'edit';
|
|
22
34
|
}
|
|
23
35
|
/** How the source was formatted, so `toSource` re-emits it the same way. */
|
|
24
36
|
export interface JsonFormat {
|
|
@@ -32,6 +44,19 @@ export interface JsonFormat {
|
|
|
32
44
|
* them back as unquoted numbers so precision survives the round-trip.
|
|
33
45
|
*/
|
|
34
46
|
bigInts: string[];
|
|
47
|
+
/**
|
|
48
|
+
* Present in schema-driven mode with `unknownKeys: 'preserve'` (the
|
|
49
|
+
* default) or `'edit'`: the original parsed data and the NodeGroup the
|
|
50
|
+
* form was built from (the JSON Schema's own under `'preserve'`, the
|
|
51
|
+
* inferred-merged one under `'edit'`). `toSource` then treats the value as
|
|
52
|
+
* authoritative for schema-born paths only and merges it over the
|
|
53
|
+
* original, so uncovered keys survive in their original key order — under
|
|
54
|
+
* `'edit'` that protection is left covering only the keys no form field
|
|
55
|
+
* can carry (e.g. a key inside a covered choice's object that no case
|
|
56
|
+
* names).
|
|
57
|
+
*/
|
|
58
|
+
original?: FormValue;
|
|
59
|
+
schema?: NodeGroup;
|
|
35
60
|
}
|
|
36
61
|
/**
|
|
37
62
|
* A JSON {@link Transformer}: turn a JSON config document into a form and write
|
|
@@ -39,12 +64,15 @@ export interface JsonFormat {
|
|
|
39
64
|
* `core` builders the YAML transformer uses — only parsing (`JSON.parse`) and
|
|
40
65
|
* serialization (`JSON.stringify`) are JSON-specific.
|
|
41
66
|
*
|
|
42
|
-
* Standard JSON has no comments, so revert re-serializes the
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
67
|
+
* Standard JSON has no comments, so revert re-serializes, preserving the
|
|
68
|
+
* source's indent width and trailing newline. In schema-driven mode with
|
|
69
|
+
* `unknownKeys: 'preserve'` or `'edit'` the value is merged over the original
|
|
70
|
+
* data at schema-born paths ({@link mergeSchemaBorn}), so uncovered keys and
|
|
71
|
+
* the original key order survive; otherwise the edited value is serialized
|
|
72
|
+
* as-is, key order following the form value. For JSON **with comments**
|
|
73
|
+
* (JSONC), parse and edit it as YAML with
|
|
74
|
+
* {@link import('../yaml/yaml-transformer').yamlTransformer} instead —
|
|
75
|
+
* `JSON.parse` rejects comments.
|
|
48
76
|
*
|
|
49
77
|
* Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
|
|
50
78
|
* as strings in the form value and re-emitted verbatim as unquoted numbers (the
|
|
@@ -5,18 +5,23 @@ const thesaurus_1 = require("../../core/thesaurus");
|
|
|
5
5
|
const infer_1 = require("../../core/infer");
|
|
6
6
|
const json_schema_1 = require("../../core/json-schema");
|
|
7
7
|
const bigint_1 = require("../../core/bigint");
|
|
8
|
+
const schema_keys_1 = require("../../core/schema-keys");
|
|
9
|
+
const merge_inferred_1 = require("../../core/merge-inferred");
|
|
8
10
|
/**
|
|
9
11
|
* A JSON {@link Transformer}: turn a JSON config document into a form and write
|
|
10
12
|
* the edited value back to JSON. The form is built by the same format-agnostic
|
|
11
13
|
* `core` builders the YAML transformer uses — only parsing (`JSON.parse`) and
|
|
12
14
|
* serialization (`JSON.stringify`) are JSON-specific.
|
|
13
15
|
*
|
|
14
|
-
* Standard JSON has no comments, so revert re-serializes the
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Standard JSON has no comments, so revert re-serializes, preserving the
|
|
17
|
+
* source's indent width and trailing newline. In schema-driven mode with
|
|
18
|
+
* `unknownKeys: 'preserve'` or `'edit'` the value is merged over the original
|
|
19
|
+
* data at schema-born paths ({@link mergeSchemaBorn}), so uncovered keys and
|
|
20
|
+
* the original key order survive; otherwise the edited value is serialized
|
|
21
|
+
* as-is, key order following the form value. For JSON **with comments**
|
|
22
|
+
* (JSONC), parse and edit it as YAML with
|
|
23
|
+
* {@link import('../yaml/yaml-transformer').yamlTransformer} instead —
|
|
24
|
+
* `JSON.parse` rejects comments.
|
|
20
25
|
*
|
|
21
26
|
* Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
|
|
22
27
|
* as strings in the form value and re-emitted verbatim as unquoted numbers (the
|
|
@@ -28,26 +33,80 @@ exports.jsonTransformer = {
|
|
|
28
33
|
const bigInts = [];
|
|
29
34
|
const parsed = JSON.parse(source, bigIntReviver);
|
|
30
35
|
const data = collectBigInts(parsed ?? {}, [], bigInts);
|
|
31
|
-
const
|
|
36
|
+
const unknownKeys = options?.unknownKeys ?? 'preserve';
|
|
37
|
+
const fromJsonSchema = options?.schema
|
|
32
38
|
? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
|
|
33
|
-
:
|
|
39
|
+
: undefined;
|
|
40
|
+
const schema = fromJsonSchema && unknownKeys === 'edit'
|
|
41
|
+
? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
|
|
42
|
+
: fromJsonSchema ?? (0, infer_1.inferNodeGroup)(data, options?.rootName);
|
|
34
43
|
const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
|
|
44
|
+
// 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit' on
|
|
45
|
+
// the merged one (schema-born ≈ everything, but keys no form field can
|
|
46
|
+
// carry stay protected); 'drop' and inferred mode leave the value
|
|
47
|
+
// authoritative for the whole document.
|
|
48
|
+
const preserveUnknown = fromJsonSchema != null && unknownKeys !== 'drop';
|
|
35
49
|
const binding = {
|
|
36
50
|
indent: detectIndent(source),
|
|
37
51
|
trailingNewline: source.endsWith('\n'),
|
|
38
52
|
bigInts,
|
|
53
|
+
original: preserveUnknown ? data : undefined,
|
|
54
|
+
schema: preserveUnknown ? schema : undefined,
|
|
39
55
|
};
|
|
40
56
|
return { schema: labeled, binding, initialValue: data };
|
|
41
57
|
},
|
|
42
58
|
toSource(value, binding) {
|
|
59
|
+
const merged = binding.schema && binding.original !== undefined
|
|
60
|
+
? mergeSchemaBorn(binding.original, value, (0, schema_keys_1.childrenOf)(binding.schema))
|
|
61
|
+
: value;
|
|
43
62
|
const paths = new Set(binding.bigInts);
|
|
44
|
-
const prepared = paths.size ? markBigInts(
|
|
63
|
+
const prepared = paths.size ? markBigInts(merged, [], paths) : merged;
|
|
45
64
|
let text = JSON.stringify(prepared, null, binding.indent);
|
|
46
65
|
if (paths.size)
|
|
47
66
|
text = text.replace(BIGINT_MARK_RE, '$1');
|
|
48
67
|
return binding.trailingNewline ? text + '\n' : text;
|
|
49
68
|
},
|
|
50
69
|
};
|
|
70
|
+
/**
|
|
71
|
+
* Merge the edited value over the original data, value-authoritative for
|
|
72
|
+
* schema-born keys only: an uncovered key keeps its original value and its
|
|
73
|
+
* position in the key order; a covered key takes the edited value (recursing
|
|
74
|
+
* where both sides are objects, and per index into arrays of groups); a
|
|
75
|
+
* covered key absent from the value is omitted (a presence toggle off);
|
|
76
|
+
* covered keys new to the document append after the originals.
|
|
77
|
+
*/
|
|
78
|
+
function mergeSchemaBorn(original, value, schema) {
|
|
79
|
+
if (!isPlainObject(original) || !isPlainObject(value) || !schema)
|
|
80
|
+
return value;
|
|
81
|
+
// Null-prototype output and own-key checks: document keys are arbitrary, so
|
|
82
|
+
// `__proto__` must stay a data key and `toString`/`constructor` must not
|
|
83
|
+
// resolve through the prototype chain (which would drop or fabricate keys).
|
|
84
|
+
const out = Object.create(null);
|
|
85
|
+
for (const key of Object.keys(original)) {
|
|
86
|
+
if (!schema.has(key)) {
|
|
87
|
+
out[key] = original[key]; // not schema-born: verbatim, in place
|
|
88
|
+
}
|
|
89
|
+
else if (hasOwn(value, key)) {
|
|
90
|
+
out[key] = mergeChild(original[key], value[key], schema.get(key));
|
|
91
|
+
} // covered + absent: deleted
|
|
92
|
+
}
|
|
93
|
+
for (const key of Object.keys(value)) {
|
|
94
|
+
if (!(key in out) && !hasOwn(original, key) && schema.has(key))
|
|
95
|
+
out[key] = value[key];
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
/** Per-key recursion: objects merge by their child schema, group arrays per index. */
|
|
100
|
+
function mergeChild(original, value, schema) {
|
|
101
|
+
if (schema && isPlainObject(original) && isPlainObject(value)) {
|
|
102
|
+
return mergeSchemaBorn(original, value, (0, schema_keys_1.childrenOf)(schema));
|
|
103
|
+
}
|
|
104
|
+
const itemSchema = (0, schema_keys_1.itemSchemaOf)(schema);
|
|
105
|
+
if (itemSchema && Array.isArray(original) && Array.isArray(value)) {
|
|
106
|
+
return value.map((item, i) => i < original.length ? mergeChild(original[i], item, itemSchema) : item);
|
|
107
|
+
}
|
|
108
|
+
return value;
|
|
109
|
+
}
|
|
51
110
|
/** Indent width (in spaces) of the first indented line, or 2 if none/tabs. */
|
|
52
111
|
function detectIndent(source) {
|
|
53
112
|
const match = source.match(/\n( +)\S/);
|
|
@@ -102,7 +161,8 @@ function collectBigInts(node, path, out) {
|
|
|
102
161
|
if (Array.isArray(node))
|
|
103
162
|
return node.map((v, i) => collectBigInts(v, [...path, i], out));
|
|
104
163
|
if (isPlainObject(node)) {
|
|
105
|
-
|
|
164
|
+
// Null prototype: a `__proto__` document key must stay a data key.
|
|
165
|
+
const res = Object.create(null);
|
|
106
166
|
for (const key of Object.keys(node))
|
|
107
167
|
res[key] = collectBigInts(node[key], [...path, key], out);
|
|
108
168
|
return res;
|
|
@@ -121,7 +181,7 @@ function markBigInts(node, path, paths) {
|
|
|
121
181
|
if (Array.isArray(node))
|
|
122
182
|
return node.map((v, i) => markBigInts(v, [...path, i], paths));
|
|
123
183
|
if (isPlainObject(node)) {
|
|
124
|
-
const res =
|
|
184
|
+
const res = Object.create(null);
|
|
125
185
|
for (const key of Object.keys(node))
|
|
126
186
|
res[key] = markBigInts(node[key], [...path, key], paths);
|
|
127
187
|
return res;
|
|
@@ -131,3 +191,7 @@ function markBigInts(node, path, paths) {
|
|
|
131
191
|
function isPlainObject(v) {
|
|
132
192
|
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
133
193
|
}
|
|
194
|
+
/** Own-key membership, immune to `Object.prototype` members and null-prototype objects. */
|
|
195
|
+
function hasOwn(obj, key) {
|
|
196
|
+
return Object.prototype.hasOwnProperty.call(obj, key);
|
|
197
|
+
}
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
* are then shown read-only. A JSON Schema in the options unlocks typed empty
|
|
13
13
|
* collections, presence toggles for optional settings, enums, and ranges.
|
|
14
14
|
*/
|
|
15
|
-
import type { FormValue, Thesaurus } from '../../core/schema';
|
|
15
|
+
import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
|
|
16
16
|
import type { TransformResult } from '../../core/transformer';
|
|
17
17
|
import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
|
|
18
18
|
import { type CfgGroup } from './parser';
|
|
@@ -34,6 +34,20 @@ export interface LibconfigOptions {
|
|
|
34
34
|
* line verbatim and edits only this file's own settings.
|
|
35
35
|
*/
|
|
36
36
|
includes?: 'reject' | 'opaque';
|
|
37
|
+
/**
|
|
38
|
+
* Schema-driven mode only: what happens to settings the JSON Schema does
|
|
39
|
+
* not cover. `'preserve'` (default) keeps them byte-verbatim in place —
|
|
40
|
+
* the form never carried them, so a partial schema edits its slice without
|
|
41
|
+
* erasing the rest. `'drop'` makes the edited value authoritative for the
|
|
42
|
+
* whole document, deleting uncovered settings — for consumers whose schema
|
|
43
|
+
* is intentionally complete (sanitizing a config, enforcing a strict
|
|
44
|
+
* template). `'edit'` surfaces them instead: uncovered settings render as
|
|
45
|
+
* editable fields typed by the document's own literals (the inferred
|
|
46
|
+
* schema merged under the JSON Schema — see `mergeInferred`), so nothing
|
|
47
|
+
* is invisible and the value covers the whole document. Ignored without a
|
|
48
|
+
* `schema`, where the inferred value covers every setting anyway.
|
|
49
|
+
*/
|
|
50
|
+
unknownKeys?: 'preserve' | 'drop' | 'edit';
|
|
37
51
|
/**
|
|
38
52
|
* Display metadata (`label`/`description`/choice `caseLabels`) injected into
|
|
39
53
|
* the produced schema, schema-driven or inferred alike — see
|
|
@@ -42,10 +56,22 @@ export interface LibconfigOptions {
|
|
|
42
56
|
*/
|
|
43
57
|
thesaurus?: Thesaurus;
|
|
44
58
|
}
|
|
45
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* The revert context: the original text, its positioned AST, and — in
|
|
61
|
+
* schema-driven mode with `unknownKeys: 'preserve'` or `'edit'` — the
|
|
62
|
+
* NodeGroup the form was built from (the JSON Schema's own under
|
|
63
|
+
* `'preserve'`, the inferred-merged one under `'edit'`), so `toSource` knows
|
|
64
|
+
* which paths are schema-born. Settings outside those paths were never
|
|
65
|
+
* carried by the form and survive verbatim; under `'edit'` that is only the
|
|
66
|
+
* keys no form field can carry (e.g. a key inside a covered choice's group
|
|
67
|
+
* that no case names). Absent `schema` (inferred mode, or
|
|
68
|
+
* `unknownKeys: 'drop'`), the value covers the whole document and is
|
|
69
|
+
* authoritative everywhere.
|
|
70
|
+
*/
|
|
46
71
|
export interface LibconfigBinding {
|
|
47
72
|
source: string;
|
|
48
73
|
root: CfgGroup;
|
|
74
|
+
schema?: NodeGroup;
|
|
49
75
|
}
|
|
50
76
|
export declare const libconfigTransformer: {
|
|
51
77
|
id: string;
|
|
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.libconfigTransformer = void 0;
|
|
4
4
|
const thesaurus_1 = require("../../core/thesaurus");
|
|
5
5
|
const json_schema_1 = require("../../core/json-schema");
|
|
6
|
+
const merge_inferred_1 = require("../../core/merge-inferred");
|
|
7
|
+
const schema_keys_1 = require("../../core/schema-keys");
|
|
6
8
|
const parser_1 = require("./parser");
|
|
7
9
|
const schema_1 = require("./schema");
|
|
8
10
|
const revert_1 = require("./revert");
|
|
@@ -10,14 +12,35 @@ exports.libconfigTransformer = {
|
|
|
10
12
|
id: 'libconfig',
|
|
11
13
|
toSchema(source, options) {
|
|
12
14
|
const root = (0, parser_1.parseLibconfig)(source, { includes: options?.includes });
|
|
13
|
-
const
|
|
15
|
+
const unknownKeys = options?.unknownKeys ?? 'preserve';
|
|
16
|
+
const fromJsonSchema = options?.schema
|
|
14
17
|
? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
|
|
15
|
-
:
|
|
18
|
+
: undefined;
|
|
19
|
+
const schema = fromJsonSchema && unknownKeys === 'edit'
|
|
20
|
+
? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__'))
|
|
21
|
+
: fromJsonSchema ?? (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
|
|
16
22
|
const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
|
|
17
23
|
const initialValue = (0, schema_1.extractValue)(root, source, options?.schema != null);
|
|
18
|
-
|
|
24
|
+
if (fromJsonSchema && unknownKeys === 'edit') {
|
|
25
|
+
// Uncovered empty collections merged as read-only carries: their value
|
|
26
|
+
// must be the verbatim slice, not the typed [] the extraction produced.
|
|
27
|
+
(0, schema_1.carryUncoveredEmpties)(root, source, initialValue, (0, schema_keys_1.childrenOf)(fromJsonSchema));
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
schema: labeled,
|
|
31
|
+
binding: {
|
|
32
|
+
source,
|
|
33
|
+
root,
|
|
34
|
+
// 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit'
|
|
35
|
+
// on the merged one (schema-born ≈ everything, but settings no form
|
|
36
|
+
// field can carry stay protected); 'drop' and inferred mode leave
|
|
37
|
+
// the value authoritative for the whole document.
|
|
38
|
+
schema: fromJsonSchema && unknownKeys !== 'drop' ? schema : undefined,
|
|
39
|
+
},
|
|
40
|
+
initialValue,
|
|
41
|
+
};
|
|
19
42
|
},
|
|
20
43
|
toSource(value, binding) {
|
|
21
|
-
return (0, revert_1.applyValueToSource)(binding.source, binding.root, value);
|
|
44
|
+
return (0, revert_1.applyValueToSource)(binding.source, binding.root, value, binding.schema);
|
|
22
45
|
},
|
|
23
46
|
};
|
|
@@ -13,6 +13,18 @@
|
|
|
13
13
|
* literal re-emits in hex at its original digit width, an int64 keeps its
|
|
14
14
|
* `L`/`LL` suffix, and values beyond 2^53 travel as exact decimal strings.
|
|
15
15
|
*/
|
|
16
|
+
import type { NodeGroup } from '../../core/schema';
|
|
16
17
|
import type { CfgGroup } from './parser';
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Apply `value` onto the parsed `root` of `source`, returning the new text.
|
|
20
|
+
*
|
|
21
|
+
* Without `schema` (inferred mode) the value is authoritative for the whole
|
|
22
|
+
* document: it was extracted from every setting, so a key it lacks is a
|
|
23
|
+
* deletion. With `schema` (the NodeGroup the form was built from) the value
|
|
24
|
+
* is authoritative for **schema-born paths only** — the form never carried
|
|
25
|
+
* the other settings, so they survive byte-verbatim in their original
|
|
26
|
+
* positions. A partial schema thus edits its slice of an OAI/srsRAN config
|
|
27
|
+
* without erasing keys the schema does not enumerate; absence of a
|
|
28
|
+
* schema-born key still deletes (a presence toggle turned off).
|
|
29
|
+
*/
|
|
30
|
+
export declare function applyValueToSource(source: string, root: CfgGroup, value: Record<string, unknown>, schema?: NodeGroup): string;
|
|
@@ -1,33 +1,50 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.applyValueToSource = applyValueToSource;
|
|
4
|
+
const schema_keys_1 = require("../../core/schema-keys");
|
|
4
5
|
const schema_1 = require("./schema");
|
|
5
|
-
/**
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* Apply `value` onto the parsed `root` of `source`, returning the new text.
|
|
8
|
+
*
|
|
9
|
+
* Without `schema` (inferred mode) the value is authoritative for the whole
|
|
10
|
+
* document: it was extracted from every setting, so a key it lacks is a
|
|
11
|
+
* deletion. With `schema` (the NodeGroup the form was built from) the value
|
|
12
|
+
* is authoritative for **schema-born paths only** — the form never carried
|
|
13
|
+
* the other settings, so they survive byte-verbatim in their original
|
|
14
|
+
* positions. A partial schema thus edits its slice of an OAI/srsRAN config
|
|
15
|
+
* without erasing keys the schema does not enumerate; absence of a
|
|
16
|
+
* schema-born key still deletes (a presence toggle turned off).
|
|
17
|
+
*/
|
|
18
|
+
function applyValueToSource(source, root, value, schema) {
|
|
7
19
|
const edits = [];
|
|
8
|
-
patchGroup(source, root, value, edits);
|
|
20
|
+
patchGroup(source, root, value, edits, schema && (0, schema_keys_1.childrenOf)(schema));
|
|
9
21
|
edits.sort((a, b) => b.start - a.start);
|
|
10
22
|
let out = source;
|
|
11
23
|
for (const e of edits)
|
|
12
24
|
out = out.slice(0, e.start) + e.text + out.slice(e.end);
|
|
13
25
|
return out;
|
|
14
26
|
}
|
|
15
|
-
function patchGroup(src, group, value, edits) {
|
|
27
|
+
function patchGroup(src, group, value, edits, schema) {
|
|
16
28
|
for (const setting of group.settings) {
|
|
17
|
-
if (
|
|
29
|
+
if (schema && !schema.has(setting.name))
|
|
30
|
+
continue; // not schema-born: verbatim
|
|
31
|
+
// Own-key check: `in` would resolve setting names like `toString` through
|
|
32
|
+
// the prototype chain, making them undeletable (and their "value" a function).
|
|
33
|
+
if (!value || !Object.prototype.hasOwnProperty.call(value, setting.name)) {
|
|
18
34
|
edits.push(deleteSetting(src, setting));
|
|
19
35
|
continue;
|
|
20
36
|
}
|
|
21
|
-
patchValue(src, setting.value, value[setting.name], edits);
|
|
37
|
+
patchValue(src, setting.value, value[setting.name], edits, schema?.get(setting.name));
|
|
22
38
|
}
|
|
23
39
|
const known = new Set(group.settings.map((s) => s.name));
|
|
24
|
-
// null/undefined means "absent", never a value: such keys are not added
|
|
25
|
-
|
|
40
|
+
// null/undefined means "absent", never a value: such keys are not added —
|
|
41
|
+
// and under a schema, only schema-born keys may be added at all.
|
|
42
|
+
const added = Object.keys(value ?? {}).filter((k) => !known.has(k) && value[k] != null && (!schema || schema.has(k)));
|
|
26
43
|
if (added.length) {
|
|
27
44
|
edits.push(insertionEdit(src, group, added.map((k) => `${k} = ${serialize(value[k], '')};`)));
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
|
-
function patchValue(src, node, value, edits) {
|
|
47
|
+
function patchValue(src, node, value, edits, schema) {
|
|
31
48
|
switch (node.kind) {
|
|
32
49
|
case 'scalar': {
|
|
33
50
|
if (node.value === value)
|
|
@@ -41,7 +58,7 @@ function patchValue(src, node, value, edits) {
|
|
|
41
58
|
}
|
|
42
59
|
case 'group': {
|
|
43
60
|
if (isRecord(value))
|
|
44
|
-
return patchGroup(src, node, value, edits);
|
|
61
|
+
return patchGroup(src, node, value, edits, schema && (0, schema_keys_1.childrenOf)(schema));
|
|
45
62
|
edits.push({ ...node.span, text: serialize(value, '') }); // shape change: regenerate
|
|
46
63
|
return;
|
|
47
64
|
}
|
|
@@ -61,7 +78,7 @@ function patchValue(src, node, value, edits) {
|
|
|
61
78
|
case 'list': {
|
|
62
79
|
const shape = (0, schema_1.listShape)(node);
|
|
63
80
|
if (shape === 'groups' && Array.isArray(value)) {
|
|
64
|
-
return patchElements(src, node.elements, node, value, edits);
|
|
81
|
+
return patchElements(src, node.elements, node, value, edits, (0, schema_keys_1.itemSchemaOf)(schema));
|
|
65
82
|
}
|
|
66
83
|
if (shape === 'scalars' && Array.isArray(value)) {
|
|
67
84
|
return patchElements(src, node.elements, node, value, edits);
|
|
@@ -89,10 +106,10 @@ function patchValue(src, node, value, edits) {
|
|
|
89
106
|
}
|
|
90
107
|
}
|
|
91
108
|
/** Element-wise patch of an array/list: edit in place, then grow or shrink. */
|
|
92
|
-
function patchElements(src, elements, collection, value, edits) {
|
|
109
|
+
function patchElements(src, elements, collection, value, edits, itemSchema) {
|
|
93
110
|
const shared = Math.min(elements.length, value.length);
|
|
94
111
|
for (let i = 0; i < shared; i++)
|
|
95
|
-
patchValue(src, elements[i], value[i], edits);
|
|
112
|
+
patchValue(src, elements[i], value[i], edits, itemSchema);
|
|
96
113
|
if (value.length > elements.length) {
|
|
97
114
|
const items = value.slice(elements.length).map((v) => serialize(v, ''));
|
|
98
115
|
const at = elements.length ? elements[elements.length - 1].span.end : collection.innerSpan.start;
|
|
@@ -19,6 +19,7 @@
|
|
|
19
19
|
* empty collections editable.
|
|
20
20
|
*/
|
|
21
21
|
import type { NodeGroup } from '../../core/schema';
|
|
22
|
+
import { type SchemaKeys } from '../../core/schema-keys';
|
|
22
23
|
import { type CfgGroup, type CfgList, type CfgValue } from './parser';
|
|
23
24
|
/** Infer the root NodeGroup for a parsed document. */
|
|
24
25
|
export declare function libconfigToNodeGroup(root: CfgGroup, source: string, name: string): NodeGroup;
|
|
@@ -36,4 +37,14 @@ type ListShape = 'groups' | 'scalars' | 'empty' | 'heterogeneous';
|
|
|
36
37
|
export declare function listShape(list: CfgList): ListShape;
|
|
37
38
|
/** The verbatim source slice of a node. */
|
|
38
39
|
export declare function raw(value: CfgValue, source: string): string;
|
|
40
|
+
/**
|
|
41
|
+
* `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
|
|
42
|
+
* with `emptyAsArrays` (schema-covered empty collections are typed and
|
|
43
|
+
* editable), but collections the JSON Schema does **not** cover merge as
|
|
44
|
+
* read-only raw-carry leaves — their value must be the verbatim source
|
|
45
|
+
* slice, not `[]`. Walks the document alongside the original (pre-merge)
|
|
46
|
+
* schema coverage and restores the carry on every uncovered empty
|
|
47
|
+
* collection, at any depth.
|
|
48
|
+
*/
|
|
49
|
+
export declare function carryUncoveredEmpties(group: CfgGroup, source: string, value: unknown, keys: SchemaKeys): void;
|
|
39
50
|
export {};
|
|
@@ -4,6 +4,8 @@ exports.libconfigToNodeGroup = libconfigToNodeGroup;
|
|
|
4
4
|
exports.extractValue = extractValue;
|
|
5
5
|
exports.listShape = listShape;
|
|
6
6
|
exports.raw = raw;
|
|
7
|
+
exports.carryUncoveredEmpties = carryUncoveredEmpties;
|
|
8
|
+
const schema_keys_1 = require("../../core/schema-keys");
|
|
7
9
|
const parser_1 = require("./parser");
|
|
8
10
|
/** Digits only — the constraint for int64 values carried as strings. */
|
|
9
11
|
const INTEGER_STRING_PATTERN = '^[-+]?[0-9]+$';
|
|
@@ -184,3 +186,61 @@ function rawLeaf(name, why) {
|
|
|
184
186
|
function raw(value, source) {
|
|
185
187
|
return source.slice(value.span.start, value.span.end);
|
|
186
188
|
}
|
|
189
|
+
/**
|
|
190
|
+
* `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
|
|
191
|
+
* with `emptyAsArrays` (schema-covered empty collections are typed and
|
|
192
|
+
* editable), but collections the JSON Schema does **not** cover merge as
|
|
193
|
+
* read-only raw-carry leaves — their value must be the verbatim source
|
|
194
|
+
* slice, not `[]`. Walks the document alongside the original (pre-merge)
|
|
195
|
+
* schema coverage and restores the carry on every uncovered empty
|
|
196
|
+
* collection, at any depth.
|
|
197
|
+
*/
|
|
198
|
+
function carryUncoveredEmpties(group, source, value, keys) {
|
|
199
|
+
if (!isRecord(value))
|
|
200
|
+
return;
|
|
201
|
+
for (const setting of group.settings) {
|
|
202
|
+
if (keys && !keys.has(setting.name)) {
|
|
203
|
+
restoreEmptyCarries(setting.value, source, value, setting.name);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const child = keys?.get(setting.name);
|
|
207
|
+
const v = value[setting.name];
|
|
208
|
+
if (setting.value.kind === 'group' && child) {
|
|
209
|
+
carryUncoveredEmpties(setting.value, source, v, (0, schema_keys_1.childrenOf)(child));
|
|
210
|
+
}
|
|
211
|
+
else if (setting.value.kind === 'list' && Array.isArray(v)) {
|
|
212
|
+
const itemSchema = (0, schema_keys_1.itemSchemaOf)(child);
|
|
213
|
+
if (!itemSchema)
|
|
214
|
+
continue;
|
|
215
|
+
const itemKeys = (0, schema_keys_1.childrenOf)(itemSchema);
|
|
216
|
+
setting.value.elements.forEach((el, i) => {
|
|
217
|
+
if (el.kind === 'group')
|
|
218
|
+
carryUncoveredEmpties(el, source, v[i], itemKeys);
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
/** In a fully uncovered subtree, every empty collection reverts to its carry. */
|
|
224
|
+
function restoreEmptyCarries(node, source, parent, key) {
|
|
225
|
+
if ((node.kind === 'array' || node.kind === 'list') && node.elements.length === 0) {
|
|
226
|
+
parent[key] = raw(node, source);
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (node.kind === 'group') {
|
|
230
|
+
const v = parent[key];
|
|
231
|
+
if (!isRecord(v))
|
|
232
|
+
return;
|
|
233
|
+
for (const s of node.settings)
|
|
234
|
+
restoreEmptyCarries(s.value, source, v, s.name);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (node.kind === 'list') {
|
|
238
|
+
const v = parent[key];
|
|
239
|
+
if (!Array.isArray(v))
|
|
240
|
+
return;
|
|
241
|
+
node.elements.forEach((el, i) => restoreEmptyCarries(el, source, v, i));
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function isRecord(v) {
|
|
245
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
246
|
+
}
|
|
@@ -7,5 +7,5 @@
|
|
|
7
7
|
* `core` — they are format-agnostic and shared with the JSON transformer.
|
|
8
8
|
*/
|
|
9
9
|
export { yamlTransformer } from './yaml-transformer';
|
|
10
|
-
export type { YamlOptions } from './yaml-transformer';
|
|
10
|
+
export type { YamlOptions, YamlBinding } from './yaml-transformer';
|
|
11
11
|
export { applyValueToDocument } from './revert';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Document } from 'yaml';
|
|
2
|
+
import type { NodeGroup } from '../../core/schema';
|
|
2
3
|
/**
|
|
3
4
|
* Apply an edited form value onto a parsed YAML {@link Document} in place,
|
|
4
5
|
* preserving comments and formatting on every node that survives the edit.
|
|
@@ -10,9 +11,15 @@ import { type Document } from 'yaml';
|
|
|
10
11
|
* {@link import('./json-schema')}. Callers should clone the document first if the
|
|
11
12
|
* original must be preserved.
|
|
12
13
|
*
|
|
14
|
+
* With `schema` (the NodeGroup the form was built from) the
|
|
15
|
+
* value is authoritative for **schema-born paths only**: keys the schema does
|
|
16
|
+
* not cover — at any depth — are preserved verbatim, their comments included.
|
|
17
|
+
* Without it the value covers the whole document and a key it lacks is a
|
|
18
|
+
* deletion.
|
|
19
|
+
*
|
|
13
20
|
* Map keys are matched by the *same string form* `doc.toJS()` produced when the
|
|
14
21
|
* form value was built, so non-string keys (a `80:` port map, a `true:` flag)
|
|
15
22
|
* reconcile against their original typed key nodes instead of being appended as
|
|
16
23
|
* duplicate string-keyed pairs.
|
|
17
24
|
*/
|
|
18
|
-
export declare function applyValueToDocument(doc: Document, value: unknown): void;
|
|
25
|
+
export declare function applyValueToDocument(doc: Document, value: unknown, schema?: NodeGroup): void;
|
|
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.applyValueToDocument = applyValueToDocument;
|
|
4
4
|
const yaml_1 = require("yaml");
|
|
5
5
|
const bigint_1 = require("../../core/bigint");
|
|
6
|
+
const schema_keys_1 = require("../../core/schema-keys");
|
|
6
7
|
/**
|
|
7
8
|
* Apply an edited form value onto a parsed YAML {@link Document} in place,
|
|
8
9
|
* preserving comments and formatting on every node that survives the edit.
|
|
@@ -14,17 +15,23 @@ const bigint_1 = require("../../core/bigint");
|
|
|
14
15
|
* {@link import('./json-schema')}. Callers should clone the document first if the
|
|
15
16
|
* original must be preserved.
|
|
16
17
|
*
|
|
18
|
+
* With `schema` (the NodeGroup the form was built from) the
|
|
19
|
+
* value is authoritative for **schema-born paths only**: keys the schema does
|
|
20
|
+
* not cover — at any depth — are preserved verbatim, their comments included.
|
|
21
|
+
* Without it the value covers the whole document and a key it lacks is a
|
|
22
|
+
* deletion.
|
|
23
|
+
*
|
|
17
24
|
* Map keys are matched by the *same string form* `doc.toJS()` produced when the
|
|
18
25
|
* form value was built, so non-string keys (a `80:` port map, a `true:` flag)
|
|
19
26
|
* reconcile against their original typed key nodes instead of being appended as
|
|
20
27
|
* duplicate string-keyed pairs.
|
|
21
28
|
*/
|
|
22
|
-
function applyValueToDocument(doc, value) {
|
|
29
|
+
function applyValueToDocument(doc, value, schema) {
|
|
23
30
|
if (doc.contents == null) {
|
|
24
31
|
doc.contents = doc.createNode(value);
|
|
25
32
|
return;
|
|
26
33
|
}
|
|
27
|
-
doc.contents = applyToNode(doc, doc.contents, value);
|
|
34
|
+
doc.contents = applyToNode(doc, doc.contents, value, schema);
|
|
28
35
|
}
|
|
29
36
|
/**
|
|
30
37
|
* Reconcile the parsed `node` at one position with its edited `value`, returning
|
|
@@ -32,22 +39,27 @@ function applyValueToDocument(doc, value) {
|
|
|
32
39
|
* a map/sequence is reconciled child-by-child; a shape change (or a brand-new
|
|
33
40
|
* position) allocates a fresh node.
|
|
34
41
|
*/
|
|
35
|
-
function applyToNode(doc, node, value) {
|
|
42
|
+
function applyToNode(doc, node, value, schema) {
|
|
36
43
|
if (isPlainObject(value)) {
|
|
37
44
|
if (!(0, yaml_1.isMap)(node))
|
|
38
45
|
return doc.createNode(value);
|
|
39
|
-
reconcileMap(doc, node, value);
|
|
46
|
+
reconcileMap(doc, node, value, schema && (0, schema_keys_1.childrenOf)(schema));
|
|
40
47
|
return node;
|
|
41
48
|
}
|
|
42
49
|
if (Array.isArray(value)) {
|
|
43
50
|
if (!(0, yaml_1.isSeq)(node))
|
|
44
51
|
return doc.createNode(value);
|
|
45
|
-
reconcileSeq(doc, node, value);
|
|
52
|
+
reconcileSeq(doc, node, value, (0, schema_keys_1.itemSchemaOf)(schema));
|
|
46
53
|
return node;
|
|
47
54
|
}
|
|
48
55
|
// scalar (string / number / boolean / null)
|
|
49
56
|
if ((0, yaml_1.isScalar)(node)) {
|
|
50
|
-
|
|
57
|
+
const next = coerceScalarValue(value, node.value);
|
|
58
|
+
// An unchanged value keeps the node untouched — the parsed BigInt and the
|
|
59
|
+
// form's number compare by numeric value, so an identity pass never
|
|
60
|
+
// downgrades a BigInt-held integer to a lossy JS number.
|
|
61
|
+
if (!sameScalar(node.value, next))
|
|
62
|
+
node.value = next;
|
|
51
63
|
return node;
|
|
52
64
|
}
|
|
53
65
|
return doc.createNode(value);
|
|
@@ -56,13 +68,24 @@ function applyToNode(doc, node, value) {
|
|
|
56
68
|
* Update a map node against the value object: drop pairs whose key is gone,
|
|
57
69
|
* recurse into the ones that remain (matched by their `toJS` key string, so
|
|
58
70
|
* typed keys line up), and append a fresh pair for each genuinely new key.
|
|
71
|
+
* Under `schema`, keys that are not schema-born are exempt from all three —
|
|
72
|
+
* never dropped, never recursed into, never addable.
|
|
59
73
|
*/
|
|
60
|
-
function reconcileMap(doc, node, value) {
|
|
61
|
-
node.items = node.items.filter((pair) =>
|
|
74
|
+
function reconcileMap(doc, node, value, schema) {
|
|
75
|
+
node.items = node.items.filter((pair) => {
|
|
76
|
+
const key = keyString(pair.key);
|
|
77
|
+
if (schema && !schema.has(key))
|
|
78
|
+
return true; // not schema-born: verbatim
|
|
79
|
+
// Own-key check: `in` would resolve doc keys like `toString` through the
|
|
80
|
+
// prototype chain and make them undeletable.
|
|
81
|
+
return Object.prototype.hasOwnProperty.call(value, key);
|
|
82
|
+
});
|
|
62
83
|
for (const key of Object.keys(value)) {
|
|
84
|
+
if (schema && !schema.has(key))
|
|
85
|
+
continue; // not schema-born: never written
|
|
63
86
|
const pair = node.items.find((p) => keyString(p.key) === key);
|
|
64
87
|
if (pair) {
|
|
65
|
-
pair.value = applyToNode(doc, pair.value, value[key]);
|
|
88
|
+
pair.value = applyToNode(doc, pair.value, value[key], schema?.get(key));
|
|
66
89
|
}
|
|
67
90
|
else {
|
|
68
91
|
node.items.push(doc.createPair(key, value[key]));
|
|
@@ -70,12 +93,12 @@ function reconcileMap(doc, node, value) {
|
|
|
70
93
|
}
|
|
71
94
|
}
|
|
72
95
|
/** Update a sequence node: shrink/grow to the value's length, recurse per item. */
|
|
73
|
-
function reconcileSeq(doc, node, value) {
|
|
96
|
+
function reconcileSeq(doc, node, value, itemSchema) {
|
|
74
97
|
while (node.items.length > value.length)
|
|
75
98
|
node.items.pop();
|
|
76
99
|
for (let i = 0; i < value.length; i++) {
|
|
77
100
|
if (i < node.items.length) {
|
|
78
|
-
node.items[i] = applyToNode(doc, node.items[i], value[i]);
|
|
101
|
+
node.items[i] = applyToNode(doc, node.items[i], value[i], itemSchema);
|
|
79
102
|
}
|
|
80
103
|
else {
|
|
81
104
|
node.items.push(doc.createNode(value[i]));
|
|
@@ -96,6 +119,15 @@ function coerceScalarValue(value, current) {
|
|
|
96
119
|
}
|
|
97
120
|
return value;
|
|
98
121
|
}
|
|
122
|
+
/** Numeric equality across the BigInt/number divide, `===` otherwise. */
|
|
123
|
+
function sameScalar(current, next) {
|
|
124
|
+
if (current === next)
|
|
125
|
+
return true;
|
|
126
|
+
if (typeof current === 'bigint' && typeof next === 'number' && Number.isInteger(next)) {
|
|
127
|
+
return current === BigInt(next);
|
|
128
|
+
}
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
99
131
|
/**
|
|
100
132
|
* The string a key node takes as a JS object key, matching `doc.toJS()`: the
|
|
101
133
|
* scalar value stringified, with `null`/`undefined` collapsing to `''` (the
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Document } from 'yaml';
|
|
2
|
-
import type { FormValue, Thesaurus } from '../../core/schema';
|
|
2
|
+
import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
|
|
3
3
|
import type { TransformResult } from '../../core/transformer';
|
|
4
4
|
import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
|
|
5
5
|
/** Options for {@link yamlTransformer}'s `toSchema`. */
|
|
@@ -21,6 +21,32 @@ export interface YamlOptions {
|
|
|
21
21
|
* case-insensitively; never paths.
|
|
22
22
|
*/
|
|
23
23
|
thesaurus?: Thesaurus;
|
|
24
|
+
/**
|
|
25
|
+
* Schema-driven mode only: what happens to keys the JSON Schema does not
|
|
26
|
+
* cover. `'preserve'` (default) keeps them verbatim — the form never
|
|
27
|
+
* carried them, so a partial schema edits its slice without erasing the
|
|
28
|
+
* rest. `'drop'` makes the edited value authoritative for the whole
|
|
29
|
+
* document, deleting uncovered keys — for consumers whose schema is
|
|
30
|
+
* intentionally complete. `'edit'` surfaces them instead: uncovered keys
|
|
31
|
+
* render as editable fields typed by the data (the inferred schema merged
|
|
32
|
+
* under the JSON Schema), so nothing is invisible and the value covers the
|
|
33
|
+
* whole document. Ignored without a `schema`.
|
|
34
|
+
*/
|
|
35
|
+
unknownKeys?: 'preserve' | 'drop' | 'edit';
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The revert context: the parsed {@link Document} and — in schema-driven
|
|
39
|
+
* mode with `unknownKeys: 'preserve'` or `'edit'` — the NodeGroup the form
|
|
40
|
+
* was built from (the JSON Schema's own under `'preserve'`, the
|
|
41
|
+
* inferred-merged one under `'edit'`), so `toSource` treats the value as
|
|
42
|
+
* authoritative for schema-born paths only. Under `'edit'` that is almost
|
|
43
|
+
* the whole document; the gate still protects the keys no form field can
|
|
44
|
+
* carry (e.g. a key inside a covered choice's object that no case names).
|
|
45
|
+
* Treat it as opaque: build it with `toSchema`, hand it back to `toSource`.
|
|
46
|
+
*/
|
|
47
|
+
export interface YamlBinding {
|
|
48
|
+
doc: Document;
|
|
49
|
+
schema?: NodeGroup;
|
|
24
50
|
}
|
|
25
51
|
/**
|
|
26
52
|
* A YAML {@link Transformer}: turn a YAML config document into a form and write
|
|
@@ -28,14 +54,15 @@ export interface YamlOptions {
|
|
|
28
54
|
* preserves comments, key order, and formatting by applying edits onto the parsed
|
|
29
55
|
* document (see {@link applyValueToDocument}).
|
|
30
56
|
*
|
|
31
|
-
* The `binding` it round-trips is
|
|
32
|
-
* it before applying, so a single `toSchema` result can serve
|
|
57
|
+
* The `binding` it round-trips is a {@link YamlBinding}; `toSource` clones the
|
|
58
|
+
* document inside it before applying, so a single `toSchema` result can serve
|
|
59
|
+
* many edits.
|
|
33
60
|
*
|
|
34
61
|
* With a JSON Schema in {@link YamlOptions}, the form is schema-driven; without
|
|
35
62
|
* one it is inferred from the data.
|
|
36
63
|
*/
|
|
37
64
|
export declare const yamlTransformer: {
|
|
38
65
|
id: string;
|
|
39
|
-
toSchema(source: string, options?: YamlOptions): TransformResult<
|
|
40
|
-
toSource(value: FormValue, binding:
|
|
66
|
+
toSchema(source: string, options?: YamlOptions): TransformResult<YamlBinding>;
|
|
67
|
+
toSource(value: FormValue, binding: YamlBinding): string;
|
|
41
68
|
};
|
|
@@ -5,6 +5,7 @@ const yaml_1 = require("yaml");
|
|
|
5
5
|
const thesaurus_1 = require("../../core/thesaurus");
|
|
6
6
|
const infer_1 = require("../../core/infer");
|
|
7
7
|
const json_schema_1 = require("../../core/json-schema");
|
|
8
|
+
const merge_inferred_1 = require("../../core/merge-inferred");
|
|
8
9
|
const bigint_1 = require("../../core/bigint");
|
|
9
10
|
const revert_1 = require("./revert");
|
|
10
11
|
/**
|
|
@@ -13,8 +14,9 @@ const revert_1 = require("./revert");
|
|
|
13
14
|
* preserves comments, key order, and formatting by applying edits onto the parsed
|
|
14
15
|
* document (see {@link applyValueToDocument}).
|
|
15
16
|
*
|
|
16
|
-
* The `binding` it round-trips is
|
|
17
|
-
* it before applying, so a single `toSchema` result can serve
|
|
17
|
+
* The `binding` it round-trips is a {@link YamlBinding}; `toSource` clones the
|
|
18
|
+
* document inside it before applying, so a single `toSchema` result can serve
|
|
19
|
+
* many edits.
|
|
18
20
|
*
|
|
19
21
|
* With a JSON Schema in {@link YamlOptions}, the form is schema-driven; without
|
|
20
22
|
* one it is inferred from the data.
|
|
@@ -27,17 +29,31 @@ exports.yamlTransformer = {
|
|
|
27
29
|
// out-of-range integers become strings there (safe ones become plain numbers).
|
|
28
30
|
const doc = (0, yaml_1.parseDocument)(source, { intAsBigInt: true });
|
|
29
31
|
const data = (normalizeBigInts(doc.toJS()) ?? {});
|
|
30
|
-
const
|
|
32
|
+
const unknownKeys = options?.unknownKeys ?? 'preserve';
|
|
33
|
+
const fromJsonSchema = options?.schema
|
|
31
34
|
? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
|
|
32
|
-
:
|
|
33
|
-
|
|
35
|
+
: undefined;
|
|
36
|
+
const schema = fromJsonSchema && unknownKeys === 'edit'
|
|
37
|
+
? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
|
|
38
|
+
: fromJsonSchema ?? (0, infer_1.inferNodeGroup)(data, options?.rootName);
|
|
39
|
+
// Inferred leaves (the whole schema, or the uncovered slice under 'edit')
|
|
40
|
+
// pick up the document's hex/octal presentation.
|
|
41
|
+
if ((!fromJsonSchema || unknownKeys === 'edit') && doc.contents)
|
|
34
42
|
annotateRadix(doc.contents, schema);
|
|
35
43
|
const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
|
|
36
|
-
return {
|
|
44
|
+
return {
|
|
45
|
+
schema: labeled,
|
|
46
|
+
// 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit' on
|
|
47
|
+
// the merged one (schema-born ≈ everything, but keys no form field can
|
|
48
|
+
// carry stay protected); 'drop' and inferred mode leave the value
|
|
49
|
+
// authoritative for the whole document.
|
|
50
|
+
binding: { doc, schema: fromJsonSchema && unknownKeys !== 'drop' ? schema : undefined },
|
|
51
|
+
initialValue: data,
|
|
52
|
+
};
|
|
37
53
|
},
|
|
38
54
|
toSource(value, binding) {
|
|
39
|
-
const doc = binding.clone();
|
|
40
|
-
(0, revert_1.applyValueToDocument)(doc, value);
|
|
55
|
+
const doc = binding.doc.clone();
|
|
56
|
+
(0, revert_1.applyValueToDocument)(doc, value, binding.schema);
|
|
41
57
|
return String(doc);
|
|
42
58
|
},
|
|
43
59
|
// `satisfies` verifies conformance to the catalog contract while keeping the
|
|
@@ -115,7 +131,8 @@ function normalizeBigInts(value) {
|
|
|
115
131
|
if (Array.isArray(value))
|
|
116
132
|
return value.map(normalizeBigInts);
|
|
117
133
|
if (value && typeof value === 'object') {
|
|
118
|
-
|
|
134
|
+
// Null prototype: a `__proto__` document key must stay a data key.
|
|
135
|
+
const out = Object.create(null);
|
|
119
136
|
for (const key of Object.keys(value))
|
|
120
137
|
out[key] = normalizeBigInts(value[key]);
|
|
121
138
|
return out;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ng-form-foundry-transformers",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.1",
|
|
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",
|