Generalise the front-matter completion into a reusable, provider-based helper
(browse/js/yaml-complete.js) and wire BOTH YAML editors through it. Still fully
deterministic — every candidate and doc string comes from a schema, no AI.
- yaml-complete.js: shared CodeMirror plumbing (indent→key-path, sibling scan,
show-hint, debounced hover tooltip) + two providers:
· flatProvider — a fixed field list (front matter), with an exclude set.
· schemaProvider — a JSON Schema walker that resolves nested key-paths
through properties / additionalProperties / patternProperties and the
recursive $ref:"#" .zddc uses for paths:; keys from object properties,
values from enum / boolean, hover docs from `description`.
- .zddc editor (preview-yaml.js): fetch /.api/zddc-schema once and attach the
schemaProvider on .zddc files — nested-key completion at every level, enum
values (default_tool, dir_tool, views.*.tool), booleans, and hover docs.
Plain .yaml stays lint+highlight only.
- Front-matter editor (preview-markdown.js): refactored to delegate to the
shared helper via flatProvider (excluding the filename-driven identity keys);
the bespoke frontMatterHints is gone — one implementation now.
- Hover-doc tooltip styling.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
275 lines
12 KiB
JavaScript
275 lines
12 KiB
JavaScript
// 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
|
|
};
|
|
})();
|