Skip to content

Commit 91cfd36

Browse files
Merge pull request #87 from cgreeno/feat/frontmatter
feat: strip YAML frontmatter and render its title above the diagram
2 parents 5671865 + 3022fcd commit 91cfd36

4 files changed

Lines changed: 180 additions & 0 deletions

File tree

cmd/frontmatter_test.go

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
package cmd
2+
3+
import (
4+
"strings"
5+
"testing"
6+
7+
"github.com/AlexanderGrooff/mermaid-ascii/pkg/diagram"
8+
)
9+
10+
// TestRenderWithFrontmatter checks frontmatter is stripped before type
11+
// detection for every diagram type, and the title is printed above the output.
12+
func TestRenderWithFrontmatter(t *testing.T) {
13+
cases := []struct {
14+
name string
15+
input string
16+
wantType string
17+
wantParts []string
18+
}{
19+
{
20+
"sequence with title",
21+
"---\ntitle: Login flow\n---\nsequenceDiagram\nAlice->>Bob: Hi",
22+
"sequence",
23+
[]string{"Login flow", "Alice", "Bob", "Hi"},
24+
},
25+
{
26+
"er with theme config",
27+
"---\nconfig:\n themeCSS: |\n rect { fill: red; }\n---\nerDiagram\n CUSTOMER ||--o{ ORDER : places",
28+
"er",
29+
[]string{"CUSTOMER", "ORDER", "places"},
30+
},
31+
{
32+
"graph with title",
33+
"---\ntitle: Deps\n---\ngraph LR\nA-->B",
34+
"graph",
35+
[]string{"Deps", "A", "B"},
36+
},
37+
}
38+
for _, c := range cases {
39+
t.Run(c.name, func(t *testing.T) {
40+
stripped, _ := diagram.StripFrontmatter(c.input)
41+
diag, err := DiagramFactory(stripped)
42+
if err != nil {
43+
t.Fatal(err)
44+
}
45+
if diag.Type() != c.wantType {
46+
t.Errorf("detected %q, want %q", diag.Type(), c.wantType)
47+
}
48+
out, err := RenderDiagram(c.input, diagram.DefaultConfig())
49+
if err != nil {
50+
t.Fatal(err)
51+
}
52+
for _, want := range c.wantParts {
53+
if !strings.Contains(out, want) {
54+
t.Errorf("output missing %q:\n%s", want, out)
55+
}
56+
}
57+
})
58+
}
59+
}
60+
61+
// TestRenderTitleAboveDiagram checks the title sits on the first line,
62+
// separated from the diagram by a blank line.
63+
func TestRenderTitleAboveDiagram(t *testing.T) {
64+
out, err := RenderDiagram("---\ntitle: My title\n---\ngraph LR\nA-->B", diagram.DefaultConfig())
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
lines := strings.Split(out, "\n")
69+
if lines[0] != "My title" || lines[1] != "" {
70+
t.Errorf("want title + blank line first, got %q, %q", lines[0], lines[1])
71+
}
72+
}

cmd/render.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,11 @@ func RenderDiagram(input string, config *diagram.Config) (string, error) {
1111
config = diagram.DefaultConfig()
1212
}
1313

14+
// YAML frontmatter carries a title and theme config; the config has no
15+
// ASCII meaning, but the title is printed above the diagram like mermaid
16+
// does. Stripped here once so type detection and parsing never see it.
17+
input, title := diagram.StripFrontmatter(input)
18+
1419
diag, err := DiagramFactory(input)
1520
if err != nil {
1621
return "", fmt.Errorf("failed to detect diagram type: %w", err)
@@ -25,5 +30,8 @@ func RenderDiagram(input string, config *diagram.Config) (string, error) {
2530
return "", fmt.Errorf("failed to render %s diagram: %w", diag.Type(), err)
2631
}
2732

33+
if title != "" {
34+
output = title + "\n\n" + output
35+
}
2836
return output, nil
2937
}

pkg/diagram/frontmatter.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package diagram
2+
3+
import "strings"
4+
5+
// StripFrontmatter removes a leading YAML frontmatter block (delimited by
6+
// `---` lines) from a mermaid document, returning the remaining input and the
7+
// frontmatter's title, if one was set.
8+
//
9+
// Mermaid uses frontmatter for a diagram title and theme/config overrides
10+
// (colors, CSS). The config has no meaning in ASCII output, so it is
11+
// discarded; the title is surfaced so callers can print it above the diagram,
12+
// as mermaid does.
13+
//
14+
// Matching mermaid's own frontmatter semantics (frontmatter.spec.ts):
15+
// frontmatter is only recognised at the start of the document, the closing
16+
// delimiter must sit at the same indentation as the opening one, and an
17+
// unclosed block is not frontmatter at all — the input is returned untouched
18+
// for the diagram parser to deal with.
19+
func StripFrontmatter(input string) (rest string, title string) {
20+
lines := strings.Split(input, "\n")
21+
22+
start := 0
23+
for start < len(lines) && strings.TrimSpace(lines[start]) == "" {
24+
start++
25+
}
26+
if start >= len(lines) || !isDelimiter(lines[start]) {
27+
return input, ""
28+
}
29+
indent := lines[start][:strings.Index(lines[start], "-")]
30+
31+
for i := start + 1; i < len(lines); i++ {
32+
if isDelimiter(lines[i]) && lines[i][:strings.Index(lines[i], "-")] == indent {
33+
return strings.Join(lines[i+1:], "\n"), title
34+
}
35+
// Only a top-level `title:` key counts — indented occurrences are
36+
// nested config values (e.g. inside themeCSS), not the title. YAML
37+
// requires whitespace after the colon for a mapping ("title:xyz" is a
38+
// plain scalar, not a key), and an unquoted value ends at a comment.
39+
trimmed := strings.TrimRight(lines[i], " \t\r")
40+
if v, ok := strings.CutPrefix(trimmed, indent+"title:"); ok && (v == "" || v[0] == ' ' || v[0] == '\t') {
41+
v = strings.TrimSpace(v)
42+
if !strings.HasPrefix(v, `"`) && !strings.HasPrefix(v, `'`) {
43+
if idx := strings.Index(v, " #"); idx != -1 {
44+
v = strings.TrimSpace(v[:idx])
45+
}
46+
}
47+
title = strings.Trim(v, `"'`)
48+
}
49+
}
50+
return input, ""
51+
}
52+
53+
// isDelimiter reports whether a line is a frontmatter delimiter: `---` with
54+
// optional surrounding whitespace.
55+
func isDelimiter(line string) bool {
56+
return strings.TrimSpace(line) == "---"
57+
}

pkg/diagram/frontmatter_test.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package diagram
2+
3+
import "testing"
4+
5+
// Cases mirror mermaid's own frontmatter.spec.ts semantics.
6+
func TestStripFrontmatter(t *testing.T) {
7+
cases := []struct {
8+
name string
9+
in string
10+
wantRest string
11+
wantTitle string
12+
}{
13+
{"no frontmatter", "graph TD\nA-->B", "graph TD\nA-->B", ""},
14+
{"title extracted", "---\ntitle: foo\n---\ndiagram", "diagram", "foo"},
15+
{"empty frontmatter", "---\n\n---\ndiagram", "diagram", ""},
16+
{"unclosed is not frontmatter", "---\ntitle: foo\ndiagram", "---\ntitle: foo\ndiagram", ""},
17+
{"only at document start", "diagram\n---\ntitle: foo\n---", "diagram\n---\ntitle: foo\n---", ""},
18+
{"delimiter inside a value line", "---\ntitle: foo---bar\n---\ndiagram", "diagram", "foo---bar"},
19+
{"unknown keys ignored", "---\nconfig:\n theme: dark\ninvalid: x\n---\ndiagram", "diagram", ""},
20+
{"quoted title", "---\ntitle: \"Customers service\"\n---\ndiagram", "diagram", "Customers service"},
21+
{"boolean-looking title stays text", "---\ntitle: true\n---\ndiagram", "diagram", "true"},
22+
{"leading blank lines", "\n\n---\ntitle: t\n---\ndiagram", "diagram", "t"},
23+
{"matching indented delimiters", " ---\n title: t\n ---\ndiagram", "diagram", "t"},
24+
{"mismatched closing indent not a close", "---\ntitle: t\n ---\ndiagram", "---\ntitle: t\n ---\ndiagram", ""},
25+
{"indented title is not the title", "---\nconfig:\n title: nested\n---\ndiagram", "diagram", ""},
26+
{"crlf input", "---\r\ntitle: t\r\n---\r\ndiagram", "diagram", "t"},
27+
{"multiline themeCSS config", "---\nconfig:\n themeCSS: |\n rect { fill: red; }\n---\nerDiagram", "erDiagram", ""},
28+
{"inline comment stripped from title", "---\ntitle: hi # note\n---\ndiagram", "diagram", "hi"},
29+
{"hash kept inside quoted title", "---\ntitle: \"a # b\"\n---\ndiagram", "diagram", "a # b"},
30+
{"no space after colon is not a mapping", "---\ntitle:xyz\n---\ndiagram", "diagram", ""},
31+
}
32+
for _, c := range cases {
33+
t.Run(c.name, func(t *testing.T) {
34+
rest, title := StripFrontmatter(c.in)
35+
if rest != c.wantRest {
36+
t.Errorf("rest = %q, want %q", rest, c.wantRest)
37+
}
38+
if title != c.wantTitle {
39+
t.Errorf("title = %q, want %q", title, c.wantTitle)
40+
}
41+
})
42+
}
43+
}

0 commit comments

Comments
 (0)