Allow image embedding
This commit is contained in:
@@ -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 "")
|
||||
// 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),
|
||||
))
|
||||
}
|
||||
Reference in New Issue
Block a user