| 1 | diff --git a/internal/wiki/wiki.go b/internal/wiki/wiki.go |
| 2 | new file mode 100644 |
| 3 | index 0000000..9e13959 |
| 4 | --- /dev/null |
| 5 | +++ b/internal/wiki/wiki.go |
| 6 | @@ -0,0 +1,398 @@ |
| 7 | +// Package wiki implements the NIP-54 rules that do not depend on storage: |
| 8 | +// article name normalization, link discovery and a conservative Djot |
| 9 | +// renderer for article content. |
| 10 | +package wiki |
| 11 | + |
| 12 | +import ( |
| 13 | + "html/template" |
| 14 | + "net/url" |
| 15 | + "strings" |
| 16 | + "unicode" |
| 17 | +) |
| 18 | + |
| 19 | +// Normalize turns a title into the article's d tag: lowercase, whitespace as |
| 20 | +// hyphens, punctuation and symbols removed, hyphens collapsed and trimmed. |
| 21 | +// Letters of every script, combining marks and digits are preserved. |
| 22 | +func Normalize(title string) string { |
| 23 | + var out []rune |
| 24 | + for _, r := range strings.ToLower(title) { |
| 25 | + switch { |
| 26 | + case unicode.IsSpace(r) || r == '-' || r == '_': |
| 27 | + if len(out) > 0 && out[len(out)-1] != '-' { |
| 28 | + out = append(out, '-') |
| 29 | + } |
| 30 | + case unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r): |
| 31 | + out = append(out, r) |
| 32 | + } |
| 33 | + } |
| 34 | + return strings.Trim(string(out), "-") |
| 35 | +} |
| 36 | + |
| 37 | +// Links lists the targets referenced from Djot content in order of first |
| 38 | +// appearance: normalized article names for wikilinks and reference-style |
| 39 | +// links with no definition, and full nostr: URIs for NIP-21 links. |
| 40 | +func Links(content string) []string { |
| 41 | + refs := definitions(content) |
| 42 | + seen := map[string]bool{} |
| 43 | + var links []string |
| 44 | + add := func(target string) { |
| 45 | + if target != "" && !seen[target] { |
| 46 | + seen[target] = true |
| 47 | + links = append(links, target) |
| 48 | + } |
| 49 | + } |
| 50 | + for _, def := range refs { |
| 51 | + if strings.HasPrefix(strings.ToLower(def), "nostr:") { |
| 52 | + add(def) |
| 53 | + } |
| 54 | + } |
| 55 | + var visit func(text string) |
| 56 | + visit = func(text string) { |
| 57 | + for _, span := range inlineSpans(text) { |
| 58 | + switch span.kind { |
| 59 | + case "em", "strong": |
| 60 | + visit(span.text) |
| 61 | + case "wikilink": |
| 62 | + add(Normalize(span.target)) |
| 63 | + case "link": |
| 64 | + if strings.HasPrefix(strings.ToLower(span.target), "nostr:") { |
| 65 | + add(span.target) |
| 66 | + } |
| 67 | + case "reference": |
| 68 | + if def, ok := refs[strings.ToLower(span.target)]; ok { |
| 69 | + if strings.HasPrefix(strings.ToLower(def), "nostr:") { |
| 70 | + add(def) |
| 71 | + } |
| 72 | + } else { |
| 73 | + add(Normalize(span.target)) |
| 74 | + } |
| 75 | + } |
| 76 | + } |
| 77 | + } |
| 78 | + for _, block := range blocks(content) { |
| 79 | + switch block.kind { |
| 80 | + case "pre", "def": |
| 81 | + case "ul", "ol": |
| 82 | + for _, item := range block.items { |
| 83 | + visit(item) |
| 84 | + } |
| 85 | + default: |
| 86 | + visit(block.text) |
| 87 | + } |
| 88 | + } |
| 89 | + return links |
| 90 | +} |
| 91 | + |
| 92 | +// RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced |
| 93 | +// code, bullet and numbered lists, emphasis, strong, code spans, links, |
| 94 | +// [[wikilinks]] and reference-style links. Everything else is shown as |
| 95 | +// escaped text. Link targets are limited to http, https, mailto, nostr and |
| 96 | +// site-relative paths. |
| 97 | +func RenderHTML(content string) template.HTML { |
| 98 | + refs := definitions(content) |
| 99 | + var b strings.Builder |
| 100 | + for _, block := range blocks(content) { |
| 101 | + switch block.kind { |
| 102 | + case "def": |
| 103 | + continue |
| 104 | + case "pre": |
| 105 | + b.WriteString("<pre><code") |
| 106 | + if block.lang != "" { |
| 107 | + b.WriteString(` class="language-` + template.HTMLEscapeString(block.lang) + `"`) |
| 108 | + } |
| 109 | + b.WriteString(">" + template.HTMLEscapeString(block.text) + "</code></pre>\n") |
| 110 | + case "heading": |
| 111 | + level := string(rune('0' + block.level)) |
| 112 | + b.WriteString("<h" + level + ">" + renderInline(block.text, refs) + "</h" + level + ">\n") |
| 113 | + case "ul", "ol": |
| 114 | + b.WriteString("<" + block.kind + ">\n") |
| 115 | + for _, item := range block.items { |
| 116 | + b.WriteString("<li>" + renderInline(item, refs) + "</li>\n") |
| 117 | + } |
| 118 | + b.WriteString("</" + block.kind + ">\n") |
| 119 | + default: |
| 120 | + b.WriteString("<p>" + renderInline(block.text, refs) + "</p>\n") |
| 121 | + } |
| 122 | + } |
| 123 | + return template.HTML(b.String()) |
| 124 | +} |
| 125 | + |
| 126 | +type block struct { |
| 127 | + kind string |
| 128 | + level int |
| 129 | + lang string |
| 130 | + text string |
| 131 | + items []string |
| 132 | +} |
| 133 | + |
| 134 | +// definitions collects reference link definitions, "[label]: target", keyed |
| 135 | +// by their lowercase label. |
| 136 | +func definitions(content string) map[string]string { |
| 137 | + refs := map[string]string{} |
| 138 | + for _, block := range blocks(content) { |
| 139 | + if block.kind == "def" { |
| 140 | + refs[strings.ToLower(block.lang)] = block.text |
| 141 | + } |
| 142 | + } |
| 143 | + return refs |
| 144 | +} |
| 145 | + |
| 146 | +func blocks(content string) []block { |
| 147 | + lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n") |
| 148 | + var out []block |
| 149 | + var para []string |
| 150 | + flush := func() { |
| 151 | + if len(para) > 0 { |
| 152 | + out = append(out, block{kind: "p", text: strings.Join(para, "\n")}) |
| 153 | + para = nil |
| 154 | + } |
| 155 | + } |
| 156 | + for i := 0; i < len(lines); i++ { |
| 157 | + trimmed := strings.TrimSpace(lines[i]) |
| 158 | + if trimmed == "" { |
| 159 | + flush() |
| 160 | + continue |
| 161 | + } |
| 162 | + if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" { |
| 163 | + flush() |
| 164 | + lang := append(strings.Fields(trimmed[3:]), "")[0] |
| 165 | + var body []string |
| 166 | + for i++; i < len(lines); i++ { |
| 167 | + if t := strings.TrimSpace(lines[i]); len(t) >= 3 && strings.Trim(t, fence[:1]) == "" { |
| 168 | + break |
| 169 | + } |
| 170 | + body = append(body, lines[i]) |
| 171 | + } |
| 172 | + out = append(out, block{kind: "pre", lang: lang, text: strings.Join(body, "\n") + "\n"}) |
| 173 | + continue |
| 174 | + } |
| 175 | + if level := headingLevel(trimmed); level > 0 { |
| 176 | + flush() |
| 177 | + out = append(out, block{kind: "heading", level: level, text: strings.TrimSpace(trimmed[level:])}) |
| 178 | + continue |
| 179 | + } |
| 180 | + if label, target, ok := definitionLine(trimmed); ok && len(para) == 0 { |
| 181 | + out = append(out, block{kind: "def", lang: label, text: target}) |
| 182 | + continue |
| 183 | + } |
| 184 | + if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 { |
| 185 | + var items []string |
| 186 | + for ; i < len(lines); i++ { |
| 187 | + current := strings.TrimSpace(lines[i]) |
| 188 | + itemKind, rest := listMarker(current) |
| 189 | + if itemKind != kind { |
| 190 | + break |
| 191 | + } |
| 192 | + items = append(items, rest) |
| 193 | + } |
| 194 | + i-- |
| 195 | + out = append(out, block{kind: kind, items: items}) |
| 196 | + continue |
| 197 | + } |
| 198 | + para = append(para, trimmed) |
| 199 | + } |
| 200 | + flush() |
| 201 | + return out |
| 202 | +} |
| 203 | + |
| 204 | +func headingLevel(line string) int { |
| 205 | + level := len(line) - len(strings.TrimLeft(line, "#")) |
| 206 | + if level == 0 || level > 6 || level >= len(line) || line[level] != ' ' { |
| 207 | + return 0 |
| 208 | + } |
| 209 | + return level |
| 210 | +} |
| 211 | + |
| 212 | +func definitionLine(line string) (label, target string, ok bool) { |
| 213 | + end := strings.Index(line, "]:") |
| 214 | + if !strings.HasPrefix(line, "[") || end < 1 { |
| 215 | + return "", "", false |
| 216 | + } |
| 217 | + target = strings.TrimSpace(line[end+2:]) |
| 218 | + return strings.TrimSpace(line[1:end]), target, target != "" && !strings.ContainsAny(target, " \t") |
| 219 | +} |
| 220 | + |
| 221 | +func listMarker(line string) (kind, rest string) { |
| 222 | + if len(line) > 1 && (line[0] == '-' || line[0] == '*' || line[0] == '+') && line[1] == ' ' { |
| 223 | + return "ul", strings.TrimSpace(line[2:]) |
| 224 | + } |
| 225 | + digits := len(line) - len(strings.TrimLeft(line, "0123456789")) |
| 226 | + if digits > 0 && digits+1 < len(line) && (line[digits] == '.' || line[digits] == ')') && line[digits+1] == ' ' { |
| 227 | + return "ol", strings.TrimSpace(line[digits+2:]) |
| 228 | + } |
| 229 | + return "", "" |
| 230 | +} |
| 231 | + |
| 232 | +type span struct { |
| 233 | + kind string // text, code, em, strong, link, wikilink, reference |
| 234 | + text, target string |
| 235 | +} |
| 236 | + |
| 237 | +// inlineSpans splits paragraph text into literal runs and recognized inline |
| 238 | +// elements. It never nests: the text inside a link or emphasis is rendered |
| 239 | +// as further spans by the caller. |
| 240 | +func inlineSpans(text string) []span { |
| 241 | + var spans []span |
| 242 | + var literal strings.Builder |
| 243 | + flush := func() { |
| 244 | + if literal.Len() > 0 { |
| 245 | + spans = append(spans, span{kind: "text", text: literal.String()}) |
| 246 | + literal.Reset() |
| 247 | + } |
| 248 | + } |
| 249 | + for i := 0; i < len(text); { |
| 250 | + c := text[i] |
| 251 | + switch { |
| 252 | + case c == '\\' && i+1 < len(text): |
| 253 | + literal.WriteByte(text[i+1]) |
| 254 | + i += 2 |
| 255 | + continue |
| 256 | + case c == '`': |
| 257 | + run := strings.TrimLeft(text[i:], "`") |
| 258 | + marker := text[i : len(text)-len(run)] |
| 259 | + if end := strings.Index(run, marker); end >= 0 { |
| 260 | + flush() |
| 261 | + spans = append(spans, span{kind: "code", text: strings.TrimSpace(run[:end])}) |
| 262 | + i += 2*len(marker) + end |
| 263 | + continue |
| 264 | + } |
| 265 | + case c == '*' || c == '_': |
| 266 | + if end := emphasisEnd(text, i); end > 0 { |
| 267 | + flush() |
| 268 | + kind := "em" |
| 269 | + if c == '*' { |
| 270 | + kind = "strong" |
| 271 | + } |
| 272 | + spans = append(spans, span{kind: kind, text: text[i+1 : end]}) |
| 273 | + i = end + 1 |
| 274 | + continue |
| 275 | + } |
| 276 | + case c == '[' && strings.HasPrefix(text[i:], "[["): |
| 277 | + if end := strings.Index(text[i:], "]]"); end > 2 { |
| 278 | + flush() |
| 279 | + target, label, _ := strings.Cut(text[i+2:i+end], "|") |
| 280 | + spans = append(spans, span{kind: "wikilink", target: strings.TrimSpace(target), text: strings.TrimSpace(label)}) |
| 281 | + i += end + 2 |
| 282 | + continue |
| 283 | + } |
| 284 | + case c == '[': |
| 285 | + if s, n := linkAt(text[i:]); n > 0 { |
| 286 | + flush() |
| 287 | + spans = append(spans, s) |
| 288 | + i += n |
| 289 | + continue |
| 290 | + } |
| 291 | + } |
| 292 | + literal.WriteByte(c) |
| 293 | + i++ |
| 294 | + } |
| 295 | + flush() |
| 296 | + return spans |
| 297 | +} |
| 298 | + |
| 299 | +// emphasisEnd finds the closing marker for Djot emphasis: the opener is not |
| 300 | +// followed by whitespace and the closer is not preceded by it. |
| 301 | +func emphasisEnd(text string, start int) int { |
| 302 | + marker := text[start] |
| 303 | + if start+1 >= len(text) || text[start+1] == ' ' || text[start+1] == '\n' { |
| 304 | + return 0 |
| 305 | + } |
| 306 | + for i := start + 2; i < len(text); i++ { |
| 307 | + if text[i] == marker && text[i-1] != ' ' && text[i-1] != '\n' { |
| 308 | + return i |
| 309 | + } |
| 310 | + } |
| 311 | + return 0 |
| 312 | +} |
| 313 | + |
| 314 | +// linkAt recognizes [text](target), [text][label] and [text][] at the start |
| 315 | +// of the string and reports how many bytes it consumed. |
| 316 | +func linkAt(text string) (span, int) { |
| 317 | + depth, end := 0, -1 |
| 318 | + for i := 0; i < len(text); i++ { |
| 319 | + if text[i] == '[' { |
| 320 | + depth++ |
| 321 | + } else if text[i] == ']' { |
| 322 | + if depth--; depth == 0 { |
| 323 | + end = i |
| 324 | + break |
| 325 | + } |
| 326 | + } |
| 327 | + } |
| 328 | + if end <= 0 { |
| 329 | + return span{}, 0 |
| 330 | + } |
| 331 | + label := text[1:end] |
| 332 | + rest := text[end+1:] |
| 333 | + if strings.HasPrefix(rest, "(") { |
| 334 | + if close := strings.IndexByte(rest, ')'); close > 0 { |
| 335 | + return span{kind: "link", text: label, target: strings.TrimSpace(rest[1:close])}, end + 2 + close |
| 336 | + } |
| 337 | + } |
| 338 | + if strings.HasPrefix(rest, "[") { |
| 339 | + if close := strings.IndexByte(rest, ']'); close > 0 { |
| 340 | + target := strings.TrimSpace(rest[1:close]) |
| 341 | + if target == "" { |
| 342 | + target = label |
| 343 | + } |
| 344 | + return span{kind: "reference", text: label, target: target}, end + 2 + close |
| 345 | + } |
| 346 | + } |
| 347 | + return span{}, 0 |
| 348 | +} |
| 349 | + |
| 350 | +func renderInline(text string, refs map[string]string) string { |
| 351 | + var b strings.Builder |
| 352 | + for _, s := range inlineSpans(text) { |
| 353 | + switch s.kind { |
| 354 | + case "text": |
| 355 | + b.WriteString(template.HTMLEscapeString(s.text)) |
| 356 | + case "code": |
| 357 | + b.WriteString("<code>" + template.HTMLEscapeString(s.text) + "</code>") |
| 358 | + case "em", "strong": |
| 359 | + b.WriteString("<" + s.kind + ">" + renderInline(s.text, refs) + "</" + s.kind + ">") |
| 360 | + case "wikilink": |
| 361 | + label := s.text |
| 362 | + if label == "" { |
| 363 | + label = s.target |
| 364 | + } |
| 365 | + b.WriteString(anchor("/wiki/"+url.PathEscape(Normalize(s.target)), renderInline(label, refs))) |
| 366 | + case "link": |
| 367 | + b.WriteString(anchor(safeHref(s.target), renderInline(s.text, refs))) |
| 368 | + case "reference": |
| 369 | + href := "/wiki/" + url.PathEscape(Normalize(s.target)) |
| 370 | + if def, ok := refs[strings.ToLower(s.target)]; ok { |
| 371 | + href = safeHref(def) |
| 372 | + } |
| 373 | + b.WriteString(anchor(href, renderInline(s.text, refs))) |
| 374 | + } |
| 375 | + } |
| 376 | + return b.String() |
| 377 | +} |
| 378 | + |
| 379 | +func anchor(href, inner string) string { |
| 380 | + if href == "" { |
| 381 | + return inner |
| 382 | + } |
| 383 | + return `<a href="` + template.HTMLEscapeString(href) + `">` + inner + `</a>` |
| 384 | +} |
| 385 | + |
| 386 | +// safeHref keeps http, https, mailto and site-relative targets, routes |
| 387 | +// nostr: links through the relay's open page and drops everything else. |
| 388 | +func safeHref(target string) string { |
| 389 | + target = strings.TrimSpace(target) |
| 390 | + lower := strings.ToLower(target) |
| 391 | + switch { |
| 392 | + case strings.HasPrefix(lower, "nostr:"): |
| 393 | + return "/open?target=" + url.QueryEscape(target) |
| 394 | + case strings.HasPrefix(lower, "mailto:"): |
| 395 | + return target |
| 396 | + case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"): |
| 397 | + if u, err := url.Parse(target); err == nil && u.Host != "" { |
| 398 | + return target |
| 399 | + } |
| 400 | + case strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && !strings.HasPrefix(target, "/\\"): |
| 401 | + return target |
| 402 | + } |
| 403 | + return "" |
| 404 | +} |
| 405 | diff --git a/internal/wiki/wiki_test.go b/internal/wiki/wiki_test.go |
| 406 | new file mode 100644 |
| 407 | index 0000000..24732b8 |
| 408 | --- /dev/null |
| 409 | +++ b/internal/wiki/wiki_test.go |
| 410 | @@ -0,0 +1,143 @@ |
| 411 | +package wiki |
| 412 | + |
| 413 | +import ( |
| 414 | + "fmt" |
| 415 | + "strings" |
| 416 | + "testing" |
| 417 | +) |
| 418 | + |
| 419 | +func TestNormalizeFollowsNIP54Examples(t *testing.T) { |
| 420 | + cases := map[string]string{ |
| 421 | + "Wiki Article": "wiki-article", |
| 422 | + "What's Up?": "whats-up", |
| 423 | + " Hello World ": "hello-world", |
| 424 | + "Article 1": "article-1", |
| 425 | + "ウィキペディア": "ウィキペディア", |
| 426 | + "Ñoño": "ñoño", |
| 427 | + "Москва": "москва", |
| 428 | + "日本語 Article": "日本語-article", |
| 429 | + "proof-of-work": "proof-of-work", |
| 430 | + "--Lightning--": "lightning", |
| 431 | + "a -- b\tc": "a-b-c", |
| 432 | + "C++ & C#": "c-c", |
| 433 | + "e\u0301lan": "e\u0301lan", |
| 434 | + "": "", |
| 435 | + "!!!": "", |
| 436 | + "snake_case_name": "snake-case-name", |
| 437 | + "Tabs\tand\nbreaks": "tabs-and-breaks", |
| 438 | + } |
| 439 | + for input, want := range cases { |
| 440 | + if got := Normalize(input); got != want { |
| 441 | + t.Errorf("Normalize(%q) = %q, want %q", input, got, want) |
| 442 | + } |
| 443 | + } |
| 444 | +} |
| 445 | + |
| 446 | +func TestLinksFindWikilinksReferencesAndNostrTargets(t *testing.T) { |
| 447 | + content := "Bitcoin is a [cryptocurrency][] invented by [Satoshi Nakamoto][].\n\n" + |
| 448 | + "See also: [proof of work][] and [lightning network][Lightning Network], [[Mining|the miners]] and _[Биткойн][]_.\n\n" + |
| 449 | + "- [Bob](nostr:npub1bob) wrote `[[not a link]]`\n" + |
| 450 | + "- [web](https://example.com/[[x]])\n\n" + |
| 451 | + "```\n[[ignored in code]]\n```\n\n" + |
| 452 | + "[Satoshi Nakamoto]: nostr:npub1satoshi\n" |
| 453 | + got := Links(content) |
| 454 | + want := []string{"nostr:npub1satoshi", "cryptocurrency", "proof-of-work", "lightning-network", "mining", "биткойн", "nostr:npub1bob"} |
| 455 | + if fmt.Sprint(got) != fmt.Sprint(want) { |
| 456 | + t.Fatalf("Links = %v, want %v", got, want) |
| 457 | + } |
| 458 | + if links := Links("plain text"); len(links) != 0 { |
| 459 | + t.Fatalf("plain text links = %v", links) |
| 460 | + } |
| 461 | +} |
| 462 | + |
| 463 | +func TestRenderHTMLSupportedSyntax(t *testing.T) { |
| 464 | + content := "# Title with _emphasis_\n\n" + |
| 465 | + "A paragraph with *strong*, `code <b>` and a [link](https://example.com/a?b=1&c=2).\n" + |
| 466 | + "Second line of the paragraph.\n\n" + |
| 467 | + "## Lists\n\n" + |
| 468 | + "- one [[Wiki Page]]\n- two [[Другая|shown]]\n\n" + |
| 469 | + "1. first\n2) second\n\n" + |
| 470 | + "``` go\nfunc main() {}\n<script>alert(1)</script>\n```\n\n" + |
| 471 | + "[Bob](nostr:npub1bob) and [reference][] and [defined][site].\n\n" + |
| 472 | + "[site]: https://example.org/\n" |
| 473 | + got := string(RenderHTML(content)) |
| 474 | + for _, want := range []string{ |
| 475 | + "<h1>Title with <em>emphasis</em></h1>", |
| 476 | + "<p>A paragraph with <strong>strong</strong>, <code>code <b></code> and a <a href=\"https://example.com/a?b=1&c=2\">link</a>.\nSecond line of the paragraph.</p>", |
| 477 | + "<h2>Lists</h2>", |
| 478 | + "<ul>\n<li>one <a href=\"/wiki/wiki-page\">Wiki Page</a></li>\n<li>two <a href=\"/wiki/%D0%B4%D1%80%D1%83%D0%B3%D0%B0%D1%8F\">shown</a></li>\n</ul>", |
| 479 | + "<ol>\n<li>first</li>\n<li>second</li>\n</ol>", |
| 480 | + "<pre><code class=\"language-go\">func main() {}\n<script>alert(1)</script>\n</code></pre>", |
| 481 | + "<a href=\"/open?target=nostr%3Anpub1bob\">Bob</a>", |
| 482 | + "<a href=\"/wiki/reference\">reference</a>", |
| 483 | + "<a href=\"https://example.org/\">defined</a>", |
| 484 | + } { |
| 485 | + if !strings.Contains(got, want) { |
| 486 | + t.Errorf("missing %q in\n%s", want, got) |
| 487 | + } |
| 488 | + } |
| 489 | + if strings.Contains(got, "[site]") { |
| 490 | + t.Errorf("definition line rendered:\n%s", got) |
| 491 | + } |
| 492 | +} |
| 493 | + |
| 494 | +func TestRenderHTMLEscapesEverythingElse(t *testing.T) { |
| 495 | + cases := []struct{ content, forbidden, required string }{ |
| 496 | + {"<script>alert(1)</script>", "<script", "<script>"}, |
| 497 | + {"[click](javascript:alert(1))", "javascript:", "click"}, |
| 498 | + {"[click](JavaScript:alert(1))", "avaScript:", "click"}, |
| 499 | + {"[click](data:text/html;base64,AAAA)", "data:", "click"}, |
| 500 | + {"[click](vbscript:msgbox)", "vbscript:", "click"}, |
| 501 | + {"[click](//evil.example/x)", "evil.example", "click"}, |
| 502 | + {"[click](/\\evil.example)", "href", "click"}, |
| 503 | + {"[[Page]] [x](\"><img src=x onerror=alert(1)>)", "<img", "x>"}, |
| 504 | + {"[ref][]\n\n[ref]: javascript:alert(1)", "javascript:", "<p>ref</p>"}, |
| 505 | + {"<a href=\"https://x\">raw html</a>", "<a href", "<a href"}, |
| 506 | + {"<img src=x onerror=alert(1)>", "<img", "<img"}, |
| 507 | + {"_<b>bold</b>_", "<b>", "<em><b>bold</b></em>"}, |
| 508 | + {"[[<script>]]", "<script", "/wiki/script"}, |
| 509 | + {"[[x|<script>]]", "<script>", "<script>"}, |
| 510 | + {"`<script>`", "<script>", "<code><script></code>"}, |
| 511 | + {"[text](https://example.com/\"onmouseover=\"alert(1))", "onmouseover=\"", ""onmouseover=""}, |
| 512 | + } |
| 513 | + for _, tc := range cases { |
| 514 | + got := string(RenderHTML(tc.content)) |
| 515 | + if strings.Contains(got, tc.forbidden) { |
| 516 | + t.Errorf("%q rendered %q:\n%s", tc.content, tc.forbidden, got) |
| 517 | + } |
| 518 | + if !strings.Contains(got, tc.required) { |
| 519 | + t.Errorf("%q lacks %q:\n%s", tc.content, tc.required, got) |
| 520 | + } |
| 521 | + } |
| 522 | +} |
| 523 | + |
| 524 | +func TestRenderHTMLHandlesEdgeCases(t *testing.T) { |
| 525 | + cases := map[string]string{ |
| 526 | + "": "", |
| 527 | + "\n\n\n": "", |
| 528 | + "```": "<pre><code>\n</code></pre>\n", |
| 529 | + "```\nunterminated": "<pre><code>unterminated\n</code></pre>\n", |
| 530 | + "#NoSpace": "<p>#NoSpace</p>\n", |
| 531 | + "####### seven": "<p>####### seven</p>\n", |
| 532 | + "* not strong": "<ul>\n<li>not strong</li>\n</ul>\n", |
| 533 | + "a * b * c": "<p>a * b * c</p>\n", |
| 534 | + "a_b_c": "<p>a<em>b</em>c</p>\n", |
| 535 | + "\\*escaped\\*": "<p>*escaped*</p>\n", |
| 536 | + "``code with ` inside``": "<p><code>code with ` inside</code></p>\n", |
| 537 | + "[[]]": "<p>[[]]</p>\n", |
| 538 | + "[unclosed": "<p>[unclosed</p>\n", |
| 539 | + "[text](": "<p>[text](</p>\n", |
| 540 | + "[mailto](mailto:a@b.example)": "<p><a href=\"mailto:a@b.example\">mailto</a></p>\n", |
| 541 | + "[rel](/files)": "<p><a href=\"/files\">rel</a></p>\n", |
| 542 | + "[bad](http://)": "<p>bad</p>\n", |
| 543 | + "- item\nplain": "<ul>\n<li>item</li>\n</ul>\n<p>plain</p>\n", |
| 544 | + "para\n- not a list": "<p>para\n- not a list</p>\n", |
| 545 | + "Ünïcödé & <ampersand>": "<p>Ünïcödé & <ampersand></p>\n", |
| 546 | + "line\r\nbreak": "<p>line\nbreak</p>\n", |
| 547 | + } |
| 548 | + for content, want := range cases { |
| 549 | + if got := string(RenderHTML(content)); got != want { |
| 550 | + t.Errorf("RenderHTML(%q) =\n%s\nwant\n%s", content, got, want) |
| 551 | + } |
| 552 | + } |
| 553 | +} |