You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
333 lines
12 KiB
333 lines
12 KiB
2 years ago
|
import { Alias } from '../nodes/Alias.js';
|
||
|
import { isEmptyPath, collectionFromPath } from '../nodes/Collection.js';
|
||
|
import { NODE_TYPE, DOC, isNode, isCollection, isScalar } from '../nodes/Node.js';
|
||
|
import { Pair } from '../nodes/Pair.js';
|
||
|
import { toJS } from '../nodes/toJS.js';
|
||
|
import { Schema } from '../schema/Schema.js';
|
||
|
import { stringify } from '../stringify/stringify.js';
|
||
|
import { stringifyDocument } from '../stringify/stringifyDocument.js';
|
||
|
import { anchorNames, findNewAnchor, createNodeAnchors } from './anchors.js';
|
||
|
import { applyReviver } from './applyReviver.js';
|
||
|
import { createNode } from './createNode.js';
|
||
|
import { Directives } from './directives.js';
|
||
|
|
||
|
class Document {
|
||
|
constructor(value, replacer, options) {
|
||
|
/** A comment before this Document */
|
||
|
this.commentBefore = null;
|
||
|
/** A comment immediately after this Document */
|
||
|
this.comment = null;
|
||
|
/** Errors encountered during parsing. */
|
||
|
this.errors = [];
|
||
|
/** Warnings encountered during parsing. */
|
||
|
this.warnings = [];
|
||
|
Object.defineProperty(this, NODE_TYPE, { value: DOC });
|
||
|
let _replacer = null;
|
||
|
if (typeof replacer === 'function' || Array.isArray(replacer)) {
|
||
|
_replacer = replacer;
|
||
|
}
|
||
|
else if (options === undefined && replacer) {
|
||
|
options = replacer;
|
||
|
replacer = undefined;
|
||
|
}
|
||
|
const opt = Object.assign({
|
||
|
intAsBigInt: false,
|
||
|
keepSourceTokens: false,
|
||
|
logLevel: 'warn',
|
||
|
prettyErrors: true,
|
||
|
strict: true,
|
||
|
uniqueKeys: true,
|
||
|
version: '1.2'
|
||
|
}, options);
|
||
|
this.options = opt;
|
||
|
let { version } = opt;
|
||
|
if (options?._directives) {
|
||
|
this.directives = options._directives.atDocument();
|
||
|
if (this.directives.yaml.explicit)
|
||
|
version = this.directives.yaml.version;
|
||
|
}
|
||
|
else
|
||
|
this.directives = new Directives({ version });
|
||
|
this.setSchema(version, options);
|
||
|
if (value === undefined)
|
||
|
this.contents = null;
|
||
|
else {
|
||
|
this.contents = this.createNode(value, _replacer, options);
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Create a deep copy of this Document and its contents.
|
||
|
*
|
||
|
* Custom Node values that inherit from `Object` still refer to their original instances.
|
||
|
*/
|
||
|
clone() {
|
||
|
const copy = Object.create(Document.prototype, {
|
||
|
[NODE_TYPE]: { value: DOC }
|
||
|
});
|
||
|
copy.commentBefore = this.commentBefore;
|
||
|
copy.comment = this.comment;
|
||
|
copy.errors = this.errors.slice();
|
||
|
copy.warnings = this.warnings.slice();
|
||
|
copy.options = Object.assign({}, this.options);
|
||
|
if (this.directives)
|
||
|
copy.directives = this.directives.clone();
|
||
|
copy.schema = this.schema.clone();
|
||
|
copy.contents = isNode(this.contents)
|
||
|
? this.contents.clone(copy.schema)
|
||
|
: this.contents;
|
||
|
if (this.range)
|
||
|
copy.range = this.range.slice();
|
||
|
return copy;
|
||
|
}
|
||
|
/** Adds a value to the document. */
|
||
|
add(value) {
|
||
|
if (assertCollection(this.contents))
|
||
|
this.contents.add(value);
|
||
|
}
|
||
|
/** Adds a value to the document. */
|
||
|
addIn(path, value) {
|
||
|
if (assertCollection(this.contents))
|
||
|
this.contents.addIn(path, value);
|
||
|
}
|
||
|
/**
|
||
|
* Create a new `Alias` node, ensuring that the target `node` has the required anchor.
|
||
|
*
|
||
|
* If `node` already has an anchor, `name` is ignored.
|
||
|
* Otherwise, the `node.anchor` value will be set to `name`,
|
||
|
* or if an anchor with that name is already present in the document,
|
||
|
* `name` will be used as a prefix for a new unique anchor.
|
||
|
* If `name` is undefined, the generated anchor will use 'a' as a prefix.
|
||
|
*/
|
||
|
createAlias(node, name) {
|
||
|
if (!node.anchor) {
|
||
|
const prev = anchorNames(this);
|
||
|
node.anchor =
|
||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||
|
!name || prev.has(name) ? findNewAnchor(name || 'a', prev) : name;
|
||
|
}
|
||
|
return new Alias(node.anchor);
|
||
|
}
|
||
|
createNode(value, replacer, options) {
|
||
|
let _replacer = undefined;
|
||
|
if (typeof replacer === 'function') {
|
||
|
value = replacer.call({ '': value }, '', value);
|
||
|
_replacer = replacer;
|
||
|
}
|
||
|
else if (Array.isArray(replacer)) {
|
||
|
const keyToStr = (v) => typeof v === 'number' || v instanceof String || v instanceof Number;
|
||
|
const asStr = replacer.filter(keyToStr).map(String);
|
||
|
if (asStr.length > 0)
|
||
|
replacer = replacer.concat(asStr);
|
||
|
_replacer = replacer;
|
||
|
}
|
||
|
else if (options === undefined && replacer) {
|
||
|
options = replacer;
|
||
|
replacer = undefined;
|
||
|
}
|
||
|
const { aliasDuplicateObjects, anchorPrefix, flow, keepUndefined, onTagObj, tag } = options ?? {};
|
||
|
const { onAnchor, setAnchors, sourceObjects } = createNodeAnchors(this,
|
||
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
||
|
anchorPrefix || 'a');
|
||
|
const ctx = {
|
||
|
aliasDuplicateObjects: aliasDuplicateObjects ?? true,
|
||
|
keepUndefined: keepUndefined ?? false,
|
||
|
onAnchor,
|
||
|
onTagObj,
|
||
|
replacer: _replacer,
|
||
|
schema: this.schema,
|
||
|
sourceObjects
|
||
|
};
|
||
|
const node = createNode(value, tag, ctx);
|
||
|
if (flow && isCollection(node))
|
||
|
node.flow = true;
|
||
|
setAnchors();
|
||
|
return node;
|
||
|
}
|
||
|
/**
|
||
|
* Convert a key and a value into a `Pair` using the current schema,
|
||
|
* recursively wrapping all values as `Scalar` or `Collection` nodes.
|
||
|
*/
|
||
|
createPair(key, value, options = {}) {
|
||
|
const k = this.createNode(key, null, options);
|
||
|
const v = this.createNode(value, null, options);
|
||
|
return new Pair(k, v);
|
||
|
}
|
||
|
/**
|
||
|
* Removes a value from the document.
|
||
|
* @returns `true` if the item was found and removed.
|
||
|
*/
|
||
|
delete(key) {
|
||
|
return assertCollection(this.contents) ? this.contents.delete(key) : false;
|
||
|
}
|
||
|
/**
|
||
|
* Removes a value from the document.
|
||
|
* @returns `true` if the item was found and removed.
|
||
|
*/
|
||
|
deleteIn(path) {
|
||
|
if (isEmptyPath(path)) {
|
||
|
if (this.contents == null)
|
||
|
return false;
|
||
|
this.contents = null;
|
||
|
return true;
|
||
|
}
|
||
|
return assertCollection(this.contents)
|
||
|
? this.contents.deleteIn(path)
|
||
|
: false;
|
||
|
}
|
||
|
/**
|
||
|
* Returns item at `key`, or `undefined` if not found. By default unwraps
|
||
|
* scalar values from their surrounding node; to disable set `keepScalar` to
|
||
|
* `true` (collections are always returned intact).
|
||
|
*/
|
||
|
get(key, keepScalar) {
|
||
|
return isCollection(this.contents)
|
||
|
? this.contents.get(key, keepScalar)
|
||
|
: undefined;
|
||
|
}
|
||
|
/**
|
||
|
* Returns item at `path`, or `undefined` if not found. By default unwraps
|
||
|
* scalar values from their surrounding node; to disable set `keepScalar` to
|
||
|
* `true` (collections are always returned intact).
|
||
|
*/
|
||
|
getIn(path, keepScalar) {
|
||
|
if (isEmptyPath(path))
|
||
|
return !keepScalar && isScalar(this.contents)
|
||
|
? this.contents.value
|
||
|
: this.contents;
|
||
|
return isCollection(this.contents)
|
||
|
? this.contents.getIn(path, keepScalar)
|
||
|
: undefined;
|
||
|
}
|
||
|
/**
|
||
|
* Checks if the document includes a value with the key `key`.
|
||
|
*/
|
||
|
has(key) {
|
||
|
return isCollection(this.contents) ? this.contents.has(key) : false;
|
||
|
}
|
||
|
/**
|
||
|
* Checks if the document includes a value at `path`.
|
||
|
*/
|
||
|
hasIn(path) {
|
||
|
if (isEmptyPath(path))
|
||
|
return this.contents !== undefined;
|
||
|
return isCollection(this.contents) ? this.contents.hasIn(path) : false;
|
||
|
}
|
||
|
/**
|
||
|
* Sets a value in this document. For `!!set`, `value` needs to be a
|
||
|
* boolean to add/remove the item from the set.
|
||
|
*/
|
||
|
set(key, value) {
|
||
|
if (this.contents == null) {
|
||
|
this.contents = collectionFromPath(this.schema, [key], value);
|
||
|
}
|
||
|
else if (assertCollection(this.contents)) {
|
||
|
this.contents.set(key, value);
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Sets a value in this document. For `!!set`, `value` needs to be a
|
||
|
* boolean to add/remove the item from the set.
|
||
|
*/
|
||
|
setIn(path, value) {
|
||
|
if (isEmptyPath(path))
|
||
|
this.contents = value;
|
||
|
else if (this.contents == null) {
|
||
|
this.contents = collectionFromPath(this.schema, Array.from(path), value);
|
||
|
}
|
||
|
else if (assertCollection(this.contents)) {
|
||
|
this.contents.setIn(path, value);
|
||
|
}
|
||
|
}
|
||
|
/**
|
||
|
* Change the YAML version and schema used by the document.
|
||
|
* A `null` version disables support for directives, explicit tags, anchors, and aliases.
|
||
|
* It also requires the `schema` option to be given as a `Schema` instance value.
|
||
|
*
|
||
|
* Overrides all previously set schema options.
|
||
|
*/
|
||
|
setSchema(version, options = {}) {
|
||
|
if (typeof version === 'number')
|
||
|
version = String(version);
|
||
|
let opt;
|
||
|
switch (version) {
|
||
|
case '1.1':
|
||
|
if (this.directives)
|
||
|
this.directives.yaml.version = '1.1';
|
||
|
else
|
||
|
this.directives = new Directives({ version: '1.1' });
|
||
|
opt = { merge: true, resolveKnownTags: false, schema: 'yaml-1.1' };
|
||
|
break;
|
||
|
case '1.2':
|
||
|
case 'next':
|
||
|
if (this.directives)
|
||
|
this.directives.yaml.version = version;
|
||
|
else
|
||
|
this.directives = new Directives({ version });
|
||
|
opt = { merge: false, resolveKnownTags: true, schema: 'core' };
|
||
|
break;
|
||
|
case null:
|
||
|
if (this.directives)
|
||
|
delete this.directives;
|
||
|
opt = null;
|
||
|
break;
|
||
|
default: {
|
||
|
const sv = JSON.stringify(version);
|
||
|
throw new Error(`Expected '1.1', '1.2' or null as first argument, but found: ${sv}`);
|
||
|
}
|
||
|
}
|
||
|
// Not using `instanceof Schema` to allow for duck typing
|
||
|
if (options.schema instanceof Object)
|
||
|
this.schema = options.schema;
|
||
|
else if (opt)
|
||
|
this.schema = new Schema(Object.assign(opt, options));
|
||
|
else
|
||
|
throw new Error(`With a null YAML version, the { schema: Schema } option is required`);
|
||
|
}
|
||
|
// json & jsonArg are only used from toJSON()
|
||
|
toJS({ json, jsonArg, mapAsMap, maxAliasCount, onAnchor, reviver } = {}) {
|
||
|
const ctx = {
|
||
|
anchors: new Map(),
|
||
|
doc: this,
|
||
|
keep: !json,
|
||
|
mapAsMap: mapAsMap === true,
|
||
|
mapKeyWarned: false,
|
||
|
maxAliasCount: typeof maxAliasCount === 'number' ? maxAliasCount : 100,
|
||
|
stringify
|
||
|
};
|
||
|
const res = toJS(this.contents, jsonArg ?? '', ctx);
|
||
|
if (typeof onAnchor === 'function')
|
||
|
for (const { count, res } of ctx.anchors.values())
|
||
|
onAnchor(res, count);
|
||
|
return typeof reviver === 'function'
|
||
|
? applyReviver(reviver, { '': res }, '', res)
|
||
|
: res;
|
||
|
}
|
||
|
/**
|
||
|
* A JSON representation of the document `contents`.
|
||
|
*
|
||
|
* @param jsonArg Used by `JSON.stringify` to indicate the array index or
|
||
|
* property name.
|
||
|
*/
|
||
|
toJSON(jsonArg, onAnchor) {
|
||
|
return this.toJS({ json: true, jsonArg, mapAsMap: false, onAnchor });
|
||
|
}
|
||
|
/** A YAML representation of the document. */
|
||
|
toString(options = {}) {
|
||
|
if (this.errors.length > 0)
|
||
|
throw new Error('Document with errors cannot be stringified');
|
||
|
if ('indent' in options &&
|
||
|
(!Number.isInteger(options.indent) || Number(options.indent) <= 0)) {
|
||
|
const s = JSON.stringify(options.indent);
|
||
|
throw new Error(`"indent" option must be a positive integer, not ${s}`);
|
||
|
}
|
||
|
return stringifyDocument(this, options);
|
||
|
}
|
||
|
}
|
||
|
function assertCollection(contents) {
|
||
|
if (isCollection(contents))
|
||
|
return true;
|
||
|
throw new Error('Expected a YAML collection as document contents');
|
||
|
}
|
||
|
|
||
|
export { Document };
|