297 lines
7.4 KiB
Go
297 lines
7.4 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="`)
|
|
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="`
|
|
if v.Autoplay {
|
|
tag = `<video autoplay="true" playsinline="true" loop="true" muted="true" preload="metadata" src="`
|
|
}
|
|
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="`)
|
|
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
|
|
|
|
return url
|
|
}
|