feat(web): render markdown frontmatter as a key/value table

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 <code>, 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P5cxPQdSGJnjXCYY9GeWXt
This commit is contained in:
Snow Lee
2026-07-15 16:17:08 -07:00
co-authored by Claude Fable 5
parent 22c3ebbf0e
commit 364ba187a2
7 changed files with 177 additions and 6 deletions
+6
View File
@@ -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 */
+76 -3
View File
@@ -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(`<table class="frontmatter"><tbody>`)
for i := 0; i+1 < len(m.Content); i += 2 {
key, val := m.Content[i], m.Content[i+1]
fmt.Fprintf(&b, `<tr><th scope="row">%s</th><td>%s</td></tr>`,
html.EscapeString(key.Value), yamlValueHTML(val))
}
b.WriteString(`</tbody></table>`)
return b.String(), body
}
// yamlValueHTML renders one frontmatter value: scalars as text, flat lists
// comma-joined, anything nested as compact YAML in a <code> 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 "<code>" + html.EscapeString(strings.TrimSpace(string(raw))) + "</code>"
}
+87
View File
@@ -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{
`<table class="frontmatter">`,
`<th scope="row">title</th><td>Q3 findings</td>`,
`<td>churn, revenue</td>`, // flat lists comma-join
`owner`, `snow@runbear.io`,
`<code>reviewed: true</code>`, // nested values as compact YAML
`<h1 id="body">Body</h1>`, // the body still renders
} {
if !strings.Contains(out, want) {
t.Errorf("missing %q in:\n%s", want, out)
}
}
if strings.Contains(out, "<hr") {
t.Errorf("frontmatter fences leaked as thematic breaks:\n%s", out)
}
// Key order preserved: title row precedes owner row.
if strings.Index(out, ">title<") > strings.Index(out, ">owner<") {
t.Errorf("frontmatter keys reordered:\n%s", out)
}
}
func TestRenderMarkdownFrontmatterEscapes(t *testing.T) {
out, err := RenderMarkdown([]byte("---\nnote: <script>alert(1)</script>\n---\nx"))
if err != nil {
t.Fatal(err)
}
if strings.Contains(out, "<script>") {
t.Fatalf("frontmatter value not escaped:\n%s", out)
}
if !strings.Contains(out, "&lt;script&gt;") {
t.Fatalf("escaped value missing:\n%s", out)
}
}
func TestRenderMarkdownFrontmatterFallthrough(t *testing.T) {
cases := map[string]struct {
src string
wantTable bool
want string
}{
"no frontmatter": {"# Hi\n\ntext", false, "<h1"},
"mid-doc fences": {"para\n\n---\n\nmore", false, "<hr"},
"unclosed fence": {"---\ntitle: x\n\nbody", false, ""},
"non-mapping yaml": {"---\n- just\n- a list\n---\nbody", false, ""},
"invalid yaml": {"---\n: : :\n---\nbody", false, ""},
"empty frontmatter hidden": {"---\n---\nbody", false, "<p>body</p>"},
}
for name, c := range cases {
out, err := RenderMarkdown([]byte(c.src))
if err != nil {
t.Fatalf("%s: %v", name, err)
}
if got := strings.Contains(out, `class="frontmatter"`); got != c.wantTable {
t.Errorf("%s: table presence = %v, want %v\n%s", name, got, c.wantTable, out)
}
if c.want != "" && !strings.Contains(out, c.want) {
t.Errorf("%s: missing %q in:\n%s", name, c.want, out)
}
}
}
+5
View File
@@ -308,6 +308,11 @@ pre code{padding:0;background:none}
img{max-width:100%%}
blockquote{margin:0;padding-left:16px;border-left:3px solid #d0d7de;color:#57606a}
table{border-collapse:collapse;display:block;overflow-x:auto;max-width:100%%}td,th{border:1px solid #d0d7de;padding:5px 10px}
table.frontmatter{display:table;font-size:12px;color:#57606a;background:#f6f8fa;border-radius:8px;margin-bottom:24px}
table.frontmatter th,table.frontmatter td{border:none;border-bottom:1px solid #d8dee4;text-align:left;vertical-align:top}
table.frontmatter th{white-space:nowrap;color:#6e7781}
table.frontmatter tr:last-child th,table.frontmatter tr:last-child td{border-bottom:none}
table.frontmatter code{white-space:pre-wrap}
pre{max-width:100%%}
footer.bdrive{margin-top:64px;padding-top:14px;border-top:1px solid #d0d7de;font-size:12.5px;color:#57606a}
footer.bdrive a{color:inherit}
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>BearDrive</title>
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128059;</text></svg>">
<script type="module" crossorigin src="/assets/index-ISEZlu5u.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D_DgiVAj.css">
<script type="module" crossorigin src="/assets/index-DKQXtbc9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cq1fMR2i.css">
</head>
<body>
<svg width="0" height="0" class="sprite" aria-hidden="true" focusable="false">