Allow image embedding

This commit is contained in:
2026-07-24 16:10:29 +02:00
parent e37dde1e40
commit 52bc694ffa
+254
View File
@@ -0,0 +1,254 @@
package main
import (
"bytes"
"path"
"regexp"
"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"
)
// wikiEmbedRe matches an ![[...]] token anchored at the current inline reader.
// The inner blob forbids brackets and newlines but allows ':' so the whole
// target::caption::align payload travels as one capture — the caption and
// alignment tail is split apart in Go (parseEmbedFields). This mirrors
// wikiLinkPattern's shape so the move rewriter needs no embed-specific changes.
var wikiEmbedRe = regexp.MustCompile(`^!\[\[([^\[\]\n]+)\]\]`)
const (
alignLeft = "left"
alignRight = "right"
alignCenter = "center"
)
// wikiEmbedNode is the AST node produced by wikiEmbedParser.
type wikiEmbedNode struct {
ast.BaseInline
Target string
Caption string
Align string
}
var kindWikiEmbed = ast.NewNodeKind("WikiEmbed")
func (n *wikiEmbedNode) Kind() ast.NodeKind { return kindWikiEmbed }
func (n *wikiEmbedNode) Dump(source []byte, level int) {
ast.DumpHelper(n, source, level, map[string]string{
"Target": n.Target,
"Caption": n.Caption,
"Align": n.Align,
}, nil)
}
// alignKeyword reports whether s (trimmed) is one of the alignment keywords.
func alignKeyword(s string) (string, bool) {
switch strings.TrimSpace(s) {
case alignLeft:
return alignLeft, true
case alignRight:
return alignRight, true
case alignCenter:
return alignCenter, true
}
return "", false
}
// parseEmbedFields splits the inner ![[...]] blob into target, caption, and
// alignment per the embed syntax rules. The target is the field before the
// first "::". The trailing "::"-fields are interpreted as:
// - none: no caption, default alignment (right)
// - one keyword field: that alignment, no caption
// - one non-keyword field: that caption, default alignment
// - two or more: the last field is the alignment slot (default when it is not
// a keyword) and the earlier fields re-join with "::" as the caption, so a
// caption may contain a literal "::" as long as an alignment field trails it.
func parseEmbedFields(inner string) (target, caption, align string) {
fields := strings.Split(inner, "::")
target = strings.TrimSpace(fields[0])
tail := fields[1:]
align = alignRight
switch len(tail) {
case 0:
// target only
case 1:
if kw, ok := alignKeyword(tail[0]); ok {
align = kw
} else {
caption = strings.TrimSpace(tail[0])
}
default:
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
align = kw
}
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
}
return target, caption, align
}
type wikiEmbedParser struct{}
func (p *wikiEmbedParser) Trigger() []byte { return []byte{'!'} }
func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
line, _ := block.PeekLine()
// Require the exact "![[" opener; a bare '!' (or a Markdown image "![](…)")
// falls through to goldmark's default image parser at priority 200.
if len(line) < 5 || line[0] != '!' || line[1] != '[' || line[2] != '[' {
return nil
}
m := wikiEmbedRe.FindSubmatchIndex(line)
if m == nil {
return nil
}
target, caption, align := parseEmbedFields(string(line[m[2]:m[3]]))
if !isValidWikiTarget([]byte(target)) {
return nil
}
block.Advance(m[1])
return &wikiEmbedNode{Target: target, Caption: caption, Align: align}
}
// 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{ root string }
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.(*wikiEmbedNode); ok {
parent.InsertBefore(parent, p, c)
}
c = next
}
parent.RemoveChild(parent, p)
}
}
// paragraphFiguresOnly reports whether p holds at least one figure-rendering
// embed and nothing else visible — only such embeds and whitespace/line-break
// text. Only these are safe to dissolve: a paragraph carrying prose, or an embed
// that degrades to an inline link (missing / non-image target), 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 *wikiEmbedNode:
if !embedIsImage(t.root, n.Target) {
return false
}
hasFigure = true
case *ast.Text:
if len(bytes.TrimSpace(n.Segment.Value(source))) != 0 {
return false
}
default:
return false
}
}
return hasFigure
}
// embedIsImage reports whether target resolves to an existing image file, i.e.
// the embed will render as a <figure> rather than degrade to a link fallback.
func embedIsImage(root, target string) bool {
name := path.Base(normalizeWikiTarget(target))
return wikiTargetExists(root, target) && isImageFile(name)
}
type wikiEmbedRenderer struct {
root string
}
func (r *wikiEmbedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(kindWikiEmbed, r.render)
}
func (r *wikiEmbedRenderer) render(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering {
return ast.WalkContinue, nil
}
n := node.(*wikiEmbedNode)
// Embed only when the target exists and is an image we can thumbnail.
// Anything else — a missing target, or an existing non-image file — degrades
// to the same anchor a plain [[wikilink]] would render (broken or working).
if !embedIsImage(r.root, n.Target) {
writeWikiLink(w, r.root, n.Target, "")
return ast.WalkContinue, nil
}
fileHref := wikiFileHref(n.Target)
alt := n.Caption
if alt == "" {
alt = path.Base(normalizeWikiTarget(n.Target))
}
w.WriteString(`<figure class="embed embed-`)
w.WriteString(n.Align)
w.WriteString(`"><a class="embed-link" href="`)
w.WriteString(fileHref)
w.WriteString(`"><img src="`)
w.WriteString(thumbURL(fileHref, 300))
w.WriteString(`" alt="`)
w.Write(util.EscapeHTML([]byte(alt)))
w.WriteString(`"></a>`)
if n.Caption != "" {
w.WriteString(`<figcaption>`)
w.Write(util.EscapeHTML([]byte(n.Caption)))
w.WriteString(`</figcaption>`)
}
w.WriteString(`</figure>`)
return ast.WalkContinue, nil
}
type wikiEmbedExt struct{ root string }
// newWikiEmbedExt returns a goldmark extension that turns ![[...]] tokens into
// image embeds resolved against root.
func newWikiEmbedExt(root string) goldmark.Extender {
return &wikiEmbedExt{root: root}
}
func (e *wikiEmbedExt) Extend(m goldmark.Markdown) {
// Priority 199 — one higher than the default image parser (200) so ![[...]]
// is consumed as an embed before the default `!` parser sees it.
m.Parser().AddOptions(parser.WithInlineParsers(
util.Prioritized(&wikiEmbedParser{}, 199),
))
m.Parser().AddOptions(parser.WithASTTransformers(
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
))
}