From a13ce12a750d0015833e16913c717ddf94a95e23 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Mon, 8 Jun 2026 09:18:07 -0500 Subject: [PATCH] feat(browse): shared schema completion + hover docs; bring it to the .zddc editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- browse/build.sh | 1 + browse/css/preview-yaml.css | 18 +++ browse/js/preview-markdown.js | 92 ++---------- browse/js/preview-yaml.js | 26 ++++ browse/js/yaml-complete.js | 275 ++++++++++++++++++++++++++++++++++ 5 files changed, 329 insertions(+), 83 deletions(-) create mode 100644 browse/js/yaml-complete.js diff --git a/browse/build.sh b/browse/build.sh index 9262b36..a4242d2 100755 --- a/browse/build.sh +++ b/browse/build.sh @@ -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" \ diff --git a/browse/css/preview-yaml.css b/browse/css/preview-yaml.css index 281dfcb..6f0441e 100644 --- a/browse/css/preview-yaml.css +++ b/browse/css/preview-yaml.css @@ -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. */ diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index e03b423..10222b0 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -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. diff --git a/browse/js/preview-yaml.js b/browse/js/preview-yaml.js index 1e8ac17..7d5ebb5 100644 --- a/browse/js/preview-yaml.js +++ b/browse/js/preview-yaml.js @@ -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; diff --git a/browse/js/yaml-complete.js b/browse/js/yaml-complete.js new file mode 100644 index 0000000..59d828a --- /dev/null +++ b/browse/js/yaml-complete.js @@ -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 + }; +})();