Files
datascape/embed.go
T
2026-09-17 17:46:36 +02:00

187 lines
5.9 KiB
Go

package main
import (
"bytes"
"path"
"strconv"
"strings"
"github.com/yuin/goldmark"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
)
// Embeds are ordinary Markdown images: ![caption](photo.jpg "left").
//
// Destinations are relative to the page they sit on. Pages always render at a
// URL ending in "/" (ServeHTTP redirects the slashless form), so the browser
// resolves the destination and nothing here builds a URL or touches the
// filesystem. Goldmark's own image parser produces the nodes; only rendering
// is overridden. The title slot carries the float alignment.
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
// embedThumbW is the width embeds request through the ?w= form of the
// file's own URL, which keeps the <img src> relative.
embedThumbW = 300
)
// embedAlign maps an image title to its float class. A title that is not an
// alignment keyword is ignored and the embed floats right.
func embedAlign(title []byte) string {
switch strings.TrimSpace(string(title)) {
case alignLeft:
return alignLeft
case alignCenter:
return alignCenter
}
return alignRight
}
// embedIsImage reports whether a destination names an image, i.e. whether it
// renders as a <figure> rather than degrading to a link. Extension-only — no
// stat, so a destination gone stale simply 404s when clicked.
func embedIsImage(dest []byte) bool {
return isImageFile(path.Base(string(dest)))
}
type embedRenderer struct{}
func (r *embedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindImage, r.render)
}
// render writes a <figure> holding a thumbnail that links to the full file,
// with the image's inline children as <figcaption>. Children are rendered by
// goldmark — WalkContinue, not WalkSkipChildren — so a caption may carry
// emphasis or a link. A non-image destination renders as a plain anchor
// labelled with the caption, or with the file name when there is none.
func (r *embedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
n := node.(*ast.Image)
name := path.Base(string(n.Destination))
dest := util.EscapeHTML(util.URLEscape(n.Destination, true))
captioned := n.FirstChild() != nil
if !embedIsImage(n.Destination) {
if entering {
w.WriteString(`<a href="`)
w.Write(dest)
w.WriteString(`">`)
if !captioned {
w.Write(util.EscapeHTML([]byte(name)))
}
} else {
w.WriteString(`</a>`)
}
return ast.WalkContinue, nil
}
if entering {
w.WriteString(`<figure class="embed embed-`)
w.WriteString(embedAlign(n.Title))
w.WriteString(`"><a class="embed-link" href="`)
w.Write(dest)
w.WriteString(`"><img src="`)
w.Write(dest)
w.WriteString(`?w=`)
w.WriteString(strconv.Itoa(embedThumbW))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(name)))
w.WriteString(`"></a>`)
if captioned {
w.WriteString(`<figcaption>`)
}
return ast.WalkContinue, nil
}
if captioned {
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
// block level. Goldmark wraps inline content in <p>, but the embed renders a
// <figure> (block), and a <figure> inside a <p> is auto-closed by the browser —
// which strands empty <p> elements (they still carry .content paragraph margins)
// and breaks the float layout once several embeds share a page. Dissolving the
// wrapping paragraph makes each embed render as a clean block sibling with no
// stray <p>.
type embedBlockTransformer struct{}
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
source := reader.Source()
var paras []*ast.Paragraph
ast.Walk(doc, func(n ast.Node, entering bool) (ast.WalkStatus, error) {
if entering {
if p, ok := n.(*ast.Paragraph); ok && t.paragraphFiguresOnly(p, source) {
paras = append(paras, p)
}
}
return ast.WalkContinue, nil
})
for _, p := range paras {
parent := p.Parent()
if parent == nil {
continue
}
// InsertBefore isolates the embed from p first, so hoist each embed to a
// block sibling ahead of the (soon removed) paragraph, then drop p.
for c := p.FirstChild(); c != nil; {
next := c.NextSibling()
if _, ok := c.(*ast.Image); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// image and nothing else visible — only such images and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an
// image that degrades to an inline link (non-image destination), stays wrapped
// so the fallback anchor keeps its paragraph.
func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []byte) bool {
hasFigure := false
for c := p.FirstChild(); c != nil; c = c.NextSibling() {
switch n := c.(type) {
case *ast.Image:
if !embedIsImage(n.Destination) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
type embedExt struct{}
// newEmbedExt returns a goldmark extension that renders Markdown images as
// wiki embeds. It adds no parser of its own: goldmark parses the images, and
// the renderer registered at 500 wins over the default HTML renderer
// (registered at 1000 — lowest priority number is applied last and wins).
func newEmbedExt() goldmark.Extender { return &embedExt{} }
func (e *embedExt) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&embedRenderer{}, 500),
))
}