feat(editor): hint recognized front-matter fields via server placeholder

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) <noreply@anthropic.com>
This commit is contained in:
ZDDC 2026-06-05 08:23:25 -05:00
parent 3823946d4f
commit 85e0061d6c
5 changed files with 178 additions and 3 deletions

View file

@ -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);

View file

@ -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

View file

@ -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

View file

@ -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)
}

View file

@ -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)
}
}