feat(browse): schema completion in the front-matter editor (keys + enum values)

Vendor CodeMirror's show-hint add-on and wire deterministic, schema-driven
completion into the markdown front-matter pane — NO heuristics, no AI; every
candidate comes from the converter's own field list.

- Vendor codemirror-show-hint.min.{js,css} (CM 5.65.x add-on); concat in
  browse/build.sh after the core CM bundle so it extends window.CodeMirror.
- Server: add a structured `values` enum to convert.FrontMatterField (doctype →
  report/letter/specification, numbering → true/false), exposed via the existing
  /.api/frontmatter JSON. Tracks the template set; keeps the server as the
  single source of truth instead of parsing the hint prose.
- Client: frontMatterHints() completes recognised KEYS at line start (excluding
  the filename-driven identity keys and keys already present) and enum VALUES
  after `key:`. Picking an enum key auto-opens its value list. Triggered on
  Ctrl-Space and automatically as you type (completeSingle:false — always a
  menu, never an auto-guess). Themed dropdown for dark mode.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-08 09:09:37 -05:00
parent 84f93ba56d
commit 2c877bd5b7
6 changed files with 131 additions and 15 deletions

View file

@ -27,6 +27,7 @@ concat_files \
"../shared/logo.css" \ "../shared/logo.css" \
"../shared/vendor/toastui-editor.min.css" \ "../shared/vendor/toastui-editor.min.css" \
"../shared/vendor/codemirror-yaml.min.css" \ "../shared/vendor/codemirror-yaml.min.css" \
"../shared/vendor/codemirror-show-hint.min.css" \
"../shared/context-menu.css" \ "../shared/context-menu.css" \
"../shared/elevation.css" \ "../shared/elevation.css" \
"../shared/profile-menu.css" \ "../shared/profile-menu.css" \
@ -48,6 +49,7 @@ concat_files \
"../shared/vendor/utif.min.js" \ "../shared/vendor/utif.min.js" \
"../shared/vendor/js-yaml.min.js" \ "../shared/vendor/js-yaml.min.js" \
"../shared/vendor/codemirror-yaml.min.js" \ "../shared/vendor/codemirror-yaml.min.js" \
"../shared/vendor/codemirror-show-hint.min.js" \
"../shared/vendor/toastui-editor-all.min.js" \ "../shared/vendor/toastui-editor-all.min.js" \
"../shared/zddc.js" \ "../shared/zddc.js" \
"../shared/zddc-filter.js" \ "../shared/zddc-filter.js" \

View file

@ -928,6 +928,24 @@ body {
background: var(--bg-secondary); background: var(--bg-secondary);
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
} }
/* Schema-completion dropdown (show-hint add-on) theme it to the app
palette so it reads in dark mode; show-hint.css ships light-only. */
.CodeMirror-hints {
z-index: 9600;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Consolas, monospace);
font-size: 0.78rem;
background: var(--bg-elevated, var(--bg, #fff));
border: 1px solid var(--border, #ccc);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.28);
}
.CodeMirror-hint {
color: var(--text, #222);
padding: 2px 8px;
}
li.CodeMirror-hint-active {
background: var(--primary, #2868c8);
color: #fff;
}
/* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced /* Older .md-fm-section / .fm-list / .md-toc-resizer rules were replaced
by the .md-shell BEM block above. */ by the .md-shell BEM block above. */

View file

@ -82,6 +82,9 @@
// empty / unavailable. The promise dedupes concurrent fetches. // empty / unavailable. The promise dedupes concurrent fetches.
var fmPlaceholder = null; var fmPlaceholder = null;
var fmPlaceholderPromise = null; var fmPlaceholderPromise = null;
// Recognised fields ([{name, hint, values}]) from the same /.api/frontmatter
// fetch — drives schema completion (keys + enum values). null = not loaded.
var fmFields = null;
// applyFrontMatterHint populates a greyed caption (+ tooltip) with the // applyFrontMatterHint populates a greyed caption (+ tooltip) with the
// server's recognised front-matter fields, in server mode only. Async + // server's recognised front-matter fields, in server mode only. Async +
@ -104,8 +107,11 @@
headers: { 'Accept': 'application/json' }, headers: { 'Accept': 'application/json' },
credentials: 'same-origin' credentials: 'same-origin'
}).then(function (r) { return r.ok ? r.json() : null; }) }).then(function (r) { return r.ok ? r.json() : null; })
.then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; }) .then(function (j) {
.catch(function () { fmPlaceholder = ''; }); fmPlaceholder = (j && j.placeholder) || '';
fmFields = (j && j.fields) || [];
})
.catch(function () { fmPlaceholder = ''; fmFields = []; });
} }
fmPlaceholderPromise.then(paint); fmPlaceholderPromise.then(paint);
} }
@ -203,6 +209,73 @@
return out; 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) ──────────────────────────────────────────── // ── TOC (table of contents) ────────────────────────────────────────────
// ATX headings only; the body markdown drives the outline. Clicking // ATX headings only; the body markdown drives the outline. Clicking
// a heading routes to whichever Toast UI pane is currently active // a heading routes to whichever Toast UI pane is currently active
@ -639,11 +712,27 @@
gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'], gutters: ['CodeMirror-lint-markers', 'CodeMirror-linenumbers'],
lint: { hasGutters: true }, lint: { hasGutters: true },
autofocus: false, autofocus: false,
readOnly: !writableMode readOnly: !writableMode,
// Ctrl-Space (and Tab as a convenience) opens schema completion.
extraKeys: {
'Ctrl-Space': function (cm) {
cm.showHint({ hint: frontMatterHints, completeSingle: false });
}
}
}); });
// The yaml lint helper (registered by the .zddc previewer) checks this // The yaml lint helper (registered by the .zddc previewer) checks this
// to decide the schema layer; a .md node → plain js-yaml parse lint. // to decide the schema layer; a .md node → plain js-yaml parse lint.
fmCM._zddcNode = node; 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 });
});
}
// CodeMirror mis-measures when mounted before its pane is laid out; // CodeMirror mis-measures when mounted before its pane is laid out;
// refresh on the next frame so the gutters + scroll size correctly. // refresh on the next frame so the gutters + scroll size correctly.
requestAnimationFrame(function () { fmCM.refresh(); }); requestAnimationFrame(function () { fmCM.refresh(); });

