clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

feat/standalone-relay
tinyrelay / internal / wiki / wiki.go
wiki.go 14.3K
1package wiki23import (4 "html/template"5 "net/url"6 "strings"7 "unicode"8)910// Normalize turns a title into the article's d tag: lowercase, whitespace as11// 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 []rune15 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}2728// Links lists the targets referenced from Djot content in order of first29// appearance: normalized article names for wikilinks and reference-style30// 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 []string35 add := func(target string) {36 if target != "" && !seen[target] {37 seen[target] = true38 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 links81}8283// RenderHTML renders a subset of Djot: ATX headings, paragraphs, fenced84// code, bullet and numbered lists, pipe tables, emphasis, strong, code spans,85// links, [[wikilinks]] and reference-style links. Everything else is shown as86// escaped text. Link targets are limited to http, https, mailto, nostr and87// site-relative paths.88func RenderHTML(content string) template.HTML {89 return RenderHTMLWith(content, Options{})90}9192// Options adjusts rendering. Block, when set, is offered every fenced code93// block with its language and source and returns markup to show in its94// place, or false to keep the code block. The relay uses it for custom95// views without this package knowing about them.96type Options struct {97 Block func(lang, source string) (html string, ok bool)98}99100// RenderHTMLWith renders Djot the way RenderHTML does, with the options101// applied.102func RenderHTMLWith(content string, options Options) template.HTML {103 refs := definitions(content)104 var b strings.Builder105 for _, block := range blocks(content) {106 switch block.kind {107 case "def":108 continue109 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 continue114 }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}164165type block struct {166 kind string167 level int168 lang string169 text string170 items []string171 rows [][]string // table rows; level counts the header rows172}173174// definitions collects reference link definitions, "[label]: target", keyed175// 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.text181 }182 }183 return refs184}185186func blocks(content string) []block {187 lines := strings.Split(strings.ReplaceAll(content, "\r\n", "\n"), "\n")188 var out []block189 var para []string190 flush := func() {191 if len(para) > 0 {192 out = append(out, block{kind: "p", text: strings.Join(para, "\n")})193 para = nil194 }195 }196 for i := 0; i < len(lines); i++ {197 trimmed := strings.TrimSpace(lines[i])198 if trimmed == "" {199 flush()200 continue201 }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 []string207 for i++; i < len(lines); i++ {208 if t := strings.TrimSpace(lines[i]); len(t) >= run && strings.Trim(t, fence[:1]) == "" {209 break210 }211 body = append(body, lines[i])212 }213 out = append(out, block{kind: "pre", lang: lang, text: strings.Join(body, "\n") + "\n"})214 continue215 }216 if level := headingLevel(trimmed); level > 0 {217 flush()218 out = append(out, block{kind: "heading", level: level, text: strings.TrimSpace(trimmed[level:])})219 continue220 }221 if label, target, ok := definitionLine(trimmed); ok && len(para) == 0 {222 out = append(out, block{kind: "def", lang: label, text: target})223 continue224 }225 if tableRow(trimmed) {226 // A table interrupts a paragraph, the way Djot block starts do.227 flush()228 var rows [][]string229 header := 0230 for ; i < len(lines); i++ {231 current := strings.TrimSpace(lines[i])232 if !tableRow(current) {233 break234 }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 continue241 }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 continue249 }250 if kind, _ := listMarker(trimmed); kind != "" && len(para) == 0 {251 var items []string252 for ; i < len(lines); i++ {253 current := strings.TrimSpace(lines[i])254 itemKind, rest := listMarker(current)255 if itemKind != kind {256 break257 }258 items = append(items, rest)259 }260 i--261 out = append(out, block{kind: kind, items: items})262 continue263 }264 para = append(para, trimmed)265 }266 flush()267 return out268}269270// tableRow reports a Djot pipe table row: a line that starts and ends with271// a pipe.272func tableRow(line string) bool {273 return len(line) >= 2 && strings.HasPrefix(line, "|") && strings.HasSuffix(line, "|")274}275276// separatorRow reports the header separator, cells of dashes with optional277// alignment colons, such as |---|:--:|.278func separatorRow(line string) bool {279 cells := tableCells(line)280 if len(cells) == 0 {281 return false282 }283 for _, cell := range cells {284 if strings.Trim(cell, "-") == "" && strings.Contains(cell, "-") {285 continue286 }287 if strings.Trim(strings.TrimSuffix(strings.TrimPrefix(cell, ":"), ":"), "-") == "" && strings.Contains(cell, "-") {288 continue289 }290 return false291 }292 return true293}294295// tableCells splits a row into trimmed cells; a backslash escapes a pipe296// inside a cell.297func tableCells(line string) []string {298 inner := strings.TrimSuffix(strings.TrimPrefix(line, "|"), "|")299 var cells []string300 var current strings.Builder301 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 cells315}316317func 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 0321 }322 return level323}324325func definitionLine(line string) (label, target string, ok bool) {326 end := strings.Index(line, "]:")327 if !strings.HasPrefix(line, "[") || end < 1 {328 return "", "", false329 }330 target = strings.TrimSpace(line[end+2:])331 return strings.TrimSpace(line[1:end]), target, target != "" && !strings.ContainsAny(target, " \t")332}333334func 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}344345type span struct {346 kind string // text, code, em, strong, link, wikilink, reference347 text, target string348}349350// inlineSpans splits paragraph text into literal runs and recognized inline351// elements. It never nests: the text inside a link or emphasis is rendered352// as further spans by the caller.353func inlineSpans(text string) []span {354 var spans []span355 var literal strings.Builder356 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 += 2368 continue369 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) + end376 continue377 }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 + 1387 continue388 }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 + 2395 continue396 }397 case c == '[':398 if s, n := linkAt(text[i:]); n > 0 {399 flush()400 spans = append(spans, s)401 i += n402 continue403 }404 }405 literal.WriteByte(c)406 i++407 }408 flush()409 return spans410}411412// emphasisEnd finds the closing marker for Djot emphasis: the opener is not413// 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 0418 }419 for i := start + 2; i < len(text); i++ {420 if text[i] == marker && text[i-1] != ' ' && text[i-1] != '\n' {421 return i422 }423 }424 return 0425}426427// linkAt recognizes [text](target), [text][label] and [text][] at the start428// of the string and reports how many bytes it consumed.429func linkAt(text string) (span, int) {430 depth, end := 0, -1431 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 = i437 break438 }439 }440 }441 if end <= 0 {442 return span{}, 0443 }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 + close449 }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 = label456 }457 return span{kind: "reference", text: label, target: target}, end + 2 + close458 }459 }460 return span{}, 0461}462463func renderInline(text string, refs map[string]string) string {464 var b strings.Builder465 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.text475 if label == "" {476 label = s.target477 }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}491492func anchor(href, inner string) string {493 if href == "" {494 return inner495 }496 return `<a href="` + template.HTMLEscapeString(href) + `">` + inner + `</a>`497}498499// safeHref keeps http, https, mailto and site-relative targets, routes500// 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 target509 case strings.HasPrefix(lower, "http://"), strings.HasPrefix(lower, "https://"):510 if u, err := url.Parse(target); err == nil && u.Host != "" {511 return target512 }513 case strings.HasPrefix(target, "/") && !strings.HasPrefix(target, "//") && !strings.HasPrefix(target, "/\\"):514 return target515 }516 return ""517}