Ronbun/cmd/web/kaku/render.go

294 lines
7.5 KiB
Go

package kaku
import (
"html"
"html/template"
"strconv"
"strings"
)
func (document *Document) Render() template.HTML {
var b strings.Builder
render(&b, document.Children)
return template.HTML(b.String())
}
// Loops over nodes of the document's children to detect node types and render them in HTML
func render(b *strings.Builder, nodes []Node) {
for _, n := range nodes {
switch v := n.(type) {
case *Heading:
id := toKebabCase(plainText(v.Children))
tagName := "h" + strconv.Itoa(v.Level)
b.WriteString("<")
b.WriteString(tagName)
b.WriteString(` id="`)
b.WriteString(id)
b.WriteString(`">`)
render(b, v.Children)
b.WriteString(`<a href="#`)
b.WriteString(id)
b.WriteString(`" aria-label="`)
b.WriteString(html.EscapeString(plainText(v.Children)))
b.WriteString(` permalink" class="heading-anchor" title="Link to this heading">#</a></`)
b.WriteString(tagName)
b.WriteString(">")
case *Paragraph:
wrap(b, "p", v.Children)
case *Bold:
wrap(b, "strong", v.Children)
case *Em:
wrap(b, "em", v.Children)
case *Strike:
wrap(b, "del", v.Children)
case *Code:
b.WriteString("<code>")
b.WriteString(html.EscapeString(v.Value))
b.WriteString("</code>")
case *Text:
b.WriteString(html.EscapeString(v.Value))
case *HR:
b.WriteString("<hr>")
case *CodeBlock:
b.WriteString("<pre><code")
if v.Language != "" {
b.WriteString(` class="language-`)
b.WriteString(html.EscapeString(v.Language))
b.WriteString(`"`)
}
b.WriteString(">")
b.WriteString(html.EscapeString(v.Content))
b.WriteString("</code></pre>")
case *List:
b.WriteString("<")
b.WriteString(v.Kind)
b.WriteString(">")
for _, item := range v.Items {
b.WriteString("<li>")
render(b, item.Children)
b.WriteString("</li>")
}
b.WriteString("</")
b.WriteString(v.Kind)
b.WriteString(">")
case *DefList:
b.WriteString("<dl>")
for _, item := range v.Items {
b.WriteString("<dt>")
render(b, item.Term)
b.WriteString("</dt><dd>")
render(b, item.Definition)
b.WriteString("</dd>")
}
b.WriteString("</dl>")
case *Quote:
b.WriteString("<figure>")
if v.Link != "" {
b.WriteString(`<blockquote cite="`)
b.WriteString(html.EscapeString(safeUrl(v.Link)))
b.WriteString(`">`)
} else {
b.WriteString("<blockquote>")
}
render(b, v.Text)
b.WriteString("</blockquote>")
if v.Author != "" || v.Source != "" {
b.WriteString("<figcaption>—")
b.WriteString(html.EscapeString(v.Author))
if v.Source != "" {
b.WriteString(", ")
if v.Link != "" {
b.WriteString(`<a href="`)
b.WriteString(html.EscapeString(safeUrl(v.Link)))
b.WriteString(`">`)
b.WriteString(html.EscapeString(v.Source))
b.WriteString("</a>")
} else {
b.WriteString(html.EscapeString(v.Source))
}
}
b.WriteString("</figcaption>")
}
b.WriteString("</figure>")
case *Link:
b.WriteString(`<a href="`)
b.WriteString(html.EscapeString(safeUrl(v.URL)))
b.WriteString(`"`)
if v.Label != "" {
b.WriteString(` aria-label="`)
b.WriteString(html.EscapeString(v.Label))
b.WriteString(`"`)
}
if v.Title != "" {
b.WriteString(` title="`)
b.WriteString(html.EscapeString(v.Title))
b.WriteString(`"`)
}
b.WriteString(">")
render(b, v.Text)
b.WriteString("</a>")
case *Image:
url := imageURL(v.URL)
b.WriteString("<figure>")
b.WriteString(`<img src="`)
b.WriteString(html.EscapeString(safeUrl(url)))
b.WriteString(`" alt="`)
b.WriteString(html.EscapeString(safeUrl(v.Alt)))
b.WriteString(`">`)
b.WriteString("<figcaption>")
if len(v.Caption) > 0 {
render(b, v.Caption)
b.WriteString(` | `)
}
b.WriteString(`<small><a href="/uploaded/`)
b.WriteString(html.EscapeString(safeUrl(v.URL)))
b.WriteString(`">Full size</a></small>`)
b.WriteString("</figcaption>")
b.WriteString("</figure>")
case *Video:
b.WriteString("<figure>")
tag := `<video controls="" preload="metadata" src="/uploaded/`
if v.Autoplay {
tag = `<video autoplay="true" playsinline="true" loop="true" muted="true" preload="metadata" src="/uploaded/`
}
b.WriteString(tag)
b.WriteString(html.EscapeString(safeUrl(v.URL)))
b.WriteString(`" type="video/mp4"></video>`)
if len(v.Caption) > 0 {
b.WriteString("<figcaption>")
render(b, v.Caption)
b.WriteString("</figcaption>")
}
b.WriteString("</figure>")
case *Audio:
b.WriteString(`<audio controls="" preload="metadata" src="/uploaded/`)
b.WriteString(html.EscapeString(safeUrl(v.URL)))
b.WriteString(`" type="audio/mpeg"></audio>`)
case *Aside:
b.WriteString("<aside><p>Side note</p>")
render(b, v.Children)
b.WriteString("</aside>")
case *Details:
b.WriteString("<details><summary>")
render(b, v.Summary)
b.WriteString("</summary>")
render(b, v.Content)
b.WriteString("</details>")
case *TodoList:
b.WriteString("<ul>")
for _, item := range v.Items {
label := ""
switch item.State {
case ' ':
label = "Opened task"
case '@':
label = "Ongoing task"
case '~':
label = "Paused or abandonned task"
case 'x':
label = "Completed task"
}
b.WriteString(`<li><code aria-hidden="true">[`)
b.WriteByte(item.State)
b.WriteString(`]</code><span class="visually-hidden">`)
b.WriteString(label)
b.WriteString("</span> ")
render(b, item.Children)
b.WriteString("</li>")
}
b.WriteString("</ul>")
case *RawHTML:
b.WriteString(v.Content)
}
}
}
// Wraps each node from render into its html tag, then calls render again for possible nested tags
func wrap(b *strings.Builder, tag string, children []Node) {
b.WriteString("<")
b.WriteString(tag)
b.WriteString(">")
render(b, children)
b.WriteString("</")
b.WriteString(tag)
b.WriteString(">")
}
// Sanitizes urls for href and src attributes
func safeUrl(url string) string {
trimmed := strings.TrimSpace(url)
scheme, _, found := strings.Cut(trimmed, ":")
if !found {
return trimmed
}
switch strings.ToLower(scheme) {
case "javascript", "data", "vbscript", "file":
return ""
default:
return trimmed
}
}
// Flattens inline nodes down to their visible text, ignoring markup like Bold/Em/Strike/Code. Used to create IDs or Aria Label text
func plainText(nodes []Node) string {
var b strings.Builder
for _, n := range nodes {
switch v := n.(type) {
case *Text:
b.WriteString(v.Value)
case *Code:
b.WriteString(v.Value)
case *Bold:
b.WriteString(plainText(v.Children))
case *Em:
b.WriteString(plainText(v.Children))
case *Strike:
b.WriteString(plainText(v.Children))
case *Link:
b.WriteString(plainText(v.Text))
}
}
return b.String()
}
// Converts text into a lowercase hyphen-separated slug
func toKebabCase(text string) string {
var b strings.Builder
lastWasHyphen := true // avoid a leading hyphen
for _, r := range strings.ToLower(text) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
lastWasHyphen = false
continue
}
if !lastWasHyphen {
b.WriteByte('-')
lastWasHyphen = true
}
}
return strings.TrimRight(b.String(), "-")
}
// Allows prefixing local/relative image paths and normalizes ".jpeg" extension to ".jpg"
// Disabled in this version
func imageURL(url string) string {
if strings.HasPrefix(url, "http://") || strings.HasPrefix(url, "https://") {
return url
}
if strings.HasSuffix(url, ".jpeg") {
url = strings.TrimSuffix(url, ".jpeg") + ".jpg"
}
return "/processed/" + url
}