| 1 | package wiki |
| 2 | |
| 3 | import ( |
| 4 | "html/template" |
| 5 | "net/url" |
| 6 | "strings" |
| 7 | "unicode" |
| 8 | ) |
| 9 | |
| 10 | // Normalize turns a title into the article's d tag: lowercase, whitespace as |
| 11 | // hyphens, punctuation and symbols removed, hyphens collapsed and trimmed. |
| 12 | // Letters of every script, combining marks and digits are preserved. |
| 13 | func Normalize(title string) string { |
| 14 | var out []rune |
| 15 | for _, r := range strings.ToLower(title) { |
| 16 | switch { |
| 17 | case unicode.IsSpace(r) || r == '-' || r == '_' : |
| 18 | if len(out) > 0 && out[len(out)-1] != '-' { |
| 19 | out = append(out, '-' ) |
| 20 | } |
| 21 | case unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r): |
| 22 | out = append(out, r) |
| 23 | } |
| 24 | } |
| 25 | return strings.Trim(string(out), "-" ) |
| 26 | } |
| 27 | |
| 28 | // Links lists the targets referenced from Djot content in order of first |
| 29 | // appearance: normalized article names for wikilinks and reference-style |
| 30 | // links with no definition, and full nostr: URIs for NIP-21 links. |
| 31 | func Links(content string) []string { |
| 32 | refs := definitions(content) |
| 33 | seen := map[string]bool{} |
| 34 | var links []string |
| 35 | add := func(target string) { |
| 36 | if target != "" && !seen[target] { |
| 37 | seen[target] = true |
| 38 | links = append(links, target) |
| 39 | } |
| 40 | } |
| 41 | for _, def := range refs { |
| 42 | if strings.HasPrefix(strings.ToLower(def), "nostr:" ) { |
| 43 | add(def) |
| 44 | } |
| 45 | } |
| 46 | var visit func(text string) |
| 47 | visit = func(text string) { |
| 48 | for _, span := range inlineSpans(text) { |
| 49 | switch span.kind { |
| 50 | case "em" , "strong" : |
| 51 | visit(span.text) |
| 52 | case "wikilink" : |
| 53 | add(Normalize(span.target)) |
| 54 | case "link" : |
| 55 | if strings.HasPrefix(strings.ToLower(span.target), "nostr:" ) { |
| 56 | add(span.target) |
| 57 | } |
| 58 | case "reference" : |
| 59 | if def, ok := refs[strings.ToLower(span.target)]; ok { |
| 60 | if strings.HasPrefix(strings.ToLower(def), "nostr:" ) { |
| 61 | add(def) |
| 62 | } |
| 63 | } else { |
| 64 | add(Normalize(span.target)) |
| 65 | } |
| 66 | } |
| 67 | } |
| 68 | } |
| 69 | for _, block := range blocks(content) { |
| 70 | switch block.kind { |
| 71 | case "pre" , "def" : |
| 72 | case "ul" , "ol" : |
| 73 | for _, item := range block.items { |
| 74 | visit(item) |
| 75 | } |
| 76 | default: |
| 77 | visit(block.text) |
| 78 | } |
| 79 | } |
| 80 | return links |
| 81 | } |
| 82 | |
| 83 | // RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced |
| 84 | // code, bullet and numbered lists, pipe tables, emphasis, strong, code spans, |
| 85 | // links, [[wikilinks]] and reference-style links. Everything else is shown as |
| 86 | // escaped text. Link targets are limited to http, https, mailto, nostr and |
| 87 | // site-relative paths. |
| 88 | func RenderHTML(content string) template.HTML { |
| 89 | return RenderHTMLWith(content, Options{}) |
| 90 | } |
| 91 | |
| 92 | // Options adjusts rendering. Block, when set, is offered every fenced code |
| 93 | // block with its language and source and returns markup to show in its |
| 94 | // place, or false to keep the code block. The relay uses it for custom |
| 95 | // views without this package knowing about them. |
| 96 | type Options struct { |
| 97 | Block func(lang, source string) (html string, ok bool) |
| 98 | } |
| 99 | |
| 100 | // RenderHTMLWith renders Djot the way RenderHTML does, with the options |
| 101 | // applied. |
| 102 | func RenderHTMLWith(content string, options Options) template.HTML { |
| 103 | refs := definitions(content) |
| 104 | var b strings.Builder |
| 105 | for _, block := range blocks(content) { |
| 106 | switch block.kind { |
| 107 | case "def" : |
| 108 | continue |
| 109 | case "pre" : |
| 110 | if options.Block != nil { |
| 111 | if html, ok := options.Block(block.lang, strings.TrimSuffix(block.text, "\n" )); ok { |
| 112 | b.WriteString(html) |
| 113 | continue |
| 114 | } |
| 115 | } |
| 116 | b.WriteString("<pre><code" ) |
| 117 | if block.lang != "" { |
| 118 | b.WriteString(` data-lang="` + template.HTMLEscapeString(block.lang) + `"` ) |
| 119 | } |
| 120 | b.WriteString(">" + template.HTMLEscapeString(block.text) + "</code></pre>\n" ) |
| 121 | case "heading" : |
| 122 | level := string(rune('0' + block.level)) |
| 123 | b.WriteString("<h" + level + ">" + renderInline(block.text, refs) + "</h" + level + ">\n" ) |
| 124 | case "ul" , "ol" : |
| 125 | b.WriteString("<" + block.kind + ">\n" ) |
| 126 | for _, item := range block.items { |
| 127 | b.WriteString("<li>" + renderInline(item, refs) + "</li>\n" ) |
| 128 | } |
| 129 | b.WriteString("</" + block.kind + ">\n" ) |
| 130 | case "table" : |
| 131 | b.WriteString("<table>\n" ) |
| 132 | for i, row := range block.rows { |
| 133 | cell := "td" |
| 134 | if i < block.level { |
| 135 | cell = "th" |
| 136 | } |
| 137 | if i == 0 && block.level > 0 { |
| 138 | b.WriteString("<thead>\n" ) |
| 139 | } |
| 140 | if i == block.level { |
| 141 | if block.level > 0 { |
| 142 | b.WriteString("</thead>\n" ) |
| 143 | } |
| 144 | b.WriteString("<tbody>\n" ) |
| 145 | } |
| 146 | b.WriteString("<tr>" ) |
| 147 | for _, text := range row { |
| 148 | b.WriteString("<" + cell + ">" + renderInline(text, refs) + "</" + cell + ">" ) |
| 149 | } |
| 150 | b.WriteString("</tr>\n" ) |
| 151 | } |
| 152 | if block.level >= len(block.rows) { |
| 153 | b.WriteString("</thead>\n" ) |
| 154 | } else { |
| 155 | b.WriteString("</tbody>\n" ) |
| 156 | } |
| 157 | b.WriteString("</table>\n" ) |
| 158 | default: |
| 159 | b.WriteString("<p>" + renderInline(block.text, refs) + "</p>\n" ) |
| 160 | } |
| 161 | } |
| 162 | return template.HTML(b.String()) |
| 163 | } |
| 164 | |
| 165 | type block struct { |
| 166 | kind string |
| 167 | level int |
| 168 | lang string |
| 169 | text string |
| 170 | items []string |
| 171 | rows [][]string // table rows; level counts the header rows |
| 172 | } |
| 173 | |
| 174 | // definitions collects reference link definitions, "[label]: target", keyed |
| 175 | // by their lowercase label. |
| 176 | func definitions(content string) map[string]string { |
| 177 | refs := map[string]string{} |
| 178 | for _, block := range blocks(content) { |
| 179 | if block.kind == "def" { |
| 180 | refs[strings.ToLower(block.lang)] = block.text |
| 181 | } |
| 182 | } |
| 183 | return refs |
| 184 | } |
| 185 | |
| 186 | func blocks(content string) []block { |
| 187 | lines := strings.Split(strings.ReplaceAll(content, "\r\n" , "\n" ), "\n" ) |
| 188 | var out []block |
| 189 | var para []string |
| 190 | flush := func() { |
| 191 | if len(para) > 0 { |
| 192 | out = append(out, block{kind: "p" , text: strings.Join(para, "\n" )}) |
| 193 | para = nil |
| 194 | } |
| 195 | } |
| 196 | for i := 0; i < len(lines); i++ { |
| 197 | trimmed := strings.TrimSpace(lines[i]) |
| 198 | if trimmed == "" { |
| 199 | flush() |
| 200 | continue |
| 201 | } |
| 202 | if fence := trimmed[:min(3, len(trimmed))]; fence == "```" || fence == "~~~" { |
| 203 | flush() |
| 204 | run := len(trimmed) - len(strings.TrimLeft(trimmed, fence[:1])) |
| 205 | lang := strings.ToLower(append(strings.Fields(trimmed[run:]), "" )[0]) |
| 206 | var body []string |
| 207 | for i++; i < len(lines); i++ { |
| 208 | if t := strings.TrimSpace(lines[i]); len(t) >= run && strings.Trim(t, fence[:1]) == "" { |
| 209 | break |
| 210 | } |
| 211 | body = append(body, lines[i]) |
| 212 | } |
| 213 | out = append(out, block{kind: "pre" , lang: lang, text: strings.Join(body, "\n" ) + "\n" }) |
| 214 | continue |
| 215 | } |
| 216 | if level := headingLevel(trimmed); level > 0 { |
| 217 | flush() |
| 218 | out = append(out, block{kind: "heading" , level: level, text: strings.TrimSpace(trimmed[level:])}) |
| 219 | continue |
| 220 | } |
| 221 | if label, target, ok := definitionLine(trimmed); ok && len(para) == 0 { |
| 222 | out = append(out, block{kind: "def" , lang: label, text: target}) |
| 223 | continue |
| 224 | } |
| 225 | if tableRow(trimmed) { |
| 226 | // A table interrupts a paragraph, the way Djot block starts do. |
| 227 | flush() |
| 228 | var rows [][]string |
| 229 | header := 0 |
| 230 | for ; i < len(lines); i++ { |
| 231 | current := strings.TrimSpace(lines[i]) |
| 232 | if !tableRow(current) { |
| 233 | break |
| 234 | } |
| 235 | if separatorRow(current) { |
| 236 | // A separator marks every row above it as a header. |
| 237 | if header == 0 { |
| 238 | header = len(rows) |
| 239 | } |
| 240 | continue |
| 241 | } |
| 242 | rows = append(rows, tableCells(current)) |
| 243 | } |
| 244 | i-- |
| 245 | if len(rows) > 0 { |
| 246 | out = append(out, block{kind: "table" , level: header, rows: rows}) |
| 247 | } |
| 248 | continue |
| 249 | } |
| 250 | if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 { |
| 251 | var items []string |
| 252 | for ; i < len(lines); i++ { |
| 253 | current := strings.TrimSpace(lines[i]) |
| 254 | itemKind, rest := listMarker(current) |
| 255 | if itemKind != kind { |
| 256 | break |
| 257 | } |
| 258 | items = append(items, rest) |
| 259 | } |
| 260 | i-- |
| 261 | out = append(out, block{kind: kind, items: items}) |
| 262 | continue |
| 263 | } |
| 264 | para = append(para, trimmed) |
| 265 | } |
| 266 | flush() |
| 267 | return out |
| 268 | } |
| 269 | |
| 270 | // tableRow reports a Djot pipe table row: a line that starts and ends with |
| 271 | // a pipe. |
| 272 | func tableRow(line string) bool { |
| 273 | return len(line) >= 2 && strings.HasPrefix(line, "|" ) && strings.HasSuffix(line, "|" ) |
| 274 | } |
| 275 | |
| 276 | // separatorRow reports the header separator, cells of dashes with optional |
| 277 | // alignment colons, such as |---|:--:|. |
| 278 | func separatorRow(line string) bool { |
| 279 | cells := tableCells(line) |
| 280 | if len(cells) == 0 { |
| 281 | return false |
| 282 | } |
| 283 | for _, cell := range cells { |
| 284 | if strings.Trim(cell, "-" ) == "" && strings.Contains(cell, "-" ) { |
| 285 | continue |
| 286 | } |
| 287 | if strings.Trim(strings.TrimSuffix(strings.TrimPrefix(cell, ":" ), ":" ), "-" ) == "" && strings.Contains(cell, "-" ) { |
| 288 | continue |
| 289 | } |
| 290 | return false |
| 291 | } |
| 292 | return true |
| 293 | } |
| 294 | |
| 295 | // tableCells splits a row into trimmed cells; a backslash escapes a pipe |
| 296 | // inside a cell. |
| 297 | func tableCells(line string) []string { |
| 298 | inner := strings.TrimSuffix(strings.TrimPrefix(line, "|" ), "|" ) |
| 299 | var cells []string |
| 300 | var current strings.Builder |
| 301 | for i := 0; i < len(inner); i++ { |
| 302 | switch { |
| 303 | case inner[i] == '\\' && i+1 < len(inner) && inner[i+1] == '|' : |
| 304 | current.WriteByte('|' ) |
| 305 | i++ |
| 306 | case inner[i] == '|' : |
| 307 | cells = append(cells, strings.TrimSpace(current.String())) |
| 308 | current.Reset() |
| 309 | default: |
| 310 | current.WriteByte(inner[i]) |
| 311 | } |
| 312 | } |
| 313 | cells = append(cells, strings.TrimSpace(current.String())) |
| 314 | return cells |
| 315 | } |
| 316 | |
| 317 | func headingLevel(line string) int { |
| 318 | level := len(line) - len(strings.TrimLeft(line, "#" )) |
| 319 | if level == 0 || level > 6 || level >= len(line) || line[level] != ' ' { |
| 320 | return 0 |
| 321 | } |
| 322 | return level |
| 323 | } |
| 324 | |
| 325 | func definitionLine(line string) (label, target string, ok bool) { |
| 326 | end := strings.Index(line, "]:" ) |
| 327 | if !strings.HasPrefix(line, "[" ) || end < 1 { |
| 328 | return "" , "" , false |
| 329 | } |
| 330 | target = strings.TrimSpace(line[end+2:]) |
| 331 | return strings.TrimSpace(line[1:end]), target, target != "" && !strings.ContainsAny(target, " \t" ) |
| 332 | } |
| 333 | |
| 334 | func listMarker(line string) (kind, rest string) { |
| 335 | if len(line) > 1 && (line[0] == '-' || line[0] == '*' || line[0] == '+' ) && line[1] == ' ' { |
| 336 | return "ul" , strings.TrimSpace(line[2:]) |
| 337 | } |
| 338 | digits := len(line) - len(strings.TrimLeft(line, "0123456789" )) |
| 339 | if digits > 0 && digits+1 < len(line) && (line[digits] == '.' || line[digits] == ')' ) && line[digits+1] == ' ' { |
| 340 | return "ol" , strings.TrimSpace(line[digits+2:]) |
| 341 | } |
| 342 | return "" , "" |
| 343 | } |
| 344 | |
| 345 | type span struct { |
| 346 | kind string // text, code, em, strong, link, wikilink, reference |
| 347 | text, target string |
| 348 | } |
| 349 | |
| 350 | // inlineSpans splits paragraph text into literal runs and recognized inline |
| 351 | // elements. It never nests: the text inside a link or emphasis is rendered |
| 352 | // as further spans by the caller. |
| 353 | func inlineSpans(text string) []span { |
| 354 | var spans []span |
| 355 | var literal strings.Builder |
| 356 | flush := func() { |
| 357 | if literal.Len() > 0 { |
| 358 | spans = append(spans, span{kind: "text" , text: literal.String()}) |
| 359 | literal.Reset() |
| 360 | } |
| 361 | } |
| 362 | for i := 0; i < len(text); { |
| 363 | c := text[i] |
| 364 | switch { |
| 365 | case c == '\\' && i+1 < len(text): |
| 366 | literal.WriteByte(text[i+1]) |
| 367 | i += 2 |
| 368 | continue |
| 369 | case c == '`' : |
| 370 | run := strings.TrimLeft(text[i:], "`" ) |
| 371 | marker := text[i : len(text)-len(run)] |
| 372 | if end := strings.Index(run, marker); end >= 0 { |
| 373 | flush() |
| 374 | spans = append(spans, span{kind: "code" , text: strings.TrimSpace(run[:end])}) |
| 375 | i += 2*len(marker) + end |
| 376 | continue |
| 377 | } |
| 378 | case c == '*' || c == '_' : |
| 379 | if end := emphasisEnd(text, i); end > 0 { |
| 380 | flush() |
| 381 | kind := "em" |
| 382 | if c == '*' { |
| 383 | kind = "strong" |
| 384 | } |
| 385 | spans = append(spans, span{kind: kind, text: text[i+1 : end]}) |
| 386 | i = end + 1 |
| 387 | continue |
| 388 | } |
| 389 | case c == '[' && strings.HasPrefix(text[i:], "[[" ): |
| 390 | if end := strings.Index(text[i:], "]]" ); end > 2 { |
| 391 | flush() |
| 392 | target, label, _ := strings.Cut(text[i+2:i+end], "|" ) |
| 393 | spans = append(spans, span{kind: "wikilink" , target: strings.TrimSpace(target), text: strings.TrimSpace(label)}) |
| 394 | i += end + 2 |
| 395 | continue |
| 396 | } |
| 397 | case c == '[' : |
| 398 | if s, n := linkAt(text[i:]); n > 0 { |
| 399 | flush() |
| 400 | spans = append(spans, s) |
| 401 | i += n |
| 402 | continue |
| 403 | } |
| 404 | } |
| 405 | literal.WriteByte(c) |
| 406 | i++ |
| 407 | } |
| 408 | flush() |
| 409 | return spans |
| 410 | } |
| 411 | |
| 412 | // emphasisEnd finds the closing marker for Djot emphasis: the opener is not |
| 413 | // followed by whitespace and the closer is not preceded by it. |
| 414 | func emphasisEnd(text string, start int) int { |
| 415 | marker := text[start] |
| 416 | if start+1 >= len(text) || text[start+1] == ' ' || text[start+1] == '\n' { |
| 417 | return 0 |
| 418 | } |
| 419 | for i := start + 2; i < len(text); i++ { |
| 420 | if text[i] == marker && text[i-1] != ' ' && text[i-1] != '\n' { |
| 421 | return i |
| 422 | } |
| 423 | } |
| 424 | return 0 |
| 425 | } |
| 426 | |
| 427 | // linkAt recognizes [text](target), [text][label] and [text][] at the start |
| 428 | // of the string and reports how many bytes it consumed. |
| 429 | func linkAt(text string) (span, int) { |
| 430 | depth, end := 0, -1 |
| 431 | for i := 0; i < len(text); i++ { |
| 432 | if text[i] == '[' { |
| 433 | depth++ |
| 434 | } else if text[i] == ']' { |
| 435 | if depth--; depth == 0 { |
| 436 | end = i |
| 437 | break |
| 438 | } |
| 439 | } |
| 440 | } |
| 441 | if end <= 0 { |
| 442 | return span{}, 0 |
| 443 | } |
| 444 | label := text[1:end] |
| 445 | rest := text[end+1:] |
| 446 | if strings.HasPrefix(rest, "(" ) { |
| 447 | if close := strings.IndexByte(rest, ')' ); close > 0 { |
| 448 | return span{kind: "link" , text: label, target: strings.TrimSpace(rest[1:close])}, end + 2 + close |
| 449 | } |
| 450 | } |
| 451 | if strings.HasPrefix(rest, "[" ) { |
| 452 | if close := strings.IndexByte(rest, ']' ); close > 0 { |
| 453 | target := strings.TrimSpace(rest[1:close]) |
| 454 | if target == "" { |
| 455 | target = label |
| 456 | } |
| 457 | return span{kind: "reference" , text: label, target: target}, end + 2 + close |
| 458 | } |
| 459 | } |
| 460 | return span{}, 0 |
| 461 | } |
| 462 | |
| 463 | func renderInline(text string, refs map[string]string) string { |
| 464 | var b strings.Builder |
| 465 | for _, s := range inlineSpans(text) { |
| 466 | switch s.kind { |
| 467 | case "text" : |
| 468 | b.WriteString(template.HTMLEscapeString(s.text)) |
| 469 | case "code" : |
| 470 | b.WriteString("<code>" + template.HTMLEscapeString(s.text) + "</code>" ) |
| 471 | case "em" , "strong" : |
| 472 | b.WriteString("<" + s.kind + ">" + renderInline(s.text, refs) + "</" + s.kind + ">" ) |
| 473 | case "wikilink" : |
| 474 | label := s.text |
| 475 | if label == "" { |
| 476 | label = s.target |
| 477 | } |
| 478 | b.WriteString(anchor("/wiki/" +url.PathEscape(Normalize(s.target)), renderInline(label, refs))) |
| 479 | case "link" : |
| 480 | b.WriteString(anchor(safeHref(s.target), renderInline(s.text, refs))) |
| 481 | case "reference" : |
| 482 | href := "/wiki/" + url.PathEscape(Normalize(s.target)) |
| 483 | if def, ok := refs[strings.ToLower(s.target)]; ok { |
| 484 | href = safeHref(def) |
| 485 | } |
| 486 | b.WriteString(anchor(href, renderInline(s.text, refs))) |
| 487 | } |
| 488 | } |
| 489 | return b.String() |
| 490 | } |
| 491 | |
| 492 | func anchor(href, inner string) string { |
| 493 | if href == "" { |
| 494 | return inner |
| 495 | } |
| 496 | return `<a href="` + template.HTMLEscapeString(href) + `">` + inner + `</a>` |
| 497 | } |
| 498 | |
| 499 | // safeHref keeps http, https, mailto and site-relative targets, routes |
| 500 | // nostr: links through the relay's open page and drops everything else. |
| 501 | func safeHref(target string) string { |
| 502 | target = strings.TrimSpace(target) |
| 503 | lower := strings.ToLower(target) |
| 504 | switch { |
| 505 | case strings.HasPrefix(lower, "nostr:" ): |
| 506 | return "/open?target=" + url.QueryEscape(target) |
| 507 | case strings.HasPrefix(lower, "mailto:" ): |
| 508 | return target |
| 509 | case strings.HasPrefix(lower, "http://" ), strings.HasPrefix(lower, "https://" ): |
| 510 | if u, err := url.Parse(target); err == nil && u.Host != "" { |
| 511 | return target |
| 512 | } |
| 513 | case strings.HasPrefix(target, "/" ) && !strings.HasPrefix(target, "//" ) && !strings.HasPrefix(target, "/\\" ): |
| 514 | return target |
| 515 | } |
| 516 | return "" |
| 517 | } |