From 90652851a388c1243d639bede08f5e9a63fe6b34 Mon Sep 17 00:00:00 2001 From: Dolan Antenucci Date: Mon, 27 Jul 2026 08:25:19 -0600 Subject: [PATCH 1/2] feat: render YAML frontmatter as HTML table in markdown preview Add pkg/frontmatter with Extract and RenderTable functions. Extract detects a leading --- delimited YAML block at byte offset 0, validates the YAML, and returns the frontmatter bytes and remaining body. RenderTable converts the parsed YAML into a bordered HTML table with document-order keys and HTML-escaped values. Wire the pre-processing step into internal/parser.MdToHTML so any markdown file with valid frontmatter renders a table above the body instead of the garbled H2 heading Goldmark produced before. Closes #75 --- go.mod | 2 +- go.sum | 14 ++ internal/parser.go | 11 +- internal/parser_test.go | 67 ++++++++ pkg/frontmatter/frontmatter.go | 141 ++++++++++++++++ pkg/frontmatter/frontmatter_test.go | 238 ++++++++++++++++++++++++++++ 6 files changed, 471 insertions(+), 2 deletions(-) create mode 100644 internal/parser_test.go create mode 100644 pkg/frontmatter/frontmatter.go create mode 100644 pkg/frontmatter/frontmatter_test.go diff --git a/go.mod b/go.mod index 75615a1..ac6fd34 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/alecthomas/chroma/v2 v2.14.0 github.com/spf13/cobra v1.8.1 github.com/stretchr/testify v1.11.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -14,7 +15,6 @@ require ( github.com/forPelevin/gomoji v1.3.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( diff --git a/go.sum b/go.sum index 670efc8..35518ea 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,12 @@ github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/bep/debounce v1.2.1 h1:v67fRdBA9UQu2NhLFXrSg0Brw7CexQekrBwDMM8bzeY= github.com/bep/debounce v1.2.1/go.mod h1:H8yggRPQKLUhUoqrJC1bO2xNya7vanpDl7xR3ISbCJ0= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= +github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k= +github.com/chromedp/chromedp v0.14.0 h1:/xE5m6wEBwivhalHwlCOyYfBcAJNwg4nLw96QiCfYr0= +github.com/chromedp/chromedp v0.14.0/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo= +github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= +github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -17,6 +23,14 @@ github.com/forPelevin/gomoji v1.3.0 h1:WPIOLWB1bvRYlKZnSSEevLt3IfKlLs+tK+YA9fFYl github.com/forPelevin/gomoji v1.3.0/go.mod h1:mM6GtmCgpoQP2usDArc6GjbXrti5+FffolyQfGgPboQ= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= +github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= +github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= +github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= +github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= +github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= +github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= +github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= diff --git a/internal/parser.go b/internal/parser.go index deaeb2c..c45d133 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -6,6 +6,7 @@ import ( "github.com/chrishrb/go-grip/pkg/alert" "github.com/chrishrb/go-grip/pkg/details" "github.com/chrishrb/go-grip/pkg/footnote" + "github.com/chrishrb/go-grip/pkg/frontmatter" "github.com/chrishrb/go-grip/pkg/ghissue" "github.com/chrishrb/go-grip/pkg/highlighting" "github.com/chrishrb/go-grip/pkg/mathjax" @@ -26,6 +27,14 @@ func NewParser() *Parser { } func (m Parser) MdToHTML(input []byte) ([]byte, error) { + var prefix []byte + if fm, body, ok := frontmatter.Extract(input); ok { + if table, err := frontmatter.RenderTable(fm); err == nil { + prefix = table + input = body + } + } + md := goldmark.New( goldmark.WithExtensions( extension.Linkify, @@ -53,5 +62,5 @@ func (m Parser) MdToHTML(input []byte) ([]byte, error) { if err := md.Convert(input, &buf); err != nil { return nil, err } - return buf.Bytes(), nil + return append(prefix, buf.Bytes()...), nil } diff --git a/internal/parser_test.go b/internal/parser_test.go new file mode 100644 index 0000000..a1b42a1 --- /dev/null +++ b/internal/parser_test.go @@ -0,0 +1,67 @@ +package internal + +import ( + "strings" + "testing" +) + +func TestMdToHTML_FrontmatterRenderedAsTable(t *testing.T) { + p := NewParser() + input := []byte("---\ntitle: Hello World\nauthor: Alice\n---\n\n# Body Heading\n") + out, err := p.MdToHTML(input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if !strings.Contains(s, `class="frontmatter-table"`) { + t.Errorf("expected frontmatter-table in output, got:\n%s", s) + } + if !strings.Contains(s, "title") { + t.Errorf("expected title key in table, got:\n%s", s) + } + if !strings.Contains(s, "Hello World") { + t.Errorf("expected title value in table, got:\n%s", s) + } + if !strings.Contains(s, " bodyPos { + t.Errorf("frontmatter table must appear before body content") + } +} + +func TestMdToHTML_NoFrontmatter_Unchanged(t *testing.T) { + p := NewParser() + plain := []byte("# Just a heading\n\nSome paragraph.\n") + out, err := p.MdToHTML(plain) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if strings.Contains(s, "frontmatter-table") { + t.Errorf("should not inject table when no frontmatter present") + } + if !strings.Contains(s, "title") { + t.Errorf("frontmatter content leaked into body as text") + } + if !strings.Contains(s, "Body paragraph here.") { + t.Errorf("body paragraph should be rendered") + } +} diff --git a/pkg/frontmatter/frontmatter.go b/pkg/frontmatter/frontmatter.go new file mode 100644 index 0000000..a19f6b3 --- /dev/null +++ b/pkg/frontmatter/frontmatter.go @@ -0,0 +1,141 @@ +// Package frontmatter detects and extracts YAML frontmatter from markdown +// sources and renders it as an HTML table. +package frontmatter + +import ( + "bytes" + "fmt" + "html" + "strings" + + "gopkg.in/yaml.v3" +) + +// Extract splits src into (frontmatterBytes, bodyBytes, found). +// +// found is true only when: +// - src starts with exactly "---\n" or "---\r\n" at byte offset 0 +// - a closing "---" appears on its own line before EOF +// - the bytes between the delimiters parse as valid YAML +// +// When found is false, bodyBytes == src (full content unchanged, no copy). +// When found is true, bodyBytes is the content after the closing delimiter line. +func Extract(src []byte) (fm []byte, body []byte, found bool) { + // Must start with --- at byte offset 0 + if !bytes.HasPrefix(src, []byte("---\n")) && !bytes.HasPrefix(src, []byte("---\r\n")) { + return nil, src, false + } + + // Find the newline that ends the opening delimiter + openEnd := bytes.IndexByte(src, '\n') + if openEnd < 0 { + return nil, src, false + } + openEnd++ // advance past the \n + + // Scan lines after the opener for a closing --- or ... + rest := src[openEnd:] + closeStart := -1 + closeEnd := -1 + + for i := 0; i < len(rest); { + // Find end of current line + nl := bytes.IndexByte(rest[i:], '\n') + var lineEnd int + if nl < 0 { + lineEnd = len(rest) + } else { + lineEnd = i + nl + 1 + } + line := rest[i:lineEnd] + stripped := bytes.TrimRight(line, "\r\n") + if bytes.Equal(stripped, []byte("---")) { + closeStart = i + closeEnd = lineEnd + break + } + if nl < 0 { + break + } + i = lineEnd + } + + if closeStart < 0 { + return nil, src, false + } + + fmBytes := rest[:closeStart] + + // Validate that fmBytes is parseable YAML + var v interface{} + if err := yaml.Unmarshal(fmBytes, &v); err != nil { + return nil, src, false + } + + return fmBytes, rest[closeEnd:], true +} + +// RenderTable renders parsed YAML frontmatter bytes as an HTML table. +// Top-level keys appear in document order. Nested values are rendered as their +// string representation. All values are HTML-escaped. +func RenderTable(fm []byte) ([]byte, error) { + var doc yaml.Node + if err := yaml.Unmarshal(fm, &doc); err != nil { + return nil, err + } + + var rows []struct{ key, val string } + + if doc.Kind == yaml.DocumentNode && len(doc.Content) > 0 { + mapping := doc.Content[0] + if mapping.Kind == yaml.MappingNode { + // MappingNode Content is [key, value, key, value, ...] + for i := 0; i+1 < len(mapping.Content); i += 2 { + k := mapping.Content[i].Value + v := nodeToString(mapping.Content[i+1]) + rows = append(rows, struct{ key, val string }{k, v}) + } + } + } + + var buf strings.Builder + buf.WriteString(`` + "\n") + buf.WriteString("\n") + for _, row := range rows { + buf.WriteString("") + buf.WriteString("") + buf.WriteString("") + buf.WriteString("\n") + } + buf.WriteString("\n") + buf.WriteString("
") + buf.WriteString(html.EscapeString(row.key)) + buf.WriteString("") + buf.WriteString(html.EscapeString(row.val)) + buf.WriteString("
\n") + + return []byte(buf.String()), nil +} + +// nodeToString converts a yaml.Node value to a human-readable string. +// Scalars return their value directly; sequences and mappings use fmt.Sprintf. +func nodeToString(n *yaml.Node) string { + switch n.Kind { + case yaml.ScalarNode: + return n.Value + case yaml.SequenceNode: + parts := make([]string, len(n.Content)) + for i, child := range n.Content { + parts[i] = nodeToString(child) + } + return fmt.Sprintf("[%s]", strings.Join(parts, ", ")) + case yaml.MappingNode: + parts := make([]string, 0, len(n.Content)/2) + for i := 0; i+1 < len(n.Content); i += 2 { + parts = append(parts, nodeToString(n.Content[i])+": "+nodeToString(n.Content[i+1])) + } + return fmt.Sprintf("{%s}", strings.Join(parts, ", ")) + default: + return n.Value + } +} diff --git a/pkg/frontmatter/frontmatter_test.go b/pkg/frontmatter/frontmatter_test.go new file mode 100644 index 0000000..eca3c2e --- /dev/null +++ b/pkg/frontmatter/frontmatter_test.go @@ -0,0 +1,238 @@ +package frontmatter_test + +import ( + "strings" + "testing" + + "github.com/chrishrb/go-grip/pkg/frontmatter" +) + +// ---------- Extract tests ---------- + +func TestExtract_HappyPath(t *testing.T) { + src := []byte("---\ntitle: Hello\ndate: 2024-01-01\n---\n# Body\n") + fm, body, ok := frontmatter.Extract(src) + if !ok { + t.Fatal("expected frontmatter to be found") + } + if !strings.Contains(string(fm), "title: Hello") { + t.Errorf("frontmatter missing title, got: %q", fm) + } + if !strings.HasPrefix(string(body), "# Body") { + t.Errorf("body should start with '# Body', got: %q", body) + } +} + +func TestExtract_NoOpeningDash_BlankFirstLine(t *testing.T) { + src := []byte("\n---\ntitle: Hello\n---\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when first line is blank") + } + if string(body) != string(src) { + t.Error("body should equal original src on no-match") + } +} + +func TestExtract_NoOpeningDash_HeadingFirst(t *testing.T) { + src := []byte("# Title\n---\nfoo: bar\n---\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when first line is a heading") + } + if string(body) != string(src) { + t.Error("body should equal original src on no-match") + } +} + +func TestExtract_NoClosingDelimiter(t *testing.T) { + src := []byte("---\ntitle: Hello\nno closer here\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when no closing delimiter") + } + if string(body) != string(src) { + t.Error("body should equal original src on no-match") + } +} + +func TestExtract_InvalidYAML(t *testing.T) { + src := []byte("---\n: invalid: yaml: :\n---\n# Body\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when YAML is invalid") + } + if string(body) != string(src) { + t.Error("body should equal original src on invalid YAML") + } +} + +func TestExtract_BOMPrefixed(t *testing.T) { + // BOM before --- means not at byte offset 0 + src := []byte("\xEF\xBB\xBF---\ntitle: Hello\n---\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when --- is preceded by BOM") + } + if string(body) != string(src) { + t.Error("body should equal original src on no-match") + } +} + +func TestExtract_EmptyFrontmatter(t *testing.T) { + src := []byte("---\n---\n# Body\n") + _, _, ok := frontmatter.Extract(src) + // Empty YAML is valid (unmarshals to nil map) -- should still extract + if !ok { + t.Fatal("expected empty frontmatter block to be found") + } +} + +func TestExtract_CRLFLineEndings(t *testing.T) { + src := []byte("---\r\ntitle: Hello\r\n---\r\n# Body\r\n") + fm, body, ok := frontmatter.Extract(src) + if !ok { + t.Fatal("expected frontmatter to be found with CRLF line endings") + } + if !strings.Contains(string(fm), "title: Hello") { + t.Errorf("frontmatter missing title, got: %q", fm) + } + if !strings.Contains(string(body), "# Body") { + t.Errorf("body missing content, got: %q", body) + } +} + +func TestExtract_MidDocumentDash(t *testing.T) { + // --- appearing only in the middle of the document + src := []byte("# Title\nsome text\n---\nmore text\n") + _, body, ok := frontmatter.Extract(src) + if ok { + t.Fatal("should not extract frontmatter when --- is mid-document") + } + if string(body) != string(src) { + t.Error("body should equal original src") + } +} + +func TestExtract_BodyPreservedAfterFrontmatter(t *testing.T) { + body_content := "# My Doc\n\nSome paragraph.\n" + src := []byte("---\ntitle: Test\n---\n" + body_content) + _, body, ok := frontmatter.Extract(src) + if !ok { + t.Fatal("expected frontmatter found") + } + if string(body) != body_content { + t.Errorf("body mismatch\nwant: %q\ngot: %q", body_content, body) + } +} + +// ---------- RenderTable tests ---------- + +func TestRenderTable_HappyPath(t *testing.T) { + fm := []byte("title: Hello\ndate: 2024-01-01\n") + out, err := frontmatter.RenderTable(fm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if !strings.Contains(s, `class="frontmatter-table"`) { + t.Error("missing frontmatter-table class") + } + if !strings.Contains(s, "title") { + t.Error("missing title key cell") + } + if !strings.Contains(s, "Hello") { + t.Error("missing title value cell") + } + if !strings.Contains(s, "date") { + t.Error("missing date key cell") + } +} + +func TestRenderTable_KeyOrder(t *testing.T) { + // Keys must appear in document order, not map iteration order + fm := []byte("z_last: 1\na_first: 2\nm_middle: 3\n") + out, err := frontmatter.RenderTable(fm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + zPos := strings.Index(s, "z_last") + aPos := strings.Index(s, "a_first") + mPos := strings.Index(s, "m_middle") + if zPos < 0 || aPos < 0 || mPos < 0 { + t.Fatalf("not all keys present in output: %s", s) + } + if zPos >= aPos || aPos >= mPos { + t.Errorf("keys not in document order: z@%d a@%d m@%d", zPos, aPos, mPos) + } +} + +func TestRenderTable_NestedMapValue(t *testing.T) { + fm := []byte("author:\n name: Alice\n email: alice@example.com\n") + out, err := frontmatter.RenderTable(fm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if !strings.Contains(s, "author") { + t.Error("missing author key") + } + // Value must be a string representation, not a sub-table + if strings.Contains(s, " 1 { + t.Error("nested value should not produce a sub-table") + } +} + +func TestRenderTable_SequenceValue(t *testing.T) { + fm := []byte("tags:\n - go\n - markdown\n") + out, err := frontmatter.RenderTable(fm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if !strings.Contains(s, "tags") { + t.Error("missing tags key") + } +} + +func TestRenderTable_HTMLEscaping(t *testing.T) { + fm := []byte("desc: \n") + out, err := frontmatter.RenderTable(fm) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + s := string(out) + if strings.Contains(s, "