View file

@ -0,0 +1 @@
.CodeMirror-hints{position:absolute;z-index:10;overflow:hidden;list-style:none;margin:0;padding:2px;-webkit-box-shadow:2px 3px 5px rgba(0,0,0,.2);-moz-box-shadow:2px 3px 5px rgba(0,0,0,.2);box-shadow:2px 3px 5px rgba(0,0,0,.2);border-radius:3px;border:1px solid silver;background:#fff;font-size:90%;font-family:monospace;max-height:20em;overflow-y:auto;box-sizing:border-box}.CodeMirror-hint{margin:0;padding:0 4px;border-radius:2px;white-space:pre;color:#000;cursor:pointer}li.CodeMirror-hint-active{background:#08f;color:#fff}

File diff suppressed because one or more lines are too long

View file

@ -65,6 +65,9 @@ type Metadata struct {
type FrontMatterField struct { type FrontMatterField struct {
Name string `json:"name"` Name string `json:"name"`
Hint string `json:"hint"` Hint string `json:"hint"`
// Values is the closed set of valid values for this key (an enum), used
// by the editor for value completion. Empty/nil = free-text.
Values []string `json:"values,omitempty"`
} }
// RecognizedFrontMatter is the single source of truth for the front-matter keys // RecognizedFrontMatter is the single source of truth for the front-matter keys
@ -76,18 +79,20 @@ type FrontMatterField struct {
// user most needs told about. // user most needs told about.
func RecognizedFrontMatter() []FrontMatterField { func RecognizedFrontMatter() []FrontMatterField {
return []FrontMatterField{ return []FrontMatterField{
{"doctype", "report | letter | specification"}, // doctype enum tracks the template set (internal/convert/templates/
{"numbering", "true to number headings (default false)"}, // *.html, sans the _-prefixed partials).
{"title", "mirrors the filename — rename the file to change it"}, {"doctype", "report | letter | specification", []string{"report", "letter", "specification"}},
{"tracking_number", "mirrors the filename — rename the file to change it"}, {"numbering", "true to number headings (default false)", []string{"true", "false"}},
{"revision", "mirrors the filename — rename the file to change it"}, {"title", "mirrors the filename — rename the file to change it", nil},
{"status", "mirrors the filename — rename the file to change it"}, {"tracking_number", "mirrors the filename — rename the file to change it", nil},
{"date", "document date (free text)"}, {"revision", "mirrors the filename — rename the file to change it", nil},
{"custom_header", "extra line shown in the document header"}, {"status", "mirrors the filename — rename the file to change it", nil},
{"client", "overrides the .zddc convert: cascade"}, {"date", "document date (free text)", nil},
{"project", "overrides the .zddc convert: cascade"}, {"custom_header", "extra line shown in the document header", nil},
{"project_number", "overrides the .zddc convert: cascade"}, {"client", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade"}, {"project", "overrides the .zddc convert: cascade", nil},
{"project_number", "overrides the .zddc convert: cascade", nil},
{"contractor", "overrides the .zddc convert: cascade", nil},
} }
} }