From 05e37256b7ec1b532431771b3d44047fc445e9b8 Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 08:34:28 -0500 Subject: [PATCH] feat(editor): add revision/status/tracking_number FM hints + filename-mismatch warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per review: the doctype templates render $revision$, $status$, $tracking_number$ and $title$, so they belong in the recognised front-matter list — added them (alongside the existing title) to convert.RecognizedFrontMatter. These four are the document's canonical identity, sourced from the ZDDC filename. Policy (chosen): the filename WINS — the rendered doc always uses the filename-derived value (the HTML/PDF templates read it from the filename-derived pandoc -V flags, which override YAML metadata). Front matter must not silently diverge, so: - their hints now read "set by the filename (the filename wins on mismatch)"; - the markdown editor shows a non-blocking warning when front matter sets one of the four to a value differing from the filename (gated on a conventional ZDDC filename — non-conventional files have no canonical identity, so front matter stays free there). Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/preview-markdown.js | 46 ++++++++++++++++++++ zddc/internal/convert/convert.go | 5 ++- zddc/internal/handler/converthandler_test.go | 5 ++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index eb1cff8..f4abafa 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -411,7 +411,18 @@ fmTextarea.placeholder = ''; applyFrontMatterPlaceholder(fmTextarea); fmBody.appendChild(fmTextarea); + // Non-blocking warning shown when front matter disagrees with the + // canonical filename on an identity field (tracking_number / revision / + // status / title). The filename always wins in the rendered doc; this + // just tells the author their front-matter value is being ignored. + var fmWarn = document.createElement('div'); + fmWarn.className = 'md-fm__warn'; + fmWarn.hidden = true; + fmWarn.style.cssText = 'color:#92400e;background:#fffbeb;border:1px solid ' + + '#fcd34d;border-radius:4px;padding:4px 8px;margin:0 0 4px;font-size:' + + '0.78rem;line-height:1.4;'; fmSection.appendChild(fmHeader); + fmSection.appendChild(fmWarn); fmSection.appendChild(fmBody); sidebar.appendChild(fmSection); @@ -736,14 +747,49 @@ }, 250); editor.on('change', onChange); + // Identity fields are sourced from the canonical ZDDC filename; setting + // a different value in front matter is ignored at render (the filename + // wins). Surface a mismatch so the author isn't silently overridden. + // Maps the front-matter key to the parseFilename field. + var IDENTITY_FIELDS = [ + { fm: 'title', fn: 'title', label: 'title' }, + { fm: 'tracking_number', fn: 'trackingNumber', label: 'tracking number' }, + { fm: 'revision', fn: 'revision', label: 'revision' }, + { fm: 'status', fn: 'status', label: 'status' } + ]; + function checkFilenameMismatch() { + var z = window.zddc; + var fn = (z && z.parseFilename) ? z.parseFilename(node.name) : null; + // Only meaningful for a conventional ZDDC filename (it always has a + // tracking number). Non-conventional files have no canonical + // identity, so front matter is free — no warning. + if (!fn || !fn.trackingNumber) { fmWarn.hidden = true; return; } + var data = parseFrontMatter('---\n' + fmTextarea.value + '\n---\n').data || {}; + var clashes = []; + IDENTITY_FIELDS.forEach(function (f) { + if (!(f.fm in data)) return; + var got = String(data[f.fm] == null ? '' : data[f.fm]).trim(); + var want = String(fn[f.fn] == null ? '' : fn[f.fn]).trim(); + if (got !== '' && want !== '' && got !== want) { + clashes.push(f.label + ' “' + got + '” ≠ filename “' + want + '”'); + } + }); + if (!clashes.length) { fmWarn.hidden = true; fmWarn.textContent = ''; return; } + fmWarn.textContent = '⚠ Front matter disagrees with the filename (the ' + + 'filename wins): ' + clashes.join('; ') + '.'; + fmWarn.hidden = false; + } + var onFmChange = debounce(async function () { if (currentInstance !== instance) return; var body = editor.getMarkdown(); var h = await hashContent(assembleContent(fmTextarea.value, body)); if (currentInstance !== instance) return; markDirty(h !== instance.hash); + checkFilenameMismatch(); }, 250); fmTextarea.addEventListener('input', onFmChange); + checkFilenameMismatch(); // initial state on load // ── Save ─────────────────────────────────────────────────────────── // Mark a successful write: adopt the new server ETag (so the next diff --git a/zddc/internal/convert/convert.go b/zddc/internal/convert/convert.go index 0ac0347..27b673d 100644 --- a/zddc/internal/convert/convert.go +++ b/zddc/internal/convert/convert.go @@ -78,7 +78,10 @@ func RecognizedFrontMatter() []FrontMatterField { return []FrontMatterField{ {"doctype", "report | letter | specification"}, {"numbering", "true to number headings (default false)"}, - {"title", "overrides the filename-derived title"}, + {"title", "set by the filename (the filename wins on mismatch)"}, + {"tracking_number", "set by the filename (the filename wins on mismatch)"}, + {"revision", "set by the filename (the filename wins on mismatch)"}, + {"status", "set by the filename (the filename wins on mismatch)"}, {"date", "document date (free text)"}, {"custom_header", "extra line shown in the document header"}, {"client", "overrides the .zddc convert: cascade"}, diff --git a/zddc/internal/handler/converthandler_test.go b/zddc/internal/handler/converthandler_test.go index 8cf985d..6d10571 100644 --- a/zddc/internal/handler/converthandler_test.go +++ b/zddc/internal/handler/converthandler_test.go @@ -90,8 +90,9 @@ func TestServeFrontMatterTemplate(t *testing.T) { if len(payload.Fields) == 0 { t.Fatal("fields empty") } - // The two keys with no other source are the ones authors most need hinted. - for _, want := range []string{"doctype", "numbering"} { + // doctype/numbering have no other source; revision/status are template + // fields an author can override here — all must be communicated. + for _, want := range []string{"doctype", "numbering", "revision", "status", "tracking_number"} { if !strings.Contains(payload.Placeholder, want) { t.Errorf("placeholder missing %q: %q", want, payload.Placeholder) }