diff options
Diffstat (limited to 'app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js')
-rw-r--r-- | app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js | 293 |
1 files changed, 293 insertions, 0 deletions
diff --git a/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js new file mode 100644 index 00000000000..212e09c8724 --- /dev/null +++ b/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js @@ -0,0 +1,293 @@ +import { toPath } from 'lodash'; +import { parseDocument, Document, visit, isScalar, isCollection, isMap } from 'yaml'; +import { findPair } from 'yaml/util'; +import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; + +export class YamlEditorExtension extends SourceEditorExtension { + /** + * Extends the source editor with capabilities for yaml files. + * + * @param { Instance } instance Source Editor Instance + * @param { boolean } enableComments Convert model nodes with the comment + * pattern to comments? + * @param { string } highlightPath Add a line highlight to the + * node specified by this e.g. `"foo.bar[0]"` + * @param { * } model Any JS Object that will be stringified and used as the + * editor's value. Equivalent to using `setDataModel()` + * @param options SourceEditorExtension Options + */ + constructor({ + instance, + enableComments = false, + highlightPath = null, + model = null, + ...options + } = {}) { + super({ + instance, + options: { + ...options, + enableComments, + highlightPath, + }, + }); + + if (model) { + YamlEditorExtension.initFromModel(instance, model); + } + + instance.onDidChangeModelContent(() => instance.onUpdate()); + } + + /** + * @private + */ + static initFromModel(instance, model) { + const doc = new Document(model); + if (instance.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + instance.setValue(doc.toString()); + } + + /** + * @private + * This wraps long comments to a maximum line length of 80 chars. + * + * The `yaml` package does not currently wrap comments. This function + * is a local workaround and should be deprecated if + * https://github.com/eemeli/yaml/issues/322 + * is resolved. + */ + static wrapCommentString(string, level = 0) { + if (!string) { + return null; + } + if (level < 0 || Number.isNaN(parseInt(level, 10))) { + throw Error(`Invalid value "${level}" for variable \`level\``); + } + const maxLineWidth = 80; + const indentWidth = 2; + const commentMarkerWidth = '# '.length; + const maxLength = maxLineWidth - commentMarkerWidth - level * indentWidth; + const lines = [[]]; + string.split(' ').forEach((word) => { + const currentLine = lines.length - 1; + if ([...lines[currentLine], word].join(' ').length <= maxLength) { + lines[currentLine].push(word); + } else { + lines.push([word]); + } + }); + return lines.map((line) => ` ${line.join(' ')}`).join('\n'); + } + + /** + * @private + * + * This utilizes `yaml`'s `visit` function to transform nodes with a + * comment key pattern to actual comments. + * + * In Objects, a key of '#' will be converted to a comment at the top of a + * property. Any key following the pattern `#|<some key>` will be placed + * right before `<some key>`. + * + * In Arrays, any string that starts with # (including the space), will + * be converted to a comment at the position it was in. + * + * @param { Document } doc + * @returns { Document } + */ + static transformComments(doc) { + const getLevel = (path) => { + const { length } = path.filter((x) => isCollection(x)); + return length ? length - 1 : 0; + }; + + visit(doc, { + Pair(_, pair, path) { + const key = pair.key.value; + // If the key is = '#', we add the value as a comment to the parent + // We can then remove the node. + if (key === '#') { + Object.assign(path[path.length - 1], { + commentBefore: YamlEditorExtension.wrapCommentString(pair.value.value, getLevel(path)), + }); + return visit.REMOVE; + } + // If the key starts with `#|`, we want to add a comment to the + // corresponding property. We can then remove the node. + if (key.startsWith('#|')) { + const targetProperty = key.split('|')[1]; + const target = findPair(path[path.length - 1].items, targetProperty); + if (target) { + target.key.commentBefore = YamlEditorExtension.wrapCommentString( + pair.value.value, + getLevel(path), + ); + } + return visit.REMOVE; + } + return undefined; // If the node is not a comment, do nothing with it + }, + // Sequence is basically an array + Seq(_, node, path) { + let comment = null; + const items = node.items.flatMap((child) => { + if (comment) { + Object.assign(child, { commentBefore: comment }); + comment = null; + } + if ( + isScalar(child) && + child.value && + child.value.startsWith && + child.value.startsWith('#') + ) { + const commentValue = child.value.replace(/^#\s?/, ''); + comment = YamlEditorExtension.wrapCommentString(commentValue, getLevel(path)); + return []; + } + return child; + }); + Object.assign(node, { items }); + // Adding a comment in case the last one is a comment + if (comment) { + Object.assign(node, { comment }); + } + }, + }); + return doc; + } + + /** + * Get the editor's value parsed as a `Document` as defined by the `yaml` + * package + * @returns {Document} + */ + getDoc() { + return parseDocument(this.getValue()); + } + + /** + * Accepts a `Document` as defined by the `yaml` package and + * sets the Editor's value to a stringified version of it. + * @param { Document } doc + */ + setDoc(doc) { + if (this.options.enableComments) { + YamlEditorExtension.transformComments(doc); + } + + if (!this.getValue()) { + this.setValue(doc.toString()); + } else { + this.updateValue(doc.toString()); + } + } + + /** + * Returns the parsed value of the Editor's content as JS. + * @returns {*} + */ + getDataModel() { + return this.getDoc().toJS(); + } + + /** + * Accepts any JS Object and sets the Editor's value to a stringified version + * of that value. + * + * @param value + */ + setDataModel(value) { + this.setDoc(new Document(value)); + } + + /** + * Method to be executed when the Editor's <TextModel> was updated + */ + onUpdate() { + if (this.options.highlightPath) { + this.highlight(this.options.highlightPath); + } + } + + /** + * Set the editors content to the input without recreating the content model. + * + * @param blob + */ + updateValue(blob) { + // Using applyEdits() instead of setValue() ensures that tokens such as + // highlighted lines aren't deleted/recreated which causes a flicker. + const model = this.getModel(); + model.applyEdits([ + { + // A nice improvement would be to replace getFullModelRange() with + // a range of the actual diff, avoiding re-formatting the document, + // but that's something for a later iteration. + range: model.getFullModelRange(), + text: blob, + }, + ]); + } + + /** + * Add a line highlight style to the node specified by the path. + * + * @param {string|null|false} path A path to a node of the Editor's value, + * e.g. `"foo.bar[0]"`. If the value is falsy, this will remove all + * highlights. + */ + highlight(path) { + if (this.options.highlightPath === path) return; + if (!path) { + SourceEditorExtension.removeHighlights(this); + } else { + const res = this.locate(path); + SourceEditorExtension.highlightLines(this, res); + } + this.options.highlightPath = path || null; + } + + /** + * Return the line numbers of a certain node identified by `path` within + * the yaml. + * + * @param {string} path A path to a node, eg. `foo.bar[0]` + * @returns {number[]} Array following the schema `[firstLine, lastLine]` + * (both inclusive) + * + * @throws {Error} Will throw if the path is not found inside the document + */ + locate(path) { + if (!path) throw Error(`No path provided.`); + const blob = this.getValue(); + const doc = parseDocument(blob); + const pathArray = toPath(path); + + if (!doc.getIn(pathArray)) { + throw Error(`The node ${path} could not be found inside the document.`); + } + + const parentNode = doc.getIn(pathArray.slice(0, pathArray.length - 1)); + let startChar; + let endChar; + if (isMap(parentNode)) { + const node = parentNode.items.find( + (item) => item.key.value === pathArray[pathArray.length - 1], + ); + [startChar] = node.key.range; + [, , endChar] = node.value.range; + } else { + const node = doc.getIn(pathArray); + [startChar, , endChar] = node.range; + } + const startSlice = blob.slice(0, startChar); + const endSlice = blob.slice(0, endChar); + const startLine = (startSlice.match(/\n/g) || []).length + 1; + const endLine = (endSlice.match(/\n/g) || []).length; + return [startLine, endLine]; + } +} |