Rework wikilinks feature
This commit is contained in:
@@ -3,7 +3,7 @@ package main
|
||||
import (
|
||||
"bytes"
|
||||
"path"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
@@ -14,105 +14,95 @@ import (
|
||||
"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]+)\]\]`)
|
||||
|
||||
// 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
|
||||
)
|
||||
|
||||
// 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) {
|
||||
// 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, true
|
||||
case alignRight:
|
||||
return alignRight, true
|
||||
return alignLeft
|
||||
case alignCenter:
|
||||
return alignCenter, true
|
||||
return alignCenter
|
||||
}
|
||||
return "", false
|
||||
return alignRight
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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 {
|
||||
caption = strings.TrimSpace(tail[0])
|
||||
w.WriteString(`</a>`)
|
||||
}
|
||||
default:
|
||||
if kw, ok := alignKeyword(tail[len(tail)-1]); ok {
|
||||
align = kw
|
||||
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>`)
|
||||
}
|
||||
caption = strings.TrimSpace(strings.Join(tail[:len(tail)-1], "::"))
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
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
|
||||
if captioned {
|
||||
w.WriteString(`</figcaption>`)
|
||||
}
|
||||
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}
|
||||
w.WriteString(`</figure>`)
|
||||
return ast.WalkContinue, nil
|
||||
}
|
||||
|
||||
// embedBlockTransformer lifts an embed that sits alone in a paragraph up to
|
||||
@@ -122,7 +112,7 @@ func (p *wikiEmbedParser) Parse(parent ast.Node, block text.Reader, pc parser.Co
|
||||
// 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 }
|
||||
type embedBlockTransformer struct{}
|
||||
|
||||
func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader, pc parser.Context) {
|
||||
source := reader.Source()
|
||||
@@ -144,7 +134,7 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
|
||||
// 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 {
|
||||
if _, ok := c.(*ast.Image); ok {
|
||||
parent.InsertBefore(parent, p, c)
|
||||
}
|
||||
c = next
|
||||
@@ -154,16 +144,16 @@ func (t embedBlockTransformer) Transform(doc *ast.Document, reader text.Reader,
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 *wikiEmbedNode:
|
||||
if !embedIsImage(t.root, n.Target) {
|
||||
case *ast.Image:
|
||||
if !embedIsImage(n.Destination) {
|
||||
return false
|
||||
}
|
||||
hasFigure = true
|
||||
@@ -178,77 +168,19 @@ func (t embedBlockTransformer) paragraphFiguresOnly(p *ast.Paragraph, source []b
|
||||
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 embedExt struct{}
|
||||
|
||||
type wikiEmbedRenderer struct {
|
||||
root string
|
||||
}
|
||||
// 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 (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),
|
||||
))
|
||||
func (e *embedExt) Extend(m goldmark.Markdown) {
|
||||
m.Parser().AddOptions(parser.WithASTTransformers(
|
||||
util.Prioritized(embedBlockTransformer{root: e.root}, 100),
|
||||
util.Prioritized(embedBlockTransformer{}, 100),
|
||||
))
|
||||
m.Renderer().AddOptions(renderer.WithNodeRenderers(
|
||||
util.Prioritized(&wikiEmbedRenderer{root: e.root}, 500),
|
||||
util.Prioritized(&embedRenderer{}, 500),
|
||||
))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user