summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/editor/extensions/source_editor_yaml_ext.js
diff options
context:
space:
mode:
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.js293
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];
+ }
+}