feat(browse): shared schema completion + hover docs; bring it to the .zddc editor

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>
This commit is contained in:
ZDDC 2026-06-08 09:18:07 -05:00
parent 2c877bd5b7
commit a13ce12a75
5 changed files with 329 additions and 83 deletions

View file

@ -68,6 +68,7 @@ concat_files \
"../shared/zddc-source.js" \
"js/init.js" \
"js/util.js" \
"js/yaml-complete.js" \
"js/conflict.js" \
"js/menu-model.js" \
"js/loader.js" \

View file

@ -40,6 +40,24 @@
outline: none;
}
/* Hover-doc tooltip (yaml-complete.js) appended to document.body, so it's
styled globally. Carries a key's schema description on hover. */
.cm-doc-tip {
position: fixed;
z-index: 9700;
max-width: 360px;
padding: 6px 9px;
font-size: 0.75rem;
line-height: 1.4;
background: var(--bg-elevated, var(--bg, #fff));
color: var(--text, #222);
border: 1px solid var(--border, #ccc);
border-radius: 4px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
pointer-events: none;
white-space: normal;
}
/* CodeMirror has to fill the grid cell. The vendored CSS sets
`height: 300px` by default we override to 100% so it grows with
the preview pane. */

View file

@ -209,73 +209,6 @@
return out;
}
// ── Schema completion for the front-matter editor ──────────────────────
// Deterministic, schema-driven — NO heuristics, no AI. Keys + enum values
// come straight from /.api/frontmatter (fmFields), the converter's own
// field list. Returns a CodeMirror show-hint result ({list, from, to}) or
// null. The front matter is flat (top-level keys), so no nesting to walk.
function frontMatterHints(cm) {
if (!fmFields || !fmFields.length || !window.CodeMirror) return null;
var Pos = window.CodeMirror.Pos;
var cur = cm.getCursor();
var before = cm.getLine(cur.line).slice(0, cur.ch);
var colon = before.indexOf(':');
if (colon === -1) {
// KEY context — complete recognised keys, minus the filename-driven
// identity keys (we don't want those typed into front matter) and
// keys already present in the document.
var m = before.match(/^(\s*)([\w-]*)$/);
if (!m) return null;
var indent = m[1], typed = m[2];
var identity = {};
IDENTITY_FIELDS.forEach(function (f) { identity[f.fm] = true; });
var present = {};
for (var ln = 0; ln < cm.lineCount(); ln++) {
var km = cm.getLine(ln).match(/^\s*([\w-]+)\s*:/);
if (km) present[km[1]] = true;
}
var list = [];
fmFields.forEach(function (f) {
if (identity[f.name] || present[f.name]) return;
if (typed && f.name.indexOf(typed) !== 0) return;
var item = {
text: f.name + ': ',
displayText: f.name + (f.hint ? ' — ' + f.hint : '')
};
// If the key is an enum, insert "key: " then immediately offer
// its values — one fluid keystroke path for doctype/numbering.
if (f.values && f.values.length) {
item.hint = function (cmi, data, comp) {
cmi.replaceRange(comp.text, data.from, data.to);
setTimeout(function () {
cmi.showHint({ hint: frontMatterHints, completeSingle: false });
}, 0);
};
}
list.push(item);
});
if (!list.length) return null;
return { list: list, from: Pos(cur.line, indent.length), to: cur };
}
// VALUE context — complete the enum values for this line's key.
var key = before.slice(0, colon).trim();
var field = null;
for (var i = 0; i < fmFields.length; i++) {
if (fmFields[i].name === key) { field = fmFields[i]; break; }
}
if (!field || !field.values || !field.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 = field.values.filter(function (v) {
return v.indexOf(valTyped) === 0;
}).map(function (v) { return { text: v, displayText: v }; });
if (!vlist.length) return null;
return { list: vlist, from: Pos(cur.line, valStart), to: cur };
}
// ── TOC (table of contents) ────────────────────────────────────────────
// ATX headings only; the body markdown drives the outline. Clicking
// a heading routes to whichever Toast UI pane is currently active
@ -712,26 +645,19 @@
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true },
autofocus: false,
readOnly: !writableMode,
// Ctrl-Space (and Tab as a convenience) opens schema completion.
extraKeys: {
'Ctrl-Space': function (cm) {
cm.showHint({ hint: frontMatterHints, completeSingle: false });
}
}
readOnly: !writableMode
});
// The yaml lint helper (registered by the .zddc previewer) checks this
// to decide the schema layer; a .md node → plain js-yaml parse lint.
fmCM._zddcNode = node;
// Auto-open completion as the author types a key/value character (never
// auto-inserts — completeSingle:false — so it's a menu, not a guess).
// No-op when frontMatterHints finds nothing to offer.
if (writableMode) {
fmCM.on('inputRead', function (cm, change) {
if (!change.text || change.text.length !== 1) return; // skip paste/delete
if (!/[\w-]/.test(change.text[0])) return;
cm.showHint({ hint: frontMatterHints, completeSingle: false });
});
// Schema completion + hover docs via the shared helper. The front
// matter is flat; the identity keys are excluded from suggestions
// (filename-driven — see renderIdentityCue).
var yc = window.app.modules.yamlComplete;
if (yc) {
yc.attach(fmCM, yc.flatProvider(function () { return fmFields; }, {
exclude: IDENTITY_FIELDS.map(function (f) { return f.fm; })
}), { readOnly: !writableMode });
}
// CodeMirror mis-measures when mounted before its pane is laid out;
// refresh on the next frame so the gutters + scroll size correctly.

View file

@ -25,6 +25,23 @@
var util = window.app.modules.util;
var escapeHtml = util.escapeHtml;
// Cached .zddc JSON Schema (the machine grammar) — fetched once from
// /.api/zddc-schema and used to drive completion + hover on .zddc files.
// null = not loaded; {} on fetch failure. The promise dedupes fetches.
var zddcSchema = null;
var zddcSchemaPromise = null;
function loadZddcSchema() {
if (zddcSchema !== null) return;
if (!zddcSchemaPromise) {
zddcSchemaPromise = fetch('/.api/zddc-schema', {
headers: { 'Accept': 'application/schema+json, application/json' },
credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { zddcSchema = j || {}; })
.catch(function () { zddcSchema = {}; });
}
}
// ── Filename routing ────────────────────────────────────────────────────
// True for .zddc cascade files — `.zddc` (literal name, no ext)
@ -535,6 +552,15 @@
editor._zddcNode = node;
// Force an initial lint pass now that _zddcNode is set.
editor.performLint();
// Schema completion + hover docs for .zddc files (the machine grammar
// drives keys, enum/boolean values, and nested paths via $ref:"#").
// Plain .yaml gets no schema (lint + highlighting only).
var yc = window.app.modules.yamlComplete;
if (yc && isZddcFile(node.name)) {
loadZddcSchema();
yc.attach(editor, yc.schemaProvider(function () { return zddcSchema || {}; }),
{ readOnly: !writable });
}
currentEditor = editor;
currentNodeRef = node;
currentDirty = false;

275
browse/js/yaml-complete.js Normal file
View file

@ -0,0 +1,275 @@
// 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
};
})();