mirror of
https://github.com/runbear-io/beardrive.git
synced 2026-08-25 08:08:08 +02:00
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:
co-authored by
Claude Fable 5
parent
22c3ebbf0e
commit
364ba187a2
@@ -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 */
|
||||
|
||||
@@ -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>"
|
||||
}
|
||||
|
||||
@@ -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, "<script>") {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -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'>🐻</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">
|
||||
|
||||
Reference in New Issue
Block a user