summaryrefslogtreecommitdiff
path: root/vrsite/markdown_test.go
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-01 16:18:19 -0300
committerGabriel Schneider <[email protected]>2026-08-01 18:21:40 -0300
commit151ea12251ed876aa87bb486da1bc337e25369e9 (patch)
tree0363d90b5df6fc8b17b1737ef2793ba3825ba4d4 /vrsite/markdown_test.go
parent8ec4e135e318c7799b2576a8729493c063da4a97 (diff)
downloadnotevi-151ea12251ed876aa87bb486da1bc337e25369e9.tar.gz
notevi-151ea12251ed876aa87bb486da1bc337e25369e9.zip
Diffstat (limited to 'vrsite/markdown_test.go')
-rw-r--r--vrsite/markdown_test.go244
1 files changed, 244 insertions, 0 deletions
diff --git a/vrsite/markdown_test.go b/vrsite/markdown_test.go
new file mode 100644
index 0000000..e8f1fe1
--- /dev/null
+++ b/vrsite/markdown_test.go
@@ -0,0 +1,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{"&lt;", "&gt;"} {
+ 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))",
+ "![pic](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, "+[-]&lt;&gt;") {
+ 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)
+ }
+ }
+}