797 lines
21 KiB
Go
797 lines
21 KiB
Go
package render
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"git.dev.alexdunmow.com/block/pluginsdk/blocks"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// BlockNoteToHTML converts a BlockNote document (map with "blocks" key) to HTML.
|
|
func BlockNoteToHTML(ctx context.Context, doc map[string]any) string {
|
|
rawBlocks := blocksFromRaw(doc["blocks"])
|
|
if len(rawBlocks) == 0 {
|
|
return ""
|
|
}
|
|
return renderBlocks(ctx, rawBlocks)
|
|
}
|
|
|
|
func renderBlocks(ctx context.Context, blocks []map[string]any) string {
|
|
var sb strings.Builder
|
|
var currentListType string
|
|
var listItems []map[string]any
|
|
|
|
flushList := func() {
|
|
if len(listItems) == 0 {
|
|
return
|
|
}
|
|
tag := "ul"
|
|
listStyle := "list-disc"
|
|
if currentListType == "numberedListItem" {
|
|
tag = "ol"
|
|
listStyle = "list-decimal"
|
|
}
|
|
fmt.Fprintf(&sb, "<%s class=\"my-4 pl-6 space-y-2 %s\">\n", tag, listStyle)
|
|
for _, item := range listItems {
|
|
content := inlineContentFromRaw(item["content"])
|
|
childrenHTML := renderChildren(ctx, item["children"])
|
|
sb.WriteString("<li>")
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
sb.WriteString("</li>\n")
|
|
}
|
|
fmt.Fprintf(&sb, "</%s>\n", tag)
|
|
listItems = nil
|
|
currentListType = ""
|
|
}
|
|
|
|
for _, blockMap := range blocks {
|
|
blockType, _ := blockMap["type"].(string)
|
|
|
|
if blockType == "bulletListItem" || blockType == "numberedListItem" {
|
|
if currentListType != "" && currentListType != blockType {
|
|
flushList()
|
|
}
|
|
currentListType = blockType
|
|
listItems = append(listItems, blockMap)
|
|
continue
|
|
}
|
|
|
|
flushList()
|
|
sb.WriteString(renderBlock(ctx, blockMap))
|
|
}
|
|
|
|
flushList()
|
|
return sb.String()
|
|
}
|
|
|
|
func inlineContentFromRaw(raw any) []map[string]any {
|
|
switch v := raw.(type) {
|
|
case []any:
|
|
items := make([]map[string]any, 0, len(v))
|
|
for _, item := range v {
|
|
if m, ok := item.(map[string]any); ok {
|
|
items = append(items, m)
|
|
}
|
|
}
|
|
return items
|
|
case []map[string]any:
|
|
return v
|
|
case string:
|
|
if v == "" {
|
|
return nil
|
|
}
|
|
return []map[string]any{{"type": "text", "text": v}}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func blocksFromRaw(raw any) []map[string]any {
|
|
switch v := raw.(type) {
|
|
case []any:
|
|
items := make([]map[string]any, 0, len(v))
|
|
for _, item := range v {
|
|
if m, ok := item.(map[string]any); ok {
|
|
items = append(items, m)
|
|
}
|
|
}
|
|
return items
|
|
case []map[string]any:
|
|
return v
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func textAlignClass(props map[string]any) string {
|
|
if props == nil {
|
|
return ""
|
|
}
|
|
align, _ := props["textAlignment"].(string)
|
|
switch align {
|
|
case "left":
|
|
return "text-left"
|
|
case "center":
|
|
return "text-center"
|
|
case "right":
|
|
return "text-right"
|
|
case "justify":
|
|
return "text-justify"
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func safeClassToken(value string) string {
|
|
for _, r := range value {
|
|
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-' {
|
|
continue
|
|
}
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func isHexDigit(c rune) bool {
|
|
return (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F')
|
|
}
|
|
|
|
func colorValueToClass(value string, prefix string) string {
|
|
if value == "" || value == "default" {
|
|
return ""
|
|
}
|
|
|
|
if after, ok := strings.CutPrefix(value, "hex:"); ok {
|
|
hex := after
|
|
if len(hex) != 7 && len(hex) != 4 {
|
|
return ""
|
|
}
|
|
for i, r := range hex {
|
|
if i == 0 && r == '#' {
|
|
continue
|
|
}
|
|
if !isHexDigit(r) {
|
|
return ""
|
|
}
|
|
}
|
|
return fmt.Sprintf("%s-[%s]", prefix, hex)
|
|
}
|
|
|
|
if after, ok := strings.CutPrefix(value, "custom:"); ok {
|
|
name := safeClassToken(after)
|
|
if name == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s-[hsl(var(--color-%s))]", prefix, name)
|
|
}
|
|
|
|
value = safeClassToken(value)
|
|
if value == "" {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("%s-%s", prefix, value)
|
|
}
|
|
|
|
func renderBlock(ctx context.Context, block map[string]any) string {
|
|
blockType, _ := block["type"].(string)
|
|
props, _ := block["props"].(map[string]any)
|
|
content := inlineContentFromRaw(block["content"])
|
|
childrenHTML := renderChildren(ctx, block["children"])
|
|
|
|
var sb strings.Builder
|
|
|
|
switch blockType {
|
|
case "paragraph":
|
|
classNames := "my-4"
|
|
if alignClass := textAlignClass(props); alignClass != "" {
|
|
classNames += " " + alignClass
|
|
}
|
|
fmt.Fprintf(&sb, "<p class=\"%s\">", classNames)
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
sb.WriteString("</p>\n")
|
|
|
|
case "heading":
|
|
level := 2
|
|
if l, ok := props["level"].(float64); ok {
|
|
level = int(l)
|
|
} else if l, ok := props["level"].(int); ok {
|
|
level = l
|
|
}
|
|
if level < 1 {
|
|
level = 1
|
|
}
|
|
if level > 6 {
|
|
level = 6
|
|
}
|
|
classNames := "mt-8 mb-4 font-bold"
|
|
if alignClass := textAlignClass(props); alignClass != "" {
|
|
classNames += " " + alignClass
|
|
}
|
|
fmt.Fprintf(&sb, "<h%d class=\"%s\">", level, classNames)
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
fmt.Fprintf(&sb, "</h%d>\n", level)
|
|
|
|
case "quote":
|
|
classNames := "my-4 border-l-4 border-border pl-4 text-muted-foreground"
|
|
if alignClass := textAlignClass(props); alignClass != "" {
|
|
classNames += " " + alignClass
|
|
}
|
|
fmt.Fprintf(&sb, "<blockquote class=\"%s\">", classNames)
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
sb.WriteString("</blockquote>\n")
|
|
|
|
case "checkListItem":
|
|
checked := false
|
|
if c, ok := props["checked"].(bool); ok {
|
|
checked = c
|
|
}
|
|
checkedAttr := ""
|
|
if checked {
|
|
checkedAttr = " checked"
|
|
}
|
|
fmt.Fprintf(&sb, `<div class="check-list-item my-2 flex items-start gap-2"><input type="checkbox" disabled%s><span>`, checkedAttr)
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
sb.WriteString("</span>")
|
|
if childrenHTML != "" {
|
|
fmt.Fprintf(&sb, `<div class="pl-6">%s</div>`, childrenHTML)
|
|
}
|
|
sb.WriteString("</div>\n")
|
|
|
|
case "toggleListItem":
|
|
openAttr := ""
|
|
if open, ok := props["open"].(bool); ok && open {
|
|
openAttr = " open"
|
|
}
|
|
fmt.Fprintf(&sb, `<details class="my-4"%s>`, openAttr)
|
|
sb.WriteString(`<summary class="cursor-pointer font-medium">`)
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
sb.WriteString("</summary>")
|
|
if childrenHTML != "" {
|
|
fmt.Fprintf(&sb, `<div class="pl-6 mt-2">%s</div>`, childrenHTML)
|
|
}
|
|
sb.WriteString("</details>\n")
|
|
|
|
case "codeBlock":
|
|
lang := ""
|
|
if l, ok := props["language"].(string); ok {
|
|
lang = l
|
|
}
|
|
fmt.Fprintf(&sb, `<pre class="my-4"><code class="language-%s">`, html.EscapeString(lang))
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
sb.WriteString("</code></pre>\n")
|
|
|
|
case "image":
|
|
url := ""
|
|
caption := ""
|
|
alt := ""
|
|
if u, ok := props["url"].(string); ok {
|
|
url = u
|
|
}
|
|
if c, ok := props["caption"].(string); ok {
|
|
caption = c
|
|
}
|
|
if a, ok := props["alt"].(string); ok {
|
|
alt = a
|
|
}
|
|
if alt == "" {
|
|
alt = caption
|
|
}
|
|
link := ""
|
|
if l, ok := props["link"].(string); ok {
|
|
link = l
|
|
}
|
|
width := ""
|
|
if w, ok := props["width"].(string); ok {
|
|
width = w
|
|
}
|
|
align := ""
|
|
if a, ok := props["align"].(string); ok {
|
|
align = a
|
|
}
|
|
attMode := ""
|
|
if m, ok := props["attributionMode"].(string); ok {
|
|
attMode = m
|
|
}
|
|
|
|
var att *blocks.MediaAttribution
|
|
if attMode == "chip" || attMode == "chipHover" || attMode == "below" {
|
|
if idStr, ok := props["mediaId"].(string); ok && idStr != "" {
|
|
if id, err := uuid.Parse(idStr); err == nil {
|
|
if resolver := blocks.GetMediaResolver(ctx); resolver != nil {
|
|
if mv := resolver.ResolveMedia(ctx, id); mv != nil {
|
|
att = mv.Attribution
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
chip := ""
|
|
switch attMode {
|
|
case "chip":
|
|
chip = buildAttributionHTML(att, imageChipClass)
|
|
case "chipHover":
|
|
chip = buildAttributionHTML(att, imageChipClass+" opacity-0 group-hover:opacity-100 focus-within:opacity-100 transition-opacity")
|
|
}
|
|
|
|
figureClass := "my-6"
|
|
switch width {
|
|
case "small":
|
|
figureClass += " max-w-[400px]"
|
|
case "medium":
|
|
figureClass += " max-w-[600px]"
|
|
case "wide":
|
|
figureClass += " max-w-[800px]"
|
|
}
|
|
if width == "small" || width == "medium" || width == "wide" {
|
|
switch align {
|
|
case "left":
|
|
figureClass += " float-left mr-6 mb-4"
|
|
case "right":
|
|
figureClass += " float-right ml-6 mb-4"
|
|
default:
|
|
figureClass += " mx-auto"
|
|
}
|
|
}
|
|
|
|
img := fmt.Sprintf(`<img src="%s" alt="%s" />`, html.EscapeString(url), html.EscapeString(alt))
|
|
if link = safeLinkURL(link); link != "" {
|
|
img = fmt.Sprintf(`<a href="%s">%s</a>`, html.EscapeString(link), img)
|
|
}
|
|
// The positioning wrapper only exists when a chip is rendered, so
|
|
// legacy and chip-less blocks keep today's exact markup.
|
|
if chip != "" {
|
|
wrapClass := "relative overflow-hidden"
|
|
if attMode == "chipHover" {
|
|
wrapClass = "group " + wrapClass
|
|
}
|
|
img = fmt.Sprintf(`<div class="%s">%s%s</div>`, wrapClass, img, chip)
|
|
}
|
|
|
|
fmt.Fprintf(&sb, `<figure class="%s">`, figureClass)
|
|
sb.WriteString(img)
|
|
if caption != "" {
|
|
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
|
}
|
|
if attMode == "below" {
|
|
if credit := buildAttributionHTML(att, ""); credit != "" {
|
|
fmt.Fprintf(&sb, `<div class="mt-1 text-xs text-muted-foreground text-center">%s</div>`, credit)
|
|
}
|
|
}
|
|
sb.WriteString("</figure>\n")
|
|
|
|
case "video":
|
|
url := ""
|
|
caption := ""
|
|
if u, ok := props["url"].(string); ok {
|
|
url = u
|
|
}
|
|
if c, ok := props["caption"].(string); ok {
|
|
caption = c
|
|
}
|
|
sb.WriteString(`<figure class="my-6">`)
|
|
fmt.Fprintf(&sb, `<video src="%s" controls></video>`, html.EscapeString(url))
|
|
if caption != "" {
|
|
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
|
}
|
|
sb.WriteString("</figure>\n")
|
|
|
|
case "audio":
|
|
url := ""
|
|
caption := ""
|
|
if u, ok := props["url"].(string); ok {
|
|
url = u
|
|
}
|
|
if c, ok := props["caption"].(string); ok {
|
|
caption = c
|
|
}
|
|
sb.WriteString(`<figure class="my-6">`)
|
|
fmt.Fprintf(&sb, `<audio src="%s" controls></audio>`, html.EscapeString(url))
|
|
if caption != "" {
|
|
fmt.Fprintf(&sb, "<figcaption>%s</figcaption>", html.EscapeString(caption))
|
|
}
|
|
sb.WriteString("</figure>\n")
|
|
|
|
case "file":
|
|
url := ""
|
|
name := ""
|
|
caption := ""
|
|
if u, ok := props["url"].(string); ok {
|
|
url = u
|
|
}
|
|
if n, ok := props["name"].(string); ok {
|
|
name = n
|
|
}
|
|
if c, ok := props["caption"].(string); ok {
|
|
caption = c
|
|
}
|
|
if name == "" {
|
|
name = url
|
|
}
|
|
sb.WriteString(`<div class="my-4 rounded border border-border p-4">`)
|
|
if href := safeLinkURL(url); href != "" {
|
|
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(href))
|
|
sb.WriteString(html.EscapeString(name))
|
|
sb.WriteString("</a>")
|
|
} else {
|
|
sb.WriteString(html.EscapeString(name))
|
|
}
|
|
if caption != "" {
|
|
fmt.Fprintf(&sb, `<p class="mt-2 text-sm text-muted-foreground">%s</p>`, html.EscapeString(caption))
|
|
}
|
|
sb.WriteString("</div>\n")
|
|
|
|
case "table":
|
|
sb.WriteString(`<div class="overflow-x-auto my-6"><table class="min-w-full border-collapse border border-border">`)
|
|
if tableContent, ok := block["content"].(map[string]any); ok {
|
|
if rows, ok := tableContent["rows"].([]any); ok && len(rows) > 0 {
|
|
renderTableRow := func(row any, isHeader bool) {
|
|
if rowMap, ok := row.(map[string]any); ok {
|
|
if cells, ok := rowMap["cells"].([]any); ok {
|
|
for _, cell := range cells {
|
|
cellTag := "td"
|
|
cellClass := "px-4 py-2 text-sm"
|
|
if isHeader {
|
|
cellTag = "th"
|
|
cellClass = "px-4 py-3 text-left text-sm font-semibold"
|
|
}
|
|
// BlockNote >=0.15 wraps each cell as a tableCell object
|
|
// {type:"tableCell", content:[...]}; older docs store the
|
|
// cell's inline content (string or array) directly.
|
|
cellContent := cell
|
|
if cellMap, ok := cell.(map[string]any); ok {
|
|
if t, _ := cellMap["type"].(string); t == "tableCell" {
|
|
cellContent = cellMap["content"]
|
|
}
|
|
}
|
|
fmt.Fprintf(&sb, "<%s class=\"%s\">", cellTag, cellClass)
|
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(cellContent), false))
|
|
fmt.Fprintf(&sb, "</%s>", cellTag)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sb.WriteString("<thead><tr class=\"bg-muted\">")
|
|
renderTableRow(rows[0], true)
|
|
sb.WriteString("</tr></thead>")
|
|
if len(rows) > 1 {
|
|
sb.WriteString("<tbody>")
|
|
for _, row := range rows[1:] {
|
|
sb.WriteString("<tr class=\"border-b border-border\">")
|
|
renderTableRow(row, false)
|
|
sb.WriteString("</tr>")
|
|
}
|
|
sb.WriteString("</tbody>")
|
|
}
|
|
}
|
|
}
|
|
sb.WriteString("</table></div>\n")
|
|
|
|
case "embed":
|
|
if resolver := blocks.GetEmbedResolver(ctx); resolver != nil {
|
|
blockID := ""
|
|
if v, ok := props["blockId"].(string); ok {
|
|
blockID = v
|
|
}
|
|
dataSource := ""
|
|
if v, ok := props["dataSource"].(string); ok {
|
|
dataSource = v
|
|
}
|
|
layout := "full"
|
|
if v, ok := props["layout"].(string); ok && v != "" {
|
|
layout = v
|
|
}
|
|
if blockID != "" {
|
|
if parsedID, err := uuid.Parse(blockID); err == nil {
|
|
sb.WriteString(resolver.RenderEmbed(ctx, parsedID, dataSource, layout))
|
|
}
|
|
}
|
|
}
|
|
|
|
case "statement":
|
|
sb.WriteString("<div class=\"bn-statement\">\n")
|
|
if len(content) > 0 {
|
|
sb.WriteString("<p>")
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
sb.WriteString("</p>\n")
|
|
}
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
sb.WriteString("</div>\n")
|
|
|
|
case "humanProof":
|
|
if hp := blocks.GetHumanProofBanner(ctx); hp != nil {
|
|
sb.WriteString(blocks.RenderHumanProofBanner(hp))
|
|
sb.WriteByte('\n')
|
|
}
|
|
|
|
case "references":
|
|
sb.WriteString("<div class=\"bn-references\">\n")
|
|
sb.WriteString("<div class=\"bn-references-label\">References</div>\n")
|
|
sb.WriteString("<ol>\n")
|
|
if itemsJSON, ok := props["items"].(string); ok && itemsJSON != "" {
|
|
var items []map[string]string
|
|
if err := json.Unmarshal([]byte(itemsJSON), &items); err == nil {
|
|
for _, item := range items {
|
|
text := html.EscapeString(item["text"])
|
|
url := item["url"]
|
|
if text == "" {
|
|
continue
|
|
}
|
|
if url != "" {
|
|
fmt.Fprintf(&sb, "<li><a href=\"%s\">%s</a></li>\n", html.EscapeString(url), text)
|
|
} else {
|
|
fmt.Fprintf(&sb, "<li>%s</li>\n", text)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
sb.WriteString("</ol>\n</div>\n")
|
|
|
|
default:
|
|
if len(content) > 0 || childrenHTML != "" {
|
|
sb.WriteString("<div>")
|
|
sb.WriteString(renderInlineContent(content, false))
|
|
if childrenHTML != "" {
|
|
sb.WriteString(childrenHTML)
|
|
}
|
|
sb.WriteString("</div>\n")
|
|
}
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
func renderChildren(ctx context.Context, children any) string {
|
|
blocks := blocksFromRaw(children)
|
|
if len(blocks) == 0 {
|
|
return ""
|
|
}
|
|
return renderBlocks(ctx, blocks)
|
|
}
|
|
|
|
func renderInlineContent(content []map[string]any, insideLink bool) string {
|
|
var sb strings.Builder
|
|
for _, itemMap := range content {
|
|
itemType, _ := itemMap["type"].(string)
|
|
text, _ := itemMap["text"].(string)
|
|
styles, _ := itemMap["styles"].(map[string]any)
|
|
|
|
switch itemType {
|
|
case "text":
|
|
isCode := false
|
|
if styles != nil {
|
|
if c, ok := styles["code"].(bool); ok && c {
|
|
isCode = true
|
|
}
|
|
}
|
|
var rendered string
|
|
if insideLink || isCode {
|
|
rendered = html.EscapeString(text)
|
|
} else {
|
|
rendered = autolinkText(text)
|
|
}
|
|
if styles != nil {
|
|
if bold, ok := styles["bold"].(bool); ok && bold {
|
|
rendered = "<strong>" + rendered + "</strong>"
|
|
}
|
|
if italic, ok := styles["italic"].(bool); ok && italic {
|
|
rendered = "<em>" + rendered + "</em>"
|
|
}
|
|
if underline, ok := styles["underline"].(bool); ok && underline {
|
|
rendered = "<u>" + rendered + "</u>"
|
|
}
|
|
if strike, ok := styles["strike"].(bool); ok && strike {
|
|
rendered = "<s>" + rendered + "</s>"
|
|
}
|
|
if strike, ok := styles["strikethrough"].(bool); ok && strike {
|
|
rendered = "<s>" + rendered + "</s>"
|
|
}
|
|
if isCode {
|
|
rendered = "<code>" + rendered + "</code>"
|
|
}
|
|
|
|
var colorClasses []string
|
|
if textColor, ok := styles["textColor"].(string); ok && textColor != "" && textColor != "default" {
|
|
if class := colorValueToClass(textColor, "text"); class != "" {
|
|
colorClasses = append(colorClasses, class)
|
|
}
|
|
}
|
|
if bgColor, ok := styles["backgroundColor"].(string); ok && bgColor != "" && bgColor != "default" {
|
|
if class := colorValueToClass(bgColor, "bg"); class != "" {
|
|
colorClasses = append(colorClasses, class)
|
|
}
|
|
}
|
|
|
|
if len(colorClasses) > 0 {
|
|
rendered = fmt.Sprintf(`<span class="%s">%s</span>`, strings.Join(colorClasses, " "), rendered)
|
|
}
|
|
}
|
|
sb.WriteString(rendered)
|
|
|
|
case "link":
|
|
href, _ := itemMap["href"].(string)
|
|
href = safeLinkURL(href)
|
|
linkContent := inlineContentFromRaw(itemMap["content"])
|
|
if href == "" {
|
|
sb.WriteString(renderInlineContent(linkContent, insideLink))
|
|
break
|
|
}
|
|
fmt.Fprintf(&sb, `<a href="%s">`, html.EscapeString(href))
|
|
sb.WriteString(renderInlineContent(linkContent, true))
|
|
sb.WriteString("</a>")
|
|
|
|
case "hardBreak":
|
|
sb.WriteString("<br />")
|
|
|
|
default:
|
|
if text != "" {
|
|
if insideLink {
|
|
sb.WriteString(html.EscapeString(text))
|
|
} else {
|
|
sb.WriteString(autolinkText(text))
|
|
}
|
|
break
|
|
}
|
|
if rawContent, ok := itemMap["content"]; ok {
|
|
sb.WriteString(renderInlineContent(inlineContentFromRaw(rawContent), insideLink))
|
|
}
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// safeLinkURL accepts ordinary web, email, telephone, and relative links while
|
|
// rejecting active-content schemes such as javascript: and data:. Escaping a
|
|
// URL protects the HTML attribute boundary, but it does not make an unsafe URL
|
|
// scheme safe to navigate to.
|
|
func safeLinkURL(raw string) string {
|
|
trimmed := strings.TrimSpace(raw)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
parsed, err := url.Parse(trimmed)
|
|
if err != nil || parsed.Scheme == "" {
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
return trimmed
|
|
}
|
|
switch strings.ToLower(parsed.Scheme) {
|
|
case "http", "https", "mailto", "tel":
|
|
return trimmed
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// imageChipClass matches the page-builder image block's attribution chip
|
|
// (backend/blocks/tags via image.ninjatpl in the cms repo) so themes style
|
|
// blog and page credits identically.
|
|
const imageChipClass = "absolute bottom-2 right-2 z-10 px-2 py-1 rounded-md bg-background/80 backdrop-blur-sm text-xs text-muted-foreground"
|
|
|
|
// buildAttributionHTML renders the escaped "Photo by NAME on SOURCE" credit,
|
|
// or "" when there is no photographer to credit. The "on SOURCE" suffix is
|
|
// omitted when no source is set.
|
|
func buildAttributionHTML(att *blocks.MediaAttribution, class string) string {
|
|
if att == nil || att.Photographer == "" {
|
|
return ""
|
|
}
|
|
var b strings.Builder
|
|
b.WriteString(`<span`)
|
|
if class != "" {
|
|
fmt.Fprintf(&b, ` class="%s"`, html.EscapeString(class))
|
|
}
|
|
b.WriteString(`>Photo by `)
|
|
writeAttributionLink(&b, att.PhotographerURL, att.Photographer)
|
|
if att.Source != "" {
|
|
sourceLabel := strings.ToUpper(att.Source[:1]) + att.Source[1:]
|
|
b.WriteString(` on `)
|
|
writeAttributionLink(&b, att.SourceURL, sourceLabel)
|
|
}
|
|
b.WriteString(`</span>`)
|
|
return b.String()
|
|
}
|
|
|
|
func writeAttributionLink(b *strings.Builder, href, label string) {
|
|
if href != "" && (strings.HasPrefix(href, "https://") || strings.HasPrefix(href, "http://")) {
|
|
fmt.Fprintf(b, `<a href="%s" target="_blank" rel="noopener noreferrer" class="underline">%s</a>`,
|
|
html.EscapeString(href), html.EscapeString(label))
|
|
return
|
|
}
|
|
b.WriteString(html.EscapeString(label))
|
|
}
|
|
|
|
// autolinkText escapes plain text for HTML output and wraps any https:// URL
|
|
// substring in <a href="..." rel="noopener">URL</a>. Bare domains, www. and
|
|
// http:// URLs are intentionally not auto-linked.
|
|
//
|
|
// Trailing sentence punctuation (.,;:!?'") is excluded from the linked URL.
|
|
// Closing parens, brackets and braces are kept inside the URL only when
|
|
// balanced with an opener inside the URL itself — so
|
|
//
|
|
// "(see https://example.com)" links only "https://example.com"
|
|
// "https://en.wikipedia.org/wiki/Foo_(bar)" keeps the trailing paren.
|
|
func autolinkText(text string) string {
|
|
const scheme = "https://"
|
|
var sb strings.Builder
|
|
rest := text
|
|
for {
|
|
idx := strings.Index(rest, scheme)
|
|
if idx < 0 {
|
|
sb.WriteString(html.EscapeString(rest))
|
|
return sb.String()
|
|
}
|
|
sb.WriteString(html.EscapeString(rest[:idx]))
|
|
|
|
// Scan forward until whitespace or a URL-terminating delimiter.
|
|
end := idx + len(scheme)
|
|
for end < len(rest) {
|
|
c := rest[end]
|
|
if c == ' ' || c == '\t' || c == '\n' || c == '\r' || c == '<' || c == '>' || c == '"' || c == '\'' {
|
|
break
|
|
}
|
|
end++
|
|
}
|
|
// Walk back over trailing characters that should sit outside the link,
|
|
// preserving paren/bracket/brace balance.
|
|
urlEnd := end
|
|
for urlEnd > idx+len(scheme) {
|
|
last := rest[urlEnd-1]
|
|
if last == '.' || last == ',' || last == ';' || last == ':' || last == '!' || last == '?' || last == '"' || last == '\'' {
|
|
urlEnd--
|
|
continue
|
|
}
|
|
candidate := rest[idx:urlEnd]
|
|
if last == ')' && strings.Count(candidate, ")") > strings.Count(candidate, "(") {
|
|
urlEnd--
|
|
continue
|
|
}
|
|
if last == ']' && strings.Count(candidate, "]") > strings.Count(candidate, "[") {
|
|
urlEnd--
|
|
continue
|
|
}
|
|
if last == '}' && strings.Count(candidate, "}") > strings.Count(candidate, "{") {
|
|
urlEnd--
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
url := rest[idx:urlEnd]
|
|
tail := rest[urlEnd:end]
|
|
|
|
if url == scheme {
|
|
sb.WriteString(html.EscapeString(rest[idx:end]))
|
|
} else {
|
|
escaped := html.EscapeString(url)
|
|
sb.WriteString(`<a href="`)
|
|
sb.WriteString(escaped)
|
|
sb.WriteString(`" rel="noopener">`)
|
|
sb.WriteString(escaped)
|
|
sb.WriteString(`</a>`)
|
|
if tail != "" {
|
|
sb.WriteString(html.EscapeString(tail))
|
|
}
|
|
}
|
|
|
|
rest = rest[end:]
|
|
}
|
|
}
|