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:
parent
2c877bd5b7
commit
a13ce12a75
5 changed files with 329 additions and 83 deletions
|
|
@ -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" \
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
275
browse/js/yaml-complete.js
Normal 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
|
||||
};
|
||||
})();
|
||||
Loading…
Reference in a new issue