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:
parent
3823946d4f
commit
85e0061d6c
5 changed files with 178 additions and 3 deletions
|
|
@ -76,6 +76,38 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Front matter ────────────────────────────────────────────────────────
|
// ── 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:
|
// Lightweight YAML front-matter parser. Same envelope as mdedit's:
|
||||||
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
// `---\n…\n---\n`, key:value lines, simple `[a, b, c]` arrays.
|
||||||
|
|
||||||
|
|
@ -368,10 +400,16 @@
|
||||||
fmTextarea.spellcheck = false;
|
fmTextarea.spellcheck = false;
|
||||||
fmTextarea.autocapitalize = 'off';
|
fmTextarea.autocapitalize = 'off';
|
||||||
fmTextarea.autocomplete = 'off';
|
fmTextarea.autocomplete = 'off';
|
||||||
// No placeholder text — files with no YAML front matter render
|
// Placeholder: in server mode, hint the recognised front-matter keys
|
||||||
// as a genuinely empty pane. Showing a synthetic example would
|
// (doctype, numbering, …) as greyed text so authors can discover them.
|
||||||
// make the file look like it had data when it doesn't.
|
// 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 = '';
|
fmTextarea.placeholder = '';
|
||||||
|
applyFrontMatterPlaceholder(fmTextarea);
|
||||||
fmBody.appendChild(fmTextarea);
|
fmBody.appendChild(fmTextarea);
|
||||||
fmSection.appendChild(fmHeader);
|
fmSection.appendChild(fmHeader);
|
||||||
fmSection.appendChild(fmBody);
|
fmSection.appendChild(fmBody);
|
||||||
|
|
|
||||||
|
|
@ -783,6 +783,14 @@ func dispatch(cfg config.Config, idx *archive.Index, ring *handler.LogRing, apps
|
||||||
return
|
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
|
// Auth check endpoints — machine-only forward_auth targets used by
|
||||||
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
|
// upstream proxies (e.g. the dev-shell pod's Caddy in front of
|
||||||
// code-server) to gate routes on root-admin status. Handled before
|
// code-server) to gate routes on root-admin status. Handled before
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,57 @@ type Metadata struct {
|
||||||
NoTOC bool
|
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
|
// 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
|
// HTML render: the chosen doctype template (Name) plus every partial it may
|
||||||
// include. pandoc resolves `$partial()$` includes from the template's own
|
// include. pandoc resolves `$partial()$` includes from the template's own
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
|
@ -346,3 +347,34 @@ func mapConvertError(w http.ResponseWriter, err error, format string) {
|
||||||
slog.Warn("convert: unexpected error", "format", format, "err", err)
|
slog.Warn("convert: unexpected error", "format", format, "err", err)
|
||||||
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
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)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,12 @@
|
||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue