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 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
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
holding a thumbnail that links to the full file, // with the image's inline children as
. 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(``) if !captioned { w.Write(util.EscapeHTML([]byte(name))) } } else { w.WriteString(``) } return ast.WalkContinue, nil } if entering { w.WriteString(`
`)
		w.Write(util.EscapeHTML([]byte(name)))
		w.WriteString(``) if captioned { w.WriteString(`
`) } return ast.WalkContinue, nil } if captioned { w.WriteString(`
`) } w.WriteString(`
`) return ast.WalkContinue, nil } // embedBlockTransformer lifts an embed that sits alone in a paragraph up to // block level. Goldmark wraps inline content in

, but the embed renders a //

(block), and a
inside a

is auto-closed by the browser — // which strands empty

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

. 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), )) }