diff options
Diffstat (limited to 'app/assets/javascripts/lib/utils/yaml.js')
-rw-r--r-- | app/assets/javascripts/lib/utils/yaml.js | 121 |
1 files changed, 121 insertions, 0 deletions
diff --git a/app/assets/javascripts/lib/utils/yaml.js b/app/assets/javascripts/lib/utils/yaml.js new file mode 100644 index 00000000000..9270d388342 --- /dev/null +++ b/app/assets/javascripts/lib/utils/yaml.js @@ -0,0 +1,121 @@ +/** + * This file adds a merge function to be used with a yaml Document as defined by + * the yaml@2.x package: https://eemeli.org/yaml/#yaml + * + * Ultimately, this functionality should be merged upstream into the package, + * track the progress of that effort at https://github.com/eemeli/yaml/pull/347 + * */ + +import { visit, Scalar, isCollection, isDocument, isScalar, isNode, isMap, isSeq } from 'yaml'; + +function getPath(ancestry) { + return ancestry.reduce((p, { key }) => { + return key !== undefined ? [...p, key.value] : p; + }, []); +} + +function getFirstChildNode(collection) { + let firstChildKey; + let type; + switch (collection.constructor.name) { + case 'YAMLSeq': // eslint-disable-line @gitlab/require-i18n-strings + return collection.items.find((i) => isNode(i)); + case 'YAMLMap': // eslint-disable-line @gitlab/require-i18n-strings + firstChildKey = collection.items[0]?.key; + if (!firstChildKey) return undefined; + return isScalar(firstChildKey) ? firstChildKey : new Scalar(firstChildKey); + default: + type = collection.constructor?.name || typeof collection; + throw Error(`Cannot identify a child Node for type ${type}`); + } +} + +function moveMetaPropsToFirstChildNode(collection) { + const firstChildNode = getFirstChildNode(collection); + const { comment, commentBefore, spaceBefore } = collection; + if (!(comment || commentBefore || spaceBefore)) return; + if (!firstChildNode) + throw new Error('Cannot move meta properties to a child of an empty Collection'); // eslint-disable-line @gitlab/require-i18n-strings + Object.assign(firstChildNode, { comment, commentBefore, spaceBefore }); + Object.assign(collection, { + comment: undefined, + commentBefore: undefined, + spaceBefore: undefined, + }); +} + +function assert(isTypeFn, node, path) { + if (![isSeq, isMap].includes(isTypeFn)) { + throw new Error('assert() can only be used with isSeq() and isMap()'); + } + const expectedTypeName = isTypeFn === isSeq ? 'YAMLSeq' : 'YAMLMap'; // eslint-disable-line @gitlab/require-i18n-strings + if (!isTypeFn(node)) { + const type = node?.constructor?.name || typeof node; + throw new Error( + `Type conflict at "${path.join( + '.', + )}": Destination node is of type ${type}, the node to be merged is of type ${expectedTypeName}.`, + ); + } +} + +function mergeCollection(target, node, path) { + // In case both the source and the target node have comments or spaces + // We'll move them to their first child so they do not conflict + moveMetaPropsToFirstChildNode(node); + if (target.hasIn(path)) { + const targetNode = target.getIn(path, true); + assert(isSeq(node) ? isSeq : isMap, targetNode, path); + moveMetaPropsToFirstChildNode(targetNode); + } +} + +function mergePair(target, node, path) { + if (!isScalar(node.value)) return undefined; + if (target.hasIn([...path, node.key.value])) { + target.setIn(path, node); + } else { + target.addIn(path, node); + } + return visit.SKIP; +} + +function getVisitorFn(target, options) { + return { + Map: (_, node, ancestors) => { + mergeCollection(target, node, getPath(ancestors)); + }, + Pair: (_, node, ancestors) => { + mergePair(target, node, getPath(ancestors)); + }, + Seq: (_, node, ancestors) => { + const path = getPath(ancestors); + mergeCollection(target, node, path); + if (options.onSequence === 'replace') { + target.setIn(path, node); + return visit.SKIP; + } + node.items.forEach((item) => target.addIn(path, item)); + return visit.SKIP; + }, + }; +} + +/** Merge another collection into this */ +export function merge(target, source, options = {}) { + const opt = { + onSequence: 'replace', + ...options, + }; + const sourceNode = target.createNode(isDocument(source) ? source.contents : source); + if (!isCollection(sourceNode)) { + const type = source?.constructor?.name || typeof source; + throw new Error(`Cannot merge type "${type}", expected a Collection`); + } + if (!isCollection(target.contents)) { + // If the target doc is empty add the source to it directly + Object.assign(target, { contents: sourceNode }); + return; + } + visit(sourceNode, getVisitorFn(target, opt)); +} |