1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168 |
package doc
import (
"strings"
"testing"
)
func kinds(bs []Block) string {
var out []string
for _, b := range bs {
out = append(out, b.Kind)
}
return strings.Join(out, ",")
}
func TestSplitBlocks(t *testing.T) {
cases := []struct {
name, in, want string
}{
{"paragraphs", "one\ntwo\n\nthree", "paragraph,paragraph"},
{"atx interrupts", "text\n# Head\nmore", "paragraph,heading,paragraph"},
{"setext", "Title\n=====\n\nbody", "heading,paragraph"},
{"thematic vs setext", "para\n\n---\n\nnext", "paragraph,break,paragraph"},
{"fence with blank lines", "```go\na\n\nb\n```\nafter", "code,paragraph"},
{"unclosed fence", "```\nnever closed\n\nstill code", "code"},
{"list items", "- a\n- b\n continued\n- c", "list_item,list_item,list_item"},
{"nested list stays", "- a\n - nested\n - nested2\n- b", "list_item,list_item"},
{"loose item paragraphs", "1. first\n\n second para\n\n2. next", "list_item,list_item"},
{"list ends at outdent", "- a\n\nparagraph", "list_item,paragraph"},
{"quote", "> a\n> b\nlazy\n\npara", "quote,paragraph"},
{"table", "| a | b |\n|---|:-:|\n| 1 | 2 |\n\nafter", "table,paragraph"},
{"html", "<div>\nx\n</div>\n\np", "html,paragraph"},
{"indented code", "para\n\n code\n\n more\n\nend", "paragraph,code,paragraph"},
{"ordered 2 does not interrupt", "In\n2026. was long", "paragraph"},
{"star break not list", "* * *", "break"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := kinds(SplitBlocks(c.in)); got != c.want {
t.Fatalf("got %s, want %s", got, c.want)
}
})
}
}
func TestBlockSourcesCoverFences(t *testing.T) {
bs := SplitBlocks("```\ncode\n```")
if bs[0].Source != "```\ncode\n```" || bs[0].Text != "code" {
t.Fatalf("got %+v", bs[0])
}
}
func TestPlainText(t *testing.T) {
cases := map[string]string{
"A [link](https://x.y) and **bold** _em_ `code`.": "A link and bold em code.",
"# Heading *one*": "Heading one",
"- item with <https://auto.link>": "item with https://auto.link",
"| a | **b** |\n|---|---|\n| 1 | 2 |": "a | b\n1 | 2",
"line one\nline two": "line one line two",
" after": "alt text after",
"> quoted *text*": "quoted text",
"Ação & reação": "Ação & reação",
"hard \nbreak": "hard break",
`escaped \*stars\* and é`: "escaped *stars* and é",
"`a & b` code": "a & b code",
}
for in, want := range cases {
bs := SplitBlocks(in)
if len(bs) != 1 {
t.Fatalf("%q split into %d blocks", in, len(bs))
}
if bs[0].Text != want {
t.Errorf("plainText(%q) = %q, want %q", in, bs[0].Text, want)
}
}
}
func TestTokenizeRoundTrip(t *testing.T) {
for _, s := range []string{"Don't stop, d'água! Ação\tok", "", "🌙 moon", "a-b_c 12.5%"} {
toks := Tokenize(s)
if strings.Join(toks, "") != s {
t.Fatalf("round trip of %q: %q", s, toks)
}
}
got := Tokenize("don't d'água x'")
want := []string{"don't", " ", "d'água", " ", "x", "'"}
if strings.Join(got, "|") != strings.Join(want, "|") {
t.Fatalf("got %q", got)
}
}
func TestFrontmatterAndHashes(t *testing.T) {
opt := DefaultOptions()
a := Parse([]byte("---\ntitle: Moon\nrevision: First note\nmaturity: seed\nflowerColor: '#fff'\n---\nBody text.\n"), opt)
if a.FrontmatterErr != nil || a.Note != "First note" || a.Maturity != "seed" || a.Title != "Moon" {
t.Fatalf("parsed %+v", a)
}
// Only the note and an ignored key change: content is the same.
b := Parse([]byte("---\ntitle: Moon\nrevision: Second note\nmaturity: seed\nflowerColor: '#000'\n---\nBody text.\n"), opt)
if a.ContentHash != b.ContentHash || a.RawHash == b.RawHash {
t.Fatal("note-only edit should keep ContentHash and change RawHash")
}
// CRLF, BOM and trailing spaces are not edits.
c := Parse([]byte("\xef\xbb\xbf---\r\ntitle: Moon\r\nrevision: First note\r\nmaturity: seed\r\nflowerColor: '#fff'\r\n---\r\nBody text. \r\n\r\n"), opt)
if c.RawHash != a.RawHash {
t.Fatal("CRLF/BOM/trailing space should not change RawHash")
}
// NFD and NFC spellings are the same text.
nfc := Parse([]byte("Ação"), opt)
nfd := Parse([]byte("Ação"), opt)
if nfc.RawHash != nfd.RawHash {
t.Fatal("NFD input should normalize to NFC")
}
// A title change is a content change.
d := Parse([]byte("---\ntitle: Sun\nrevision: First note\nmaturity: seed\n---\nBody text.\n"), opt)
if d.ContentHash == a.ContentHash {
t.Fatal("title change should change ContentHash")
}
}
func TestMalformedFrontmatter(t *testing.T) {
d := Parse([]byte("---\ntitle: [unclosed\n---\nBody"), DefaultOptions())
if d.FrontmatterErr == nil {
t.Fatal("want YAML error")
}
if d.Body != "Body" || len(d.Blocks) != 1 {
t.Fatalf("body should survive: %+v", d)
}
}
func TestNoFrontmatterUsesHeadingTitle(t *testing.T) {
d := Parse([]byte("# Jardim de agentes autônomos\n\ntext"), DefaultOptions())
if d.Title != "Jardim de agentes autônomos" {
t.Fatalf("title %q", d.Title)
}
unclosed := Parse([]byte("---\nnot frontmatter"), DefaultOptions())
if len(unclosed.Frontmatter) != 0 || unclosed.Body != "---\nnot frontmatter" {
t.Fatalf("unclosed delimiter is body: %+v", unclosed)
}
}
func TestIgnoreRegions(t *testing.T) {
opt := DefaultOptions()
a := Parse([]byte("Keep.\n\n<!-- patchwork:ignore -->\nUpdated 2026-10-01\n<!-- /patchwork:ignore -->\n\nEnd."), opt)
b := Parse([]byte("Keep.\n\n<!-- patchwork:ignore -->\nUpdated 2026-10-23\n<!-- /patchwork:ignore -->\n\nEnd."), opt)
if a.ContentHash != b.ContentHash {
t.Fatal("ignored region edits must not change ContentHash")
}
if kinds(a.Blocks) != "paragraph,paragraph" {
t.Fatalf("blocks %s", kinds(a.Blocks))
}
fenced := Parse([]byte("```\n<!-- patchwork:ignore -->\nshown\n```"), opt)
if !strings.Contains(fenced.Blocks[0].Text, "shown") {
t.Fatal("markers inside code fences must be literal")
}
}
func TestYAMLScalarsAsText(t *testing.T) {
d := Parse([]byte("---\nrevision: 2\nmaturity: yes\n---\n"), DefaultOptions())
// yaml.v3 follows YAML 1.2, where "yes" is a plain string.
if d.Note != "2" || d.Maturity != "yes" {
t.Fatalf("note %q maturity %q", d.Note, d.Maturity)
}
}
|