MediaValue gains an Attribution credit resolved through the existing MediaResolver context hook. The BlockNote image case renders width and align presets, alt text, an optional link wrap, and the attribution chip in chip, chipHover, and below modes with the same themed markup as the cms page-builder image block. Legacy url-and-caption blocks render byte-identical to the previous output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
770 lines
21 KiB
Go
770 lines
21 KiB
Go
package render
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"html"
|
|
"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 != "" {
|
|
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 url != "" {
|
|
fmt.Fprintf(&sb, `<a class="text-primary underline" href="%s">`, html.EscapeString(url))
|
|
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)
|
|
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()
|
|
}
|
|
|
|
// 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:]
|
|
}
|
|
}
|