From 364ba187a2e422b8405d6fd1681fccd5d6154e01 Mon Sep 17 00:00:00 2001 From: Snow Lee Date: Wed, 15 Jul 2026 16:17:08 -0700 Subject: [PATCH] feat(web): render markdown frontmatter as a key/value table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A leading YAML frontmatter block used to render as goldmark's thematic-break soup (hr + stray text). It now renders as a compact table: keys in the author's order (yaml.Node, not a map), flat lists comma-joined, nested values as compact YAML in , everything HTML-escaped. Anything that isn't a well-formed YAML mapping — mid-doc fences, unclosed fences, list-shaped or invalid YAML — falls through and renders exactly as before; empty frontmatter is simply hidden. Applies everywhere the server renders markdown: the hub viewer and public share pages, each with theme-matched styling. Matters most for OKF/gbrain-style frontmattered knowledge bases. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt --- internal/webapp/frontend/src/style.css | 6 ++ internal/webapp/markdown.go | 79 ++++++++++++++++- internal/webapp/markdown_test.go | 87 +++++++++++++++++++ internal/webapp/shares.go | 5 ++ ...{index-D_DgiVAj.css => index-Cq1fMR2i.css} | 2 +- .../{index-ISEZlu5u.js => index-DKQXtbc9.js} | 0 internal/webapp/static/index.html | 4 +- 7 files changed, 177 insertions(+), 6 deletions(-) create mode 100644 internal/webapp/markdown_test.go rename internal/webapp/static/assets/{index-D_DgiVAj.css => index-Cq1fMR2i.css} (95%) rename internal/webapp/static/assets/{index-ISEZlu5u.js => index-DKQXtbc9.js} (100%) diff --git a/internal/webapp/frontend/src/style.css b/internal/webapp/frontend/src/style.css index eedabe0..f11642b 100644 --- a/internal/webapp/frontend/src/style.css +++ b/internal/webapp/frontend/src/style.css @@ -509,6 +509,12 @@ button, input, a.btn { font-family: inherit; } .markdown tr:hover td { background: rgba(255,255,255,.02); } .markdown img { max-width: 100%; border-radius: 8px; border: 1px solid var(--border); } .markdown hr { border: none; border-top: 1px solid var(--border); margin: 2.2em 0; } +/* Frontmatter key/value table (server-rendered from a doc's YAML header). */ +.markdown table.frontmatter { margin: 0 0 1.8em; font-size: 12px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px; border-collapse: separate; border-spacing: 0; } +.markdown table.frontmatter th { text-transform: none; letter-spacing: 0; font-size: 11.5px; color: var(--text-faint); font-weight: 600; text-align: left; white-space: nowrap; vertical-align: top; padding: 6px 14px 6px 12px; border-bottom: 1px solid var(--border); } +.markdown table.frontmatter td { color: var(--text-dim); padding: 6px 12px 6px 0; border-bottom: 1px solid var(--border); } +.markdown table.frontmatter tr:last-child th, .markdown table.frontmatter tr:last-child td { border-bottom: none; } +.markdown table.frontmatter code { white-space: pre-wrap; font-size: 11px; } .markdown input[type="checkbox"] { accent-color: var(--accent); } /* plain file / binary views */ diff --git a/internal/webapp/markdown.go b/internal/webapp/markdown.go index 2ac6aac..432b5a2 100644 --- a/internal/webapp/markdown.go +++ b/internal/webapp/markdown.go @@ -2,12 +2,16 @@ package webapp import ( "bytes" + "fmt" + "html" "net/url" "regexp" + "strings" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/parser" + "gopkg.in/yaml.v3" ) var md = goldmark.New( @@ -32,11 +36,80 @@ func expandWikilinks(src []byte) []byte { } // RenderMarkdown converts markdown to HTML (GFM + wikilinks). Raw HTML in -// the source is escaped by goldmark's safe default. +// the source is escaped by goldmark's safe default. A leading YAML +// frontmatter block renders as a small key/value table instead of the +// broken thematic-break soup goldmark would make of it. func RenderMarkdown(src []byte) (string, error) { + table, body := frontmatterTable(src) var buf bytes.Buffer - if err := md.Convert(expandWikilinks(src), &buf); err != nil { + if err := md.Convert(expandWikilinks(body), &buf); err != nil { return "", err } - return buf.String(), nil + return table + buf.String(), nil +} + +// fmCloseRe matches a frontmatter closing fence on its own line. +var fmCloseRe = regexp.MustCompile(`(?m)^(---|\.\.\.)\s*$`) + +// frontmatterTable splits a leading YAML frontmatter block off src and +// renders it as an HTML table (keys in author order, values escaped). +// Anything that isn't a well-formed YAML mapping falls through untouched — +// a stray --- line must keep rendering exactly as it always did. +func frontmatterTable(src []byte) (string, []byte) { + rest, ok := bytes.CutPrefix(src, []byte("---\n")) + if !ok { + if rest, ok = bytes.CutPrefix(src, []byte("---\r\n")); !ok { + return "", src + } + } + loc := fmCloseRe.FindIndex(rest) + if loc == nil { + return "", src + } + fm, body := rest[:loc[0]], rest[loc[1]:] + var doc yaml.Node + if yaml.Unmarshal(fm, &doc) != nil || len(doc.Content) != 1 || doc.Content[0].Kind != yaml.MappingNode { + return "", src + } + m := doc.Content[0] + if len(m.Content) == 0 { + return "", body // empty frontmatter: hide it, nothing to tabulate + } + var b strings.Builder + b.WriteString(``) + for i := 0; i+1 < len(m.Content); i += 2 { + key, val := m.Content[i], m.Content[i+1] + fmt.Fprintf(&b, ``, + html.EscapeString(key.Value), yamlValueHTML(val)) + } + b.WriteString(`
%s%s
`) + return b.String(), body +} + +// yamlValueHTML renders one frontmatter value: scalars as text, flat lists +// comma-joined, anything nested as compact YAML in a block. Always +// escaped — frontmatter is user input, never markup. +func yamlValueHTML(n *yaml.Node) string { + switch n.Kind { + case yaml.ScalarNode: + return html.EscapeString(n.Value) + case yaml.SequenceNode: + flat := true + parts := make([]string, 0, len(n.Content)) + for _, c := range n.Content { + if c.Kind != yaml.ScalarNode { + flat = false + break + } + parts = append(parts, c.Value) + } + if flat { + return html.EscapeString(strings.Join(parts, ", ")) + } + } + raw, err := yaml.Marshal(n) + if err != nil { + return "" + } + return "" + html.EscapeString(strings.TrimSpace(string(raw))) + "" } diff --git a/internal/webapp/markdown_test.go b/internal/webapp/markdown_test.go new file mode 100644 index 0000000..5598c62 --- /dev/null +++ b/internal/webapp/markdown_test.go @@ -0,0 +1,87 @@ +package webapp + +import ( + "strings" + "testing" +) + +// A leading YAML frontmatter block renders as a key/value table (author +// key order, escaped values) instead of goldmark's thematic-break soup; +// everything that isn't a clean frontmatter mapping renders exactly as +// before. +func TestRenderMarkdownFrontmatter(t *testing.T) { + src := `--- +title: Q3 findings +tags: [churn, revenue] +owner: snow@runbear.io +meta: + reviewed: true +--- + +# Body + +Hello.` + out, err := RenderMarkdown([]byte(src)) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{ + ``, + ``, + ``, // flat lists comma-join + `owner`, `snow@runbear.io`, + `reviewed: true`, // nested values as compact YAML + `

Body

`, // the body still renders + } { + if !strings.Contains(out, want) { + t.Errorf("missing %q in:\n%s", want, out) + } + } + if strings.Contains(out, "title<") > strings.Index(out, ">owner<") { + t.Errorf("frontmatter keys reordered:\n%s", out) + } +} + +func TestRenderMarkdownFrontmatterEscapes(t *testing.T) { + out, err := RenderMarkdown([]byte("---\nnote: \n---\nx")) + if err != nil { + t.Fatal(err) + } + if strings.Contains(out, " - + +
titleQ3 findingschurn, revenue