187 lines
5.9 KiB
Go
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: .
|
|
//
|
|
// 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(`" loading="lazy"></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),
|
|
))
|
|
}
|