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.
332 lines
12 KiB
332 lines
12 KiB
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 };
|
|
|