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 };