337 lines
13 KiB
JavaScript
337 lines
13 KiB
JavaScript
'use strict';
|
|
|
|
var Alias = require('../nodes/Alias.js');
|
|
var Collection = require('../nodes/Collection.js');
|
|
var identity = require('../nodes/identity.js');
|
|
var Pair = require('../nodes/Pair.js');
|
|
var toJS = require('../nodes/toJS.js');
|
|
var Schema = require('../schema/Schema.js');
|
|
var stringifyDocument = require('../stringify/stringifyDocument.js');
|
|
var anchors = require('./anchors.js');
|
|
var applyReviver = require('./applyReviver.js');
|
|
var createNode = require('./createNode.js');
|
|
var directives = require('./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, identity.NODE_TYPE, { value: identity.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.Directives({ version });
|
|
this.setSchema(version, options);
|
|
// @ts-expect-error We can't really know that this matches Contents.
|
|
this.contents =
|
|
value === undefined ? null : 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, {
|
|
[identity.NODE_TYPE]: { value: identity.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();
|
|
// @ts-expect-error We can't really know that this matches Contents.
|
|
copy.contents = identity.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 = anchors.anchorNames(this);
|
|
node.anchor =
|
|
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
|
|
!name || prev.has(name) ? anchors.findNewAnchor(name || 'a', prev) : name;
|
|
}
|
|
return new Alias.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 } = anchors.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.createNode(value, tag, ctx);
|
|
if (flow && identity.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.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 (Collection.isEmptyPath(path)) {
|
|
if (this.contents == null)
|
|
return false;
|
|
// @ts-expect-error Presumed impossible if Strict extends 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 identity.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 (Collection.isEmptyPath(path))
|
|
return !keepScalar && identity.isScalar(this.contents)
|
|
? this.contents.value
|
|
: this.contents;
|
|
return identity.isCollection(this.contents)
|
|
? this.contents.getIn(path, keepScalar)
|
|
: undefined;
|
|
}
|
|
/**
|
|
* Checks if the document includes a value with the key `key`.
|
|
*/
|
|
has(key) {
|
|
return identity.isCollection(this.contents) ? this.contents.has(key) : false;
|
|
}
|
|
/**
|
|
* Checks if the document includes a value at `path`.
|
|
*/
|
|
hasIn(path) {
|
|
if (Collection.isEmptyPath(path))
|
|
return this.contents !== undefined;
|
|
return identity.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) {
|
|
// @ts-expect-error We can't really know that this matches Contents.
|
|
this.contents = Collection.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 (Collection.isEmptyPath(path)) {
|
|
// @ts-expect-error We can't really know that this matches Contents.
|
|
this.contents = value;
|
|
}
|
|
else if (this.contents == null) {
|
|
// @ts-expect-error We can't really know that this matches Contents.
|
|
this.contents = Collection.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.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.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.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
|
|
};
|
|
const res = toJS.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.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.stringifyDocument(this, options);
|
|
}
|
|
}
|
|
function assertCollection(contents) {
|
|
if (identity.isCollection(contents))
|
|
return true;
|
|
throw new Error('Expected a YAML collection as document contents');
|
|
}
|
|
|
|
exports.Document = Document;
|