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
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
|
package main
import (
"net/http/httptest"
"net/url"
"strings"
"testing"
)
func testMarkdown(t *testing.T) *markdown {
t.Helper()
return newMarkdown(newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light")))
}
func TestMarkdownNotes(t *testing.T) {
m := testMarkdown(t)
cases := []struct {
name string
in string
want []string
not []string
}{
{
name: "inline marks",
in: "`detect()` prefers **direct env** and only walks the tree as *fallback*",
want: []string{"<code>detect()</code>", "<strong>direct env</strong>", "<em>fallback</em>"},
},
{
name: "list after a paragraph",
in: "three classes:\n\n- persisted RPC narrowed\n- ingress bounds change",
want: []string{"<p>three classes:</p>", "<ul>", "<li>persisted RPC narrowed</li>"},
},
{
name: "bare url",
in: "see https://example.com/x for the rest",
want: []string{`<a href="https://example.com/x">`},
},
{
name: "single newlines are kept",
in: "first line\nsecond line",
want: []string{"first line<br>", "second line"},
not: []string{"<ul>"},
},
// the identifiers real notes are full of must survive untouched:
// underscores are not emphasis, "4.2" is not a list, "@" is not a
// mail address
{
name: "code prose is left alone",
in: "4.2 drops stake_minimum_delegation_for_rewards and remove_simple_vote, unlike @",
want: []string{"stake_minimum_delegation_for_rewards", "remove_simple_vote", "4.2 drops"},
not: []string{"<em>", "<ol>", "<a href"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(m.render(c.in))
for _, w := range c.want {
if !strings.Contains(got, w) {
t.Errorf("missing %q in:\n%s", w, got)
}
}
for _, n := range c.not {
if strings.Contains(got, n) {
t.Errorf("unwanted %q in:\n%s", n, got)
}
}
})
}
}
func TestNoteLinksFindOnlyLocalMarkdownLinks(t *testing.T) {
p := newNoteLinkParser()
got := noteLinks(p, strings.Join([]string{
"[root](/note/2)",
"[same target](note/2#discussion)",
"[static relative](../note/3.html?scope=all)",
"`[code](note/4)` and plain note/5 are not links",
"[remote](https://example.com/note/6)",
"[protocol relative](//example.com/note/7)",
"[reference][n]",
"[n]: ./note/8",
}, "\n\n"))
if len(got) != 3 || got[0] != 2 || got[1] != 3 || got[2] != 8 {
t.Errorf("note links = %v, want [2 3 8]", got)
}
m := testMarkdown(t)
text := "[note](../note/3.html) and [remote](https://example.com/note/4)"
if body := string(m.renderAt(text, &page{Live: true, Root: "/"})); !strings.Contains(body, `href="/note/3"`) || !strings.Contains(body, `href="https://example.com/note/4"`) {
t.Errorf("live note links were not canonicalized selectively:\n%s", body)
}
if body := string(m.renderAt(text, &page{Root: "../"})); !strings.Contains(body, `href="../note/3.html"`) {
t.Errorf("static note link does not follow the page root:\n%s", body)
}
}
// Angle brackets in a note are code, never markup: they must come through as
// text, and must not stop the markdown around them from being parsed. That
// second half is what goldmark's HTML *block* parser would break — a note
// opening with a quoted tag would render the rest of its paragraph as source.
func TestMarkdownShowsAngleBracketsAsText(t *testing.T) {
m := testMarkdown(t)
cases := []struct{ name, in string }{
{"inline", "sysvar_cache: RwLock<SysvarCache> — hoisted out of the loop"},
{"block, tag alone on the line", "<div>\nthe **wrapper** in [page.go](https://go.dev)"},
{"script", "<script>alert(1)</script> is quoted from [the file](https://go.dev)"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := string(m.render(c.in))
if strings.Contains(got, "<div>") || strings.Contains(got, "<script>") {
t.Errorf("markup reached the page:\n%s", got)
}
if strings.Contains(got, "raw HTML omitted") {
t.Errorf("text was dropped:\n%s", got)
}
for _, w := range []string{"<", ">"} {
if !strings.Contains(got, w) {
t.Errorf("missing %q in:\n%s", w, got)
}
}
if strings.Contains(c.in, "[") && !strings.Contains(got, `<a href="https://go.dev">`) {
t.Errorf("markdown stopped being parsed:\n%s", got)
}
})
}
}
// A note is written by whoever ran vr, so the renderer is the last thing
// standing between the log and the page.
func TestMarkdownDropsDangerousLinks(t *testing.T) {
m := testMarkdown(t)
for _, in := range []string{
"[click](javascript:alert(1))",
")",
"[click](vbscript:alert(1))",
} {
if got := string(m.render(in)); strings.Contains(got, "javascript:") || strings.Contains(got, "vbscript:") {
t.Errorf("%q rendered as %s", in, got)
}
}
}
// A fenced block is painted by the same grammars as the code view, so a
// snippet in a note wears the reader's theme.
func TestMarkdownHighlightsFences(t *testing.T) {
m := testMarkdown(t)
got := string(m.render("```go\nfunc load() error { return nil }\n```"))
for _, w := range []string{`<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`, "</code></pre>"} {
if !strings.Contains(got, w) {
t.Errorf("missing %q in:\n%s", w, got)
}
}
// a language no grammar claims still renders, just unpainted
got = string(m.render("```brainfuck\n+[-]<>\n```"))
if !strings.Contains(got, "+[-]<>") {
t.Errorf("unclaimed fence lost its text:\n%s", got)
}
if strings.Contains(got, "<span") {
t.Errorf("unclaimed fence was painted:\n%s", got)
}
}
// Fence languages are mostly extensions already, so the grammar table does the
// lookup; fenceAliases only has to cover the spelled-out names. Each pair here
// is a snippet the claimed grammar must find something to paint in.
func TestMarkdownFenceLanguages(t *testing.T) {
hl := newHighlighter(loadTheme("", "", "dark"), loadTheme("", "", "light"))
for _, c := range []struct{ lang, src string }{
{"go", "func f() {}"}, {"GO", "func f() {}"}, {"golang", "func f() {}"},
{"rs", "fn f() {}"}, {"rust", "fn f() {}"},
{"py", "def f(): pass"}, {"python", "def f(): pass"}, {"python3", "def f(): pass"},
{"js", "function f() {}"}, {"javascript", "function f() {}"}, {"node", "function f() {}"},
{"jsx", "function f() {}"}, {"mjs", "function f() {}"},
{"ts", "function f(): void {}"}, {"typescript", "function f(): void {}"},
{"tsx", "function f(): void {}"},
{"c", "int f(void) { return 0; }"},
{"cpp", "int f() { return 0; }"}, {"c++", "int f() { return 0; }"}, {"h", "int f();"},
{"json", `{"a": 1}`}, {"jsonc", `{"a": 1}`}, {"json5", `{"a": 1}`},
{"sh", "for x in a; do echo $x; done"}, {"bash", "for x in a; do echo $x; done"},
{"shell", "for x in a; do echo $x; done"}, {"zsh", "for x in a; do echo $x; done"},
{"console", "for x in a; do echo $x; done"},
} {
if !painted(hl.classifyLang(c.lang, []byte(c.src))) {
t.Errorf("fence language %q painted nothing in %q", c.lang, c.src)
}
}
for _, lang := range []string{"", "text", "brainfuck", " ", "."} {
src := []byte("func f() {}")
if got := hl.classifyLang(lang, src); len(got) != len(src) || painted(got) {
t.Errorf("classifyLang(%q) claimed a grammar it should not have", lang)
}
}
}
func painted(classes []int16) bool {
for _, c := range classes {
if c != 0 {
return true
}
}
return false
}
// The note card is the one place all of this has to arrive: the notes page,
// the aside on a file, and the conversation view all render it.
func TestNotePageRendersMarkdown(t *testing.T) {
sv, _, _ := testServer(t)
h := sv.handler()
form := url.Values{"file": {"hello.txt"}, "text": {
"`cover()` widens the **mask**\n\n- one\n- two\n\n```go\nfunc f() {}\n```"}, "rev": {"@"}}
r := httptest.NewRequest("POST", "/notes", strings.NewReader(form.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.Header.Set("HX-Request", "true")
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
if w.Code != 200 {
t.Fatalf("POST /notes = %d: %s", w.Code, w.Body)
}
for _, want := range []string{
`<div class="body">`,
"<code>cover()</code>", "<strong>mask</strong>",
"<li>one</li>",
`<pre class="mdcode" data-lang="go">`, `<span class="s-keyword">func</span>`,
} {
if !strings.Contains(w.Body.String(), want) {
t.Errorf("missing %q in the posted note card:\n%s", want, w.Body)
}
}
// and again off the page itself, not just the htmx fragment
if b := get(t, h, "/notes", false).Body.String(); !strings.Contains(b, "<code>cover()</code>") {
t.Error("notes page did not render the note as markdown")
}
}
func TestMarkdownEmpty(t *testing.T) {
m := testMarkdown(t)
for _, in := range []string{"", " ", "\n\n"} {
if got := m.render(in); got != "" {
t.Errorf("render(%q) = %q, want empty", in, got)
}
}
}
|