diff --git a/embed.go b/embed.go new file mode 100644 index 0000000..0777a4f --- /dev/null +++ b/embed.go @@ -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

, 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{ 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

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(`
`)
+	w.Write(util.EscapeHTML([]byte(alt)))
+	w.WriteString(``) + if n.Caption != "" { + w.WriteString(`
`) + w.Write(util.EscapeHTML([]byte(n.Caption))) + w.WriteString(`
`) + } + w.WriteString(`
`) + 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), + )) +}