From 376b024b4679d65c713369b6f333f74bdb78a1b6 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:36:18 -0500 Subject: [PATCH 1/5] fix(docs): render frontmatter as a collapsed metadata disclosure goldmark was reading the opening --- as a thematic break and folding the YAML into a mangled heading. The frontmatter extension now consumes it at parse; the parsed pairs render as a collapsed disclosure (kv grid) above the body, built post-sanitizer with every fragment HTML-escaped. Session-Name: deft-sonata Session-Id: 7fc5913c-2346-479f-a249-9d871812e47d Model: claude-fable-5 Harness: claude-code 2.1.252 Machine: cf6e768835c7 Co-Authored-By: Claude Fable 5 --- go.mod | 6 +- go.sum | 11 +++ internal/docs/render.go | 105 ++++++++++++++++++++++- internal/docs/render_frontmatter_test.go | 101 ++++++++++++++++++++++ 4 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 internal/docs/render_frontmatter_test.go diff --git a/go.mod b/go.mod index 0b8f3476..b007b279 100644 --- a/go.mod +++ b/go.mod @@ -16,10 +16,13 @@ require ( github.com/microcosm-cc/bluemonday v1.0.27 github.com/muesli/termenv v0.16.0 github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 github.com/yuin/goldmark v1.8.4 github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc + go.abhg.dev/goldmark/frontmatter v0.3.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -45,6 +48,7 @@ require ( github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/kr/text v0.2.0 // indirect github.com/lucasb-eyer/go-colorful v1.4.0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect @@ -57,8 +61,8 @@ require ( github.com/muesli/mango-pflag v0.1.0 // indirect github.com/muesli/roff v0.1.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/rogpeppe/go-internal v1.16.0 // indirect github.com/sahilm/fuzzy v0.1.1 // indirect - github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/net v0.55.0 // indirect golang.org/x/sync v0.20.0 // indirect diff --git a/go.sum b/go.sum index 8dde9253..6cd8bbcf 100644 --- a/go.sum +++ b/go.sum @@ -63,6 +63,7 @@ github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3 github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -92,6 +93,10 @@ github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= @@ -124,6 +129,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.16.0 h1:O9DK+vNMDVGLr2BeZqmpLeMjiMNkuXfcqntWbZV6S5g= +github.com/rogpeppe/go-internal v1.16.0/go.mod h1:DrUVZyrJU+txYW5/1kwtXQSMFio52ZOxX7yM1VHvnxs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA= github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= @@ -143,6 +150,8 @@ github.com/yuin/goldmark v1.8.4 h1:oat/nd3U6NeQqFEL3xpEJq7d7c86NI+DbSNGAs4xnjA= github.com/yuin/goldmark v1.8.4/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc h1:+IAOyRda+RLrxa1WC7umKOZRsGq4QrFFMYApOeHzQwQ= github.com/yuin/goldmark-highlighting/v2 v2.0.0-20230729083705-37449abec8cc/go.mod h1:ovIvrum6DQJA4QsJSovrkC4saKHQVs7TvcaeO8AIl5I= +go.abhg.dev/goldmark/frontmatter v0.3.0 h1:ZOrMkeyyYzhlbenFNmOXyGFx1dFE8TgBWAgZfs9D5RA= +go.abhg.dev/goldmark/frontmatter v0.3.0/go.mod h1:W3KXvVveKKxU1FIFZ7fgFFQrlkcolnDcOVmu19cCO9U= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= @@ -159,6 +168,8 @@ golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/docs/render.go b/internal/docs/render.go index 1e63ed2d..19b11736 100644 --- a/internal/docs/render.go +++ b/internal/docs/render.go @@ -3,7 +3,10 @@ package docs import ( "bytes" "fmt" + "html" "regexp" + "sort" + "strings" "sync" chromahtml "github.com/alecthomas/chroma/v2/formatters/html" @@ -14,6 +17,8 @@ import ( "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" goldmarkhtml "github.com/yuin/goldmark/renderer/html" + "go.abhg.dev/goldmark/frontmatter" + "gopkg.in/yaml.v3" ) // chromaStyle is the fixed syntax-highlighting palette. It is deliberately @@ -46,6 +51,11 @@ var markdown = goldmark.New( // never claims them — see mermaid.go for why a second renderer // alongside the highlighting extension is not an option. mermaidExtension{}, + // Consumes a leading YAML/TOML frontmatter block at parse time, so the + // delimiters stop rendering as a thematic break + mangled heading. The + // parsed data is read back per-render (frontmatter.Get) and presented + // as a collapsed metadata disclosure — see frontmatterHTML. + &frontmatter.Extender{}, ), goldmark.WithParserOptions(parser.WithAutoHeadingID()), goldmark.WithRendererOptions(goldmarkhtml.WithUnsafe()), @@ -465,13 +475,104 @@ var renderMu sync.Mutex // styling allowed). The result is safe to embed directly into a response — // sanitization is the last step, not a pre-filter goldmark's raw-HTML // passthrough could bypass. +// +// A leading frontmatter block, when present, is rendered as a collapsed +// disclosure ABOVE the sanitized body. That block is generated here from +// parsed values with every fragment HTML-escaped, which is why prepending it +// after sanitization does not reopen the XSS door the sanitizer closes: the +// document author's bytes only ever reach it through html.EscapeString. +// Building it post-sanitizer keeps the bluemonday allowlist untouched — +// details/summary/dl stay denied for document-authored HTML. func Render(source []byte) (string, error) { renderMu.Lock() var buf bytes.Buffer - err := markdown.Convert(source, &buf) + ctx := parser.NewContext() + err := markdown.Convert(source, &buf, parser.WithContext(ctx)) renderMu.Unlock() if err != nil { return "", fmt.Errorf("render markdown: %w", err) } - return string(sanitizer.SanitizeBytes(dropDuplicateSVGNamespaces(buf.Bytes()))), nil + body := string(sanitizer.SanitizeBytes(dropDuplicateSVGNamespaces(buf.Bytes()))) + return frontmatterHTML(ctx) + body, nil +} + +// frontmatterHTML renders a document's parsed frontmatter as a collapsed +// Artificer disclosure (accordion + kv grid), or "" when the document has +// none. Key order follows the document; a non-scalar value is shown as its +// YAML flow form rather than flattened. +func frontmatterHTML(ctx parser.Context) string { + fm := frontmatter.Get(ctx) + if fm == nil { + return "" + } + var node yaml.Node + if err := fm.Decode(&node); err != nil || len(node.Content) == 0 { + // TOML frontmatter (or unparseable YAML) has no yaml.Node form — + // fall back to the unordered map both formats can decode into. + return frontmatterHTMLUnordered(fm) + } + mapping := node.Content[0] + if mapping.Kind != yaml.MappingNode { + return frontmatterHTMLUnordered(fm) + } + var b strings.Builder + pairs := 0 + for i := 0; i+1 < len(mapping.Content); i += 2 { + key, value := mapping.Content[i], mapping.Content[i+1] + writeKV(&b, key.Value, yamlScalar(value)) + pairs++ + } + return wrapFrontmatter(b.String(), pairs) +} + +func frontmatterHTMLUnordered(fm *frontmatter.Data) string { + var m map[string]any + if err := fm.Decode(&m); err != nil || len(m) == 0 { + return "" + } + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + sort.Strings(keys) + var b strings.Builder + for _, k := range keys { + b2, err := yaml.Marshal(m[k]) + if err != nil { + continue + } + writeKV(&b, k, strings.TrimSpace(string(b2))) + } + return wrapFrontmatter(b.String(), len(keys)) +} + +func yamlScalar(n *yaml.Node) string { + if n.Kind == yaml.ScalarNode { + return n.Value + } + out, err := yaml.Marshal(n) + if err != nil { + return "" + } + return strings.TrimSpace(string(out)) +} + +func writeKV(b *strings.Builder, key, value string) { + b.WriteString("
") + b.WriteString(html.EscapeString(key)) + b.WriteString("
") + b.WriteString(html.EscapeString(value)) + b.WriteString("
") +} + +func wrapFrontmatter(kvBody string, pairs int) string { + if pairs == 0 { + return "" + } + noun := "keys" + if pairs == 1 { + noun = "key" + } + return fmt.Sprintf(`
Front matter %d %s
%s
`, + pairs, noun, kvBody) } diff --git a/internal/docs/render_frontmatter_test.go b/internal/docs/render_frontmatter_test.go new file mode 100644 index 00000000..7819dced --- /dev/null +++ b/internal/docs/render_frontmatter_test.go @@ -0,0 +1,101 @@ +package docs + +// Frontmatter rendering contract: +// [x] Happy: YAML frontmatter becomes a collapsed disclosure block with a .kv grid +// [x] Happy: document order of keys is preserved +// [x] Sad: no frontmatter -> no disclosure block, body untouched +// [x] Sad: frontmatter values are HTML-escaped (no injection through metadata) +// [x] Sad: a mid-document thematic break still renders as
+ +import ( + "strings" + "testing" +) + +const fmDoc = `--- +status: "in-review" +updated: "2026-08-31" +branch: "plan/docs-fix-up" +--- + +# Title + +Body paragraph. +` + +func TestRender_FrontmatterBecomesDisclosure(t *testing.T) { + got, err := Render([]byte(fmDoc)) + if err != nil { + t.Fatalf("Render: %v", err) + } + for _, want := range []string{ + `
`, + `
`, + `
status
`, + `
in-review
`, + `
branch
`, + `
plan/docs-fix-up
`, + } { + if !strings.Contains(got, want) { + t.Errorf("rendered output missing %q:\n%s", want, got) + } + } + // The pre-fix failure mode: goldmark reads the opening --- as a thematic + // break and folds the YAML into a mangled heading. + if strings.Contains(got, "status: "") || strings.Contains(got, "

Title

") { + t.Errorf("body heading missing:\n%s", got) + } +} + +func TestRender_FrontmatterKeyOrderPreserved(t *testing.T) { + got, err := Render([]byte(fmDoc)) + if err != nil { + t.Fatalf("Render: %v", err) + } + iStatus := strings.Index(got, "
status
") + iUpdated := strings.Index(got, "
updated
") + iBranch := strings.Index(got, "
branch
") + if iStatus < 0 || iUpdated < 0 || iBranch < 0 { + t.Fatalf("expected all three keys present:\n%s", got) + } + if !(iStatus < iUpdated && iUpdated < iBranch) { + t.Errorf("keys out of document order (status=%d updated=%d branch=%d)", iStatus, iUpdated, iBranch) + } +} + +func TestRender_NoFrontmatterNoDisclosure(t *testing.T) { + got, err := Render([]byte("# Plain\n\nNo metadata here.\n")) + if err != nil { + t.Fatalf("Render: %v", err) + } + if strings.Contains(got, `class="frontmatter"`) { + t.Errorf("disclosure block on a frontmatter-less doc:\n%s", got) + } +} + +func TestRender_FrontmatterValuesEscaped(t *testing.T) { + doc := "---\ntitle: \"\"\n---\n\n# Body\n" + got, err := Render([]byte(doc)) + if err != nil { + t.Fatalf("Render: %v", err) + } + if strings.Contains(got, "") { + t.Fatalf("unescaped frontmatter value reached the output:\n%s", got) + } + if !strings.Contains(got, "<script>alert(1)</script>") { + t.Errorf("escaped frontmatter value missing:\n%s", got) + } +} + +func TestRender_MidDocumentThematicBreakSurvives(t *testing.T) { + got, err := Render([]byte("# A\n\nabove\n\n---\n\nbelow\n")) + if err != nil { + t.Fatalf("Render: %v", err) + } + if !strings.Contains(got, " Date: Mon, 31 Aug 2026 19:36:18 -0500 Subject: [PATCH 2/5] fix(docs): render each root as a collapsible directory tree in the sidenav 887 docs rendered as one flat scroll. Per-root groups now emit nested tree--static markup (details per directory, leaf counts, path to the current doc pre-expanded); the filter box hides empty branches and auto-expands matches, restoring rest state on clear. Recent stays flat. Session-Name: deft-sonata Session-Id: 7fc5913c-2346-479f-a249-9d871812e47d Model: claude-fable-5 Harness: claude-code 2.1.252 Machine: cf6e768835c7 Co-Authored-By: Claude Fable 5 --- internal/docs/assets/sidenav-filter.js | 52 ++++++++-- internal/docs/server.go | 50 +++++++++- internal/docs/server_tree_test.go | 124 ++++++++++++++++++++++++ internal/docs/templates/shell.html.tmpl | 5 + 4 files changed, 222 insertions(+), 9 deletions(-) create mode 100644 internal/docs/server_tree_test.go diff --git a/internal/docs/assets/sidenav-filter.js b/internal/docs/assets/sidenav-filter.js index 8009c329..a6d3a2ea 100644 --- a/internal/docs/assets/sidenav-filter.js +++ b/internal/docs/assets/sidenav-filter.js @@ -1,7 +1,11 @@ // Sidenav filter box for `forgectl docs serve`. // -// Hides sidenav links whose data-filter-text does not contain the query, then -// hides any group heading left with no visible links under it. +// The sidenav holds two shapes: the flat Recent link list, and one +// .tree--static per root (nested details/summary). Filtering hides +// non-matching leaves in both, hides any directory left with no visible +// descendant, auto-expands directories that contain a match while a query is +// live, and restores each directory's original open state when the box +// clears. Group headings hide when their whole section is filtered out. // // This is a file rather than an inline From a1d9ac5d0f70514e984bc106d7179aea5774a596 Mon Sep 17 00:00:00 2001 From: Cameron Sjo <4084915+cameronsjo@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:54:56 -0500 Subject: [PATCH 5/5] =?UTF-8?q?docs(plans):=20handoff=20stub=20=E2=80=94?= =?UTF-8?q?=20docs-reader-design-surface?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Session-Name: deft-sonata Session-Id: 7fc5913c-2346-479f-a249-9d871812e47d Model: claude-fable-5 Harness: claude-code 2.1.252 Machine: cf6e768835c7 Co-Authored-By: Claude Fable 5 --- .../2026-08-31-docs-reader-design-surface.md | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 docs/plans/2026-08-31-docs-reader-design-surface.md diff --git a/docs/plans/2026-08-31-docs-reader-design-surface.md b/docs/plans/2026-08-31-docs-reader-design-surface.md new file mode 100644 index 00000000..6cc39bd6 --- /dev/null +++ b/docs/plans/2026-08-31-docs-reader-design-surface.md @@ -0,0 +1,56 @@ +--- +status: in-flight +branch: fix/docs-frontmatter-and-tree +next: design-iterate the docs reader (tree density, frontmatter treatment, code-block fit) — read this stub, open `forgectl docs serve`, compare against the Artificer live-spec +--- + +# The docs reader as a design surface + +## Continuation + +Design-iterate the docs reader: tree density, frontmatter treatment, code-block fit. Open `forgectl docs serve` on real docs and judge against the Artificer live-spec. + +Branch: fix/docs-frontmatter-and-tree +PR: #430 (this branch); #429 (Artificer 0.19.0 -> 0.25.0 re-vendor + docs slate, ready) + +### What the surface is + +`forgectl docs serve` is a pure-Go, server-rendered markdown reader on loopback HTTP. The shell is an Artificer surface-tool layout: appbar + split-pane + sidenav on the left, `surface-document` main pane. Assets are vendored Artificer (0.25.0 once #429 merges), embedded via `internal/docs/assets.go` — only `artificer.css`, `artificer-theme.js`, and `artificer-tree.js` from the vendored set are served. Two hard constraints shape every design change: + +- CSP sends `script-src 'self'` — no inline scripts ever; any behavior must be a served asset (see `assets/sidenav-filter.js` for the precedent). +- `style-src` allows inline styles, so template-level `