// yaml-complete.js — deterministic, schema-driven completion + hover docs for // the browse YAML editors (markdown front matter + .zddc). NO heuristics, no // AI: every candidate and doc string comes from a PROVIDER backed by the // converter's field list or the .zddc JSON Schema. // // A provider answers three questions about a position, identified by its key // PATH (the array of parent keys): // keysAt(path) → [{name, hint, values}] valid child keys here // valuesFor(path, key) → [string] | null enum/boolean values // describe(path, key) → string | null doc text (for hover) // The CodeMirror plumbing (indent→path, sibling scan, show-hint, hover) is // shared; only the provider differs between the flat front matter and the // nested .zddc schema. Requires CodeMirror 5 + the show-hint add-on. (function () { 'use strict'; if (!window.app) window.app = {}; if (!window.app.modules) window.app.modules = {}; function indentOf(line) { var m = line.match(/^(\s*)/); return m ? m[1].length : 0; } function isBlankOrComment(line) { return /^\s*$/.test(line) || /^\s*#/.test(line); } function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + '…' : s; } // Parent key-path for a line, derived from YAML indentation: walk upward // collecting each "key:" line at a strictly smaller indent. function pathAt(cm, lineNo) { var path = []; var target = indentOf(cm.getLine(lineNo)); for (var ln = lineNo - 1; ln >= 0 && target > 0; ln--) { var line = cm.getLine(ln); if (isBlankOrComment(line)) continue; var ind = indentOf(line); if (ind < target) { var m = line.match(/^\s*([\w.\-]+)\s*:/); if (m) { path.unshift(m[1]); target = ind; } } } return path; } // Sibling keys already present at the same indent within this block, so we // don't re-suggest a key the author already wrote. function presentSiblings(cm, lineNo, indent) { var present = {}; [-1, 1].forEach(function (dir) { for (var ln = lineNo + dir; ln >= 0 && ln < cm.lineCount(); ln += dir) { var line = cm.getLine(ln); if (isBlankOrComment(line)) continue; var ind = indentOf(line); if (ind < indent) break; // left the block if (ind === indent) { var m = line.match(/^\s*([\w.\-]+)\s*:/); if (m) present[m[1]] = true; } } }); return present; } function keyItem(k, hinter) { var item = { text: k.name + ': ', displayText: k.name + (k.hint ? ' — ' + truncate(k.hint, 64) : '') }; // An enum key inserts "key: " then immediately opens its value menu. if (k.values && k.values.length) { item.hint = function (cmi, data, comp) { cmi.replaceRange(comp.text, data.from, data.to); setTimeout(function () { cmi.showHint({ hint: hinter, completeSingle: false }); }, 0); }; } return item; } function makeHinter(provider) { function hinter(cm) { var CM = window.CodeMirror; if (!CM) return null; var cur = cm.getCursor(); var before = cm.getLine(cur.line).slice(0, cur.ch); var colon = before.indexOf(':'); var path = pathAt(cm, cur.line); if (colon === -1) { // KEY context. var m = before.match(/^(\s*)([\w.\-]*)$/); if (!m) return null; var indent = m[1], typed = m[2]; var keys = provider.keysAt(path) || []; if (!keys.length) return null; var present = presentSiblings(cm, cur.line, indent.length); var list = []; keys.forEach(function (k) { if (present[k.name]) return; if (typed && k.name.indexOf(typed) !== 0) return; list.push(keyItem(k, hinter)); }); if (!list.length) return null; return { list: list, from: CM.Pos(cur.line, indent.length), to: cur }; } // VALUE context. var key = before.slice(0, colon).trim(); var values = provider.valuesFor(path, key) || []; if (!values.length) return null; var rest = before.slice(colon + 1); var valTyped = rest.replace(/^\s*/, ''); var valStart = colon + 1 + (rest.length - valTyped.length); var vlist = []; values.forEach(function (v) { if (valTyped && v.indexOf(valTyped) !== 0) return; vlist.push({ text: v, displayText: v }); }); if (!vlist.length) return null; return { list: vlist, from: CM.Pos(cur.line, valStart), to: cur }; } return hinter; } // Lightweight hover docs: hover a "key:" → its schema description. No // add-on — a debounced mousemove over the editor + a fixed-position tip. function attachHover(cm, provider) { var tip = null, timer = null; function hide() { if (tip && tip.parentNode) tip.parentNode.removeChild(tip); tip = null; } function show(text, x, y) { hide(); tip = document.createElement('div'); tip.className = 'cm-doc-tip'; tip.textContent = text; document.body.appendChild(tip); tip.style.left = x + 'px'; tip.style.top = (y + 16) + 'px'; } var wrap = cm.getWrapperElement(); wrap.addEventListener('mousemove', function (e) { if (timer) clearTimeout(timer); var ex = e.clientX, ey = e.clientY; timer = setTimeout(function () { if (!wrap.isConnected) { hide(); return; } try { var pos = cm.coordsChar({ left: ex, top: ey }, 'window'); var line = cm.getLine(pos.line) || ''; var m = line.match(/^\s*([\w.\-]+)\s*:/); if (!m) { hide(); return; } var keyStart = line.indexOf(m[1]); if (pos.ch < keyStart || pos.ch > keyStart + m[1].length) { hide(); return; } var doc = provider.describe(pathAt(cm, pos.line), m[1]); if (doc) show(doc, ex, ey); else hide(); } catch (_e) { hide(); } }, 350); }); wrap.addEventListener('mouseleave', function () { if (timer) clearTimeout(timer); hide(); }); cm.on('cursorActivity', hide); cm.on('changes', hide); } // Wire completion (Ctrl-Space + auto-trigger as you type) and hover docs // onto a CodeMirror instance. opts.readOnly skips the typing trigger; // opts.hover:false skips hover. function attach(cm, provider, opts) { opts = opts || {}; var hinter = makeHinter(provider); var keys = Object.assign({}, cm.getOption('extraKeys') || {}, { 'Ctrl-Space': function (c) { c.showHint({ hint: hinter, completeSingle: false }); } }); cm.setOption('extraKeys', keys); if (!opts.readOnly) { cm.on('inputRead', function (c, change) { if (!change.text || change.text.length !== 1) return; // skip paste/delete if (!/[\w.\-]/.test(change.text[0])) return; c.showHint({ hint: hinter, completeSingle: false }); }); } if (opts.hover !== false) attachHover(cm, provider); return hinter; } // ── Providers ─────────────────────────────────────────────────────────── // Flat: a fixed field list [{name, hint, values}] at the root, nothing // nested (front matter). opts.exclude = names never suggested. function flatProvider(getFields, opts) { opts = opts || {}; var exclude = {}; (opts.exclude || []).forEach(function (n) { exclude[n] = true; }); function fields() { return getFields() || []; } function find(name) { var fs = fields(); for (var i = 0; i < fs.length; i++) if (fs[i].name === name) return fs[i]; return null; } return { keysAt: function (path) { if (path.length) return []; return fields().filter(function (f) { return !exclude[f.name]; }) .map(function (f) { return { name: f.name, hint: f.hint, values: f.values }; }); }, valuesFor: function (path, key) { if (path.length) return null; var f = find(key); return f ? f.values : null; }, describe: function (path, key) { if (path.length) return null; var f = find(key); return f ? f.hint : null; } }; } // Schema: a JSON Schema (draft-2020-12 subset). Resolves nested key-paths // through properties / additionalProperties / patternProperties and the // recursive $ref:"#" .zddc uses for paths:. Keys = object property names; // values = enum / boolean. function schemaProvider(getSchema) { function root() { return getSchema(); } function deref(node) { return (node && node.$ref === '#') ? root() : node; } function stepInto(node, seg) { node = deref(node); if (!node || node.type !== 'object') return null; if (node.properties && node.properties[seg]) return node.properties[seg]; if (node.additionalProperties && typeof node.additionalProperties === 'object') { return node.additionalProperties; } if (node.patternProperties) { for (var p in node.patternProperties) { if (Object.prototype.hasOwnProperty.call(node.patternProperties, p)) { return node.patternProperties[p]; } } } return null; } function containerAt(path) { var node = deref(root()); for (var i = 0; i < path.length; i++) { node = stepInto(node, path[i]); if (!node) return null; node = deref(node); } return node; } function valuesOf(node) { node = deref(node); if (!node) return null; if (Array.isArray(node.enum)) return node.enum.map(String); if (node.type === 'boolean') return ['true', 'false']; return null; } function keyNodeAt(path, key) { var c = containerAt(path); if (!c || !c.properties) return null; return c.properties[key] || null; } return { keysAt: function (path) { var c = containerAt(path); if (!c || c.type !== 'object' || !c.properties) return []; return Object.keys(c.properties).map(function (name) { var n = deref(c.properties[name]) || {}; return { name: name, hint: n.description, values: valuesOf(n) }; }); }, valuesFor: function (path, key) { return valuesOf(keyNodeAt(path, key)); }, describe: function (path, key) { var n = deref(keyNodeAt(path, key)); return n ? n.description : null; } }; } window.app.modules.yamlComplete = { attach: attach, makeHinter: makeHinter, flatProvider: flatProvider, schemaProvider: schemaProvider }; })();