Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ 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 (
github.com/davecgh/go-spew v1.1.1 // indirect
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 (
Expand Down
11 changes: 10 additions & 1 deletion internal/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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,
Expand Down Expand Up @@ -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
}
67 changes: 67 additions & 0 deletions internal/parser_test.go
Original file line number Diff line number Diff line change
@@ -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, "<th>title</th>") {
t.Errorf("expected title key in table, got:\n%s", s)
}
if !strings.Contains(s, "<td>Hello World</td>") {
t.Errorf("expected title value in table, got:\n%s", s)
}
if !strings.Contains(s, "<h1") {
t.Errorf("expected body heading rendered, got:\n%s", s)
}
// Table must appear before body content
tablePos := strings.Index(s, `class="frontmatter-table"`)
bodyPos := strings.Index(s, "<h1")
if tablePos > 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, "<h1") {
t.Errorf("heading should be rendered normally")
}
}

func TestMdToHTML_FrontmatterBodyNotInTable(t *testing.T) {
p := NewParser()
input := []byte("---\ntitle: Test\n---\n\nBody paragraph here.\n")
out, err := p.MdToHTML(input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
s := string(out)
// Frontmatter keys must not bleed into the body
if strings.Contains(s, "title: Test") && !strings.Contains(s, "<th>title</th>") {
t.Errorf("frontmatter content leaked into body as text")
}
if !strings.Contains(s, "Body paragraph here.") {
t.Errorf("body paragraph should be rendered")
}
}
141 changes: 141 additions & 0 deletions pkg/frontmatter/frontmatter.go
Original file line number Diff line number Diff line change
@@ -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(`<table class="frontmatter-table">` + "\n")
buf.WriteString("<tbody>\n")
for _, row := range rows {
buf.WriteString("<tr>")
buf.WriteString("<th>")
buf.WriteString(html.EscapeString(row.key))
buf.WriteString("</th>")
buf.WriteString("<td>")
buf.WriteString(html.EscapeString(row.val))
buf.WriteString("</td>")
buf.WriteString("</tr>\n")
}
buf.WriteString("</tbody>\n")
buf.WriteString("</table>\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
}
}
Loading
Loading