From 85e0061d6c451e6023e375165516e1e0bdf9375c Mon Sep 17 00:00:00 2001 From: ZDDC Date: Fri, 5 Jun 2026 08:23:25 -0500 Subject: [PATCH] feat(editor): hint recognized front-matter fields via server placeholder MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The markdown editor's YAML front-matter pane was a bare textarea, so authors had no way to discover the keys the converter honours — notably `doctype:` (report|letter|specification) and `numbering:`, which have no other source. Add a single server-side source of truth, convert.RecognizedFrontMatter() + convert.FrontMatterPlaceholder(), and expose it as JSON at GET /.api/frontmatter (handler.ServeFrontMatterTemplate; read-only, no auth — leaks only documented field names). The browse editor fetches it once (server mode) and sets the front-matter textarea's placeholder to the greyed hint, so an empty pane shows the recognized keys with one-line hints. It's placeholder-only: it inserts nothing, vanishes on the first keystroke, and arbitrary keys remain free — front matter is still passed through to pandoc untouched. file:// mode shows no placeholder (conversion is server-only). Co-Authored-By: Claude Opus 4.8 (1M context) --- browse/js/preview-markdown.js | 44 +++++++++++++++-- zddc/cmd/zddc-server/main.go | 8 +++ zddc/internal/convert/convert.go | 51 ++++++++++++++++++++ zddc/internal/handler/converthandler.go | 32 ++++++++++++ zddc/internal/handler/converthandler_test.go | 46 ++++++++++++++++++ 5 files changed, 178 insertions(+), 3 deletions(-) diff --git a/browse/js/preview-markdown.js b/browse/js/preview-markdown.js index 84e98d1..eb1cff8 100644 --- a/browse/js/preview-markdown.js +++ b/browse/js/preview-markdown.js @@ -76,6 +76,38 @@ } // ── Front matter ──────────────────────────────────────────────────────── + // Cached recognised-front-matter placeholder, fetched once from the server + // (/.api/frontmatter — the single source of truth that mirrors the + // converter's RecognizedFrontMatter). null = not yet fetched; '' = fetched + // empty / unavailable. The promise dedupes concurrent fetches. + var fmPlaceholder = null; + var fmPlaceholderPromise = null; + + // applyFrontMatterPlaceholder sets the textarea placeholder to the server's + // recognised-field hint, in server mode only. Async + best-effort: a failed + // fetch leaves the pane blank (no placeholder), never an error. + function applyFrontMatterPlaceholder(textarea) { + var st = window.app && window.app.state; + if (!st || st.source !== 'server') return; + if (fmPlaceholder !== null) { + textarea.placeholder = fmPlaceholder; + return; + } + if (!fmPlaceholderPromise) { + fmPlaceholderPromise = fetch('/.api/frontmatter', { + headers: { 'Accept': 'application/json' }, + credentials: 'same-origin' + }).then(function (r) { return r.ok ? r.json() : null; }) + .then(function (j) { fmPlaceholder = (j && j.placeholder) || ''; }) + .catch(function () { fmPlaceholder = ''; }); + } + fmPlaceholderPromise.then(function () { + // Only apply if this textarea is still in the DOM (user may have + // switched files before the fetch resolved). + if (textarea.isConnected) textarea.placeholder = fmPlaceholder; + }); + } + // Lightweight YAML front-matter parser. Same envelope as mdedit's: // `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays. @@ -368,10 +400,16 @@ fmTextarea.spellcheck = false; fmTextarea.autocapitalize = 'off'; fmTextarea.autocomplete = 'off'; - // No placeholder text — files with no YAML front matter render - // as a genuinely empty pane. Showing a synthetic example would - // make the file look like it had data when it doesn't. + // Placeholder: in server mode, hint the recognised front-matter keys + // (doctype, numbering, …) as greyed text so authors can discover them. + // It's placeholder-only — inserts nothing, vanishes on the first + // keystroke — so arbitrary keys stay free and a file with no front + // matter still renders as a genuinely empty pane. The text is fetched + // from the server (/.api/frontmatter), the single source of truth, so + // it never drifts from what the converter honours. file:// mode shows + // no placeholder (conversion is server-only). fmTextarea.placeholder = ''; + applyFrontMatterPlaceholder(fmTextarea); fmBody.appendChild(fmTextarea); fmSection.appendChild(fmHeader); fmSection.appendChild(fmBody); diff --git a/zddc/cmd/zddc-server/main.go b/zddc/cmd/zddc-server/main.go index 3801e40..f391604 100644 --- a/zddc/cmd/zddc-server/main.go +++ b/zddc/cmd/zddc-server/main.go @@ -783,6 +783,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps return } + // Recognised markdown front-matter fields + editor placeholder (JSON). + // The browse markdown editor fetches this to hint the valid keys; it's + // static, read-only, and leaks nothing, so no auth gate. + if urlPath == handler.FrontMatterTemplatePath { + handler.ServeFrontMatterTemplate(w, r) + return + } + // Auth check endpoints — machine-only forward_auth targets used by // upstream proxies (e.g. the dev-shell pod's Caddy in front of // code-server) to gate routes on root-admin status. Handled before diff --git a/zddc/internal/convert/convert.go b/zddc/internal/convert/convert.go index 7ff3f43..0ac0347 100644 --- a/zddc/internal/convert/convert.go +++ b/zddc/internal/convert/convert.go @@ -58,6 +58,57 @@ type Metadata struct { NoTOC bool } +// FrontMatterField is a YAML front-matter key the conversion pipeline honours, +// paired with a short human hint. Clients (the markdown editor) use this to +// communicate the recognised fields to authors while still allowing arbitrary +// keys (anything else is passed straight through to pandoc). +type FrontMatterField struct { + Name string `json:"name"` + Hint string `json:"hint"` +} + +// RecognizedFrontMatter is the single source of truth for the front-matter keys +// the converter + doctype templates honour, in a sensible authoring order. All +// are optional. title/tracking_number/revision/status are normally derived from +// the filename and client/project/project_number/contractor from the .zddc +// `convert:` cascade — listing them here lets an author OVERRIDE those. doctype, +// numbering, date and custom_header have no other source, so they're the ones a +// user most needs told about. +func RecognizedFrontMatter() []FrontMatterField { + return []FrontMatterField{ + {"doctype", "report | letter | specification"}, + {"numbering", "true to number headings (default false)"}, + {"title", "overrides the filename-derived title"}, + {"date", "document date (free text)"}, + {"custom_header", "extra line shown in the document header"}, + {"client", "overrides the .zddc convert: cascade"}, + {"project", "overrides the .zddc convert: cascade"}, + {"project_number", "overrides the .zddc convert: cascade"}, + {"contractor", "overrides the .zddc convert: cascade"}, + } +} + +// FrontMatterPlaceholder renders RecognizedFrontMatter as greyed editor +// placeholder text: a leading note, then one "key: # hint" line per field. +// Shown when the front-matter box is empty; it inserts nothing (placeholder +// vanishes once the author types), so arbitrary keys remain free. +func FrontMatterPlaceholder() string { + var b strings.Builder + b.WriteString("# Recognised front matter (all optional; any other key is allowed):\n") + fields := RecognizedFrontMatter() + width := 0 + for _, f := range fields { + if len(f.Name) > width { + width = len(f.Name) + } + } + for _, f := range fields { + pad := strings.Repeat(" ", width-len(f.Name)) + b.WriteString(f.Name + ":" + pad + " # " + f.Hint + "\n") + } + return b.String() +} + // TemplateSet is the bundle of files written to the per-call scratch dir for an // HTML render: the chosen doctype template (Name) plus every partial it may // include. pandoc resolves `$partial()$` includes from the template's own diff --git a/zddc/internal/handler/converthandler.go b/zddc/internal/handler/converthandler.go index 1059274..4589e82 100644 --- a/zddc/internal/handler/converthandler.go +++ b/zddc/internal/handler/converthandler.go @@ -2,6 +2,7 @@ package handler import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -346,3 +347,34 @@ func mapConvertError(w http.ResponseWriter, err error, format string) { slog.Warn("convert: unexpected error", "format", format, "err", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) } + +// FrontMatterTemplatePath is the JSON endpoint that exposes the recognised +// markdown front-matter fields + a ready-made greyed placeholder string. The +// browse markdown editor fetches it (server mode) to communicate the valid +// keys to authors without baking the list into client JS — it stays in sync +// with convert.RecognizedFrontMatter, the server-side source of truth. +const FrontMatterTemplatePath = "/.api/frontmatter" + +// ServeFrontMatterTemplate returns the recognised front-matter fields and the +// editor placeholder as JSON. Read-only, no auth gate: it leaks nothing beyond +// the documented field names. GET/HEAD only. +func ServeFrontMatterTemplate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + w.Header().Set("Allow", "GET, HEAD") + http.Error(w, "Method Not Allowed", http.StatusMethodNotAllowed) + return + } + payload := struct { + Placeholder string `json:"placeholder"` + Fields []convert.FrontMatterField `json:"fields"` + }{ + Placeholder: convert.FrontMatterPlaceholder(), + Fields: convert.RecognizedFrontMatter(), + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.Header().Set("Cache-Control", "max-age=300") + if r.Method == http.MethodHead { + return + } + _ = json.NewEncoder(w).Encode(payload) +} diff --git a/zddc/internal/handler/converthandler_test.go b/zddc/internal/handler/converthandler_test.go index dce4d43..8cf985d 100644 --- a/zddc/internal/handler/converthandler_test.go +++ b/zddc/internal/handler/converthandler_test.go @@ -1,8 +1,12 @@ package handler import ( + "encoding/json" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strings" "testing" ) @@ -63,3 +67,45 @@ func TestRecognizeVirtualConvert_MatrixAndPrecedence(t *testing.T) { }) } } + +func TestServeFrontMatterTemplate(t *testing.T) { + rec := httptest.NewRecorder() + ServeFrontMatterTemplate(rec, httptest.NewRequest(http.MethodGet, FrontMatterTemplatePath, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status=%d, want 200", rec.Code) + } + if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") { + t.Errorf("Content-Type=%q, want application/json", ct) + } + var payload struct { + Placeholder string `json:"placeholder"` + Fields []struct { + Name string `json:"name"` + Hint string `json:"hint"` + } `json:"fields"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode: %v; body=%s", err, rec.Body.String()) + } + 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"} { + if !strings.Contains(payload.Placeholder, want) { + t.Errorf("placeholder missing %q: %q", want, payload.Placeholder) + } + } + // HEAD returns headers, no body. + hrec := httptest.NewRecorder() + ServeFrontMatterTemplate(hrec, httptest.NewRequest(http.MethodHead, FrontMatterTemplatePath, nil)) + if hrec.Code != http.StatusOK || hrec.Body.Len() != 0 { + t.Errorf("HEAD: status=%d bodylen=%d, want 200 + empty", hrec.Code, hrec.Body.Len()) + } + // Non-GET/HEAD is rejected. + prec := httptest.NewRecorder() + ServeFrontMatterTemplate(prec, httptest.NewRequest(http.MethodPost, FrontMatterTemplatePath, nil)) + if prec.Code != http.StatusMethodNotAllowed { + t.Errorf("POST: status=%d, want 405", prec.Code) + } +}