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, `| %s | %s |
`,
+ html.EscapeString(key.Value), yamlValueHTML(val))
+ }
+ b.WriteString(`
`)
+ 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{
+ ``,
+ `| title | Q3 findings | `,
+ `churn, revenue | `, // 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, "
-
+
+