tinyrelayfile
tinyrelay/internal/wiki/wiki.go/

internal/wiki/wiki.go raw
1package wiki
2
3import (
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.
13func 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.
31func 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.
88func 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.
96type 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.
102func 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
165type 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.
176func 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
186func 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.
272func 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 |---|:--:|.
278func 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.
297func 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
317func 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
325func 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
334func 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
345type 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.
353func 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.
414func 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.
429func 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
463func 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
492func 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.
501func 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}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs