Improve and extend .Position handling in Goldmark render hooks

Goldmark v1.8 reports source positions in render hooks.
Use this to build a source map that translates Goldmark positions back to
the original content source.

Now all render hooks' context object implenent the `BaseContext` interface:

```go
type BaseContext interface {
	Position() Position
	Page() any
	PageInner() any
	Ordinal() int
}
```

Closes #14663

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bjørn Erik Pedersen
2026-03-25 18:28:19 +01:00
parent beb57a6839
commit 303e443ea7
16 changed files with 259 additions and 168 deletions
+7 -7
View File
@@ -102,8 +102,8 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
}
}
bqctx := &blockquoteContext{
BaseContext: render.NewBaseContext(ctx, renderer, n, src, nil, ordinal),
bqctx := blockquoteContext{
BaseContext: render.NewBaseContext(ctx, renderer, n, src, ordinal),
typ: typ,
alert: alert,
text: hstring.HTML(text),
@@ -150,23 +150,23 @@ type blockquoteContext struct {
*attributes.AttributesHolder
}
func (c *blockquoteContext) Type() string {
func (c blockquoteContext) Type() string {
return c.typ
}
func (c *blockquoteContext) AlertType() string {
func (c blockquoteContext) AlertType() string {
return c.alert.typ
}
func (c *blockquoteContext) AlertTitle() hstring.HTML {
func (c blockquoteContext) AlertTitle() hstring.HTML {
return hstring.HTML(c.alert.title)
}
func (c *blockquoteContext) AlertSign() string {
func (c blockquoteContext) AlertSign() string {
return c.alert.sign
}
func (c *blockquoteContext) Text() hstring.HTML {
func (c blockquoteContext) Text() hstring.HTML {
return c.text
}
@@ -79,7 +79,7 @@ title: "p1"
"Blockquote Alert: |<p>This is a caution with some whitespace before the alert type.</p>|alert|",
"Blockquote: |<p>A regular blockquote.</p>\n|regular|",
"Blockquote Alert Attributes: |<p>This is a tip with attributes.</p>|map[class:foo bar id:baz]|",
filepath.FromSlash("/content/p1.md:19:3"),
filepath.FromSlash("/content/p1.md:20:1"),
"Blockquote Alert Page: |<p>This is a tip with attributes.</p>|p1|p1|",
// Issue 12767.
@@ -181,10 +181,8 @@ title: "p1"
b := hugolib.Test(t, files)
b.AssertFileContent("public/p1/index.html", `
# Issue 9627: For the Position in code blocks we try to match the .Inner with the original source. This isn't always possible.
p1.md:0:0
`,
b.AssertFileContent("public/p1/index.html",
"p1.md:7:1",
)
}
@@ -388,3 +386,30 @@ Attributes: {{ .Attributes }}|Type: {{ .Type }}|
b.Assert(err, qt.Not(qt.IsNil))
b.Assert(err.Error(), qt.Contains, "p1.md:7:9\": failed to parse Markdown attributes; you may need to quote the values")
}
func TestCodeblockPosition(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
-- layouts/_markup/render-codeblock.html --
{{ .Position | safeHTML }}
-- layouts/single.html --
{{ .Content }}
-- content/p1.md --
---
title: "p1"
---
## Simple
§§§text
Some code.
§§§
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/p1/index.html", "p1.md:7:1")
}
+4 -4
View File
@@ -101,8 +101,8 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
return ast.WalkStop, &herrors.TextSegmentError{Err: err, Segment: attrStr}
}
cbctx := &codeBlockContext{
BaseContext: render.NewBaseContext(ctx, renderer, node, src, func() []byte { return []byte(s) }, ordinal),
cbctx := codeBlockContext{
BaseContext: render.NewBaseContext(ctx, renderer, node, src, ordinal),
lang: lang,
code: s,
AttributesHolder: attributes.New(attrs, attrtp),
@@ -130,11 +130,11 @@ type codeBlockContext struct {
*attributes.AttributesHolder
}
func (c *codeBlockContext) Type() string {
func (c codeBlockContext) Type() string {
return c.lang
}
func (c *codeBlockContext) Inner() string {
func (c codeBlockContext) Inner() string {
return c.code
}
+6 -69
View File
@@ -19,7 +19,6 @@ import (
"strings"
"sync"
"github.com/gohugoio/hugo-goldmark-extensions/passthrough"
bp "github.com/gohugoio/hugo/bufferpool"
east "github.com/yuin/goldmark-emoji/ast"
@@ -160,51 +159,6 @@ func (ctx *RenderContextDataHolder) DocumentContext() converter.DocumentContext
return ctx.Dctx
}
// extractSourceSample returns a sample of the source for the given node.
// Note that this is not a copy of the source, but a slice of it,
// so it assumes that the source is not mutated.
func extractSourceSample(n ast.Node, src []byte) []byte {
if n.Type() == ast.TypeInline {
switch n := n.(type) {
case *passthrough.PassthroughInline:
return n.Segment.Value(src)
}
return nil
}
var sample []byte
getStartStop := func(n ast.Node) (int, int) {
if n == nil {
return 0, 0
}
var start, stop int
for i := 0; i < n.Lines().Len() && i < 2; i++ {
line := n.Lines().At(i)
if i == 0 {
start = line.Start
}
stop = line.Stop
}
return start, stop
}
start, stop := getStartStop(n)
if stop == 0 {
// Try first child.
start, stop = getStartStop(n.FirstChild())
}
if stop > 0 {
// We do not mutate the source, so this is safe.
sample = src[start:stop]
}
return sample
}
// GetPageAndPageInner returns the current page and the inner page for the given context.
func GetPageAndPageInner(rctx *Context) (any, any) {
p := rctx.DocumentContext().Document
@@ -220,24 +174,17 @@ func GetPageAndPageInner(rctx *Context) (any, any) {
}
// NewBaseContext creates a new BaseContext.
func NewBaseContext(rctx *Context, renderer any, n ast.Node, src []byte, getSourceSample func() []byte, ordinal int) hooks.BaseContext {
if getSourceSample == nil {
getSourceSample = func() []byte {
return extractSourceSample(n, src)
}
}
func NewBaseContext(rctx *Context, renderer any, n ast.Node, src []byte, ordinal int) hooks.BaseContext {
page, pageInner := GetPageAndPageInner(rctx)
b := &hookBase{
page: page,
pageInner: pageInner,
getSourceSample: getSourceSample,
ordinal: ordinal,
ordinal: ordinal,
}
b.createPos = func() htext.Position {
if resolver, ok := renderer.(hooks.ElementPositionResolver); ok {
return resolver.ResolvePosition(b)
return resolver.ResolvePosition(b, src, n.Pos())
}
return htext.Position{
@@ -250,19 +197,14 @@ func NewBaseContext(rctx *Context, renderer any, n ast.Node, src []byte, getSour
return b
}
var _ hooks.PositionerSourceTargetProvider = (*hookBase)(nil)
type hookBase struct {
page any
pageInner any
ordinal int
// This is only used in error situations and is expensive to create,
// so delay creation until needed.
pos htext.Position
posInit sync.Once
createPos func() htext.Position
getSourceSample func() []byte
pos htext.Position
posInit sync.Once
createPos func() htext.Position
}
func (c *hookBase) Page() any {
@@ -284,11 +226,6 @@ func (c *hookBase) Position() htext.Position {
return c.pos
}
// For internal use.
func (c *hookBase) PositionerSourceTarget() []byte {
return c.getSourceSample()
}
// TextPlain returns a plain text representation of the given node.
// This will resolve any leftover HTML entities. This will typically be
// entities inserted by e.g. the typographer extension.
+4 -4
View File
@@ -132,8 +132,8 @@ func (r *htmlRenderer) renderPassthroughBlock(w util.BufWriter, src []byte, node
// Trim the delimiters.
s = s[len(delims.Open) : len(s)-len(delims.Close)]
pctx := &passthroughContext{
BaseContext: render.NewBaseContext(ctx, renderer, node, src, nil, ordinal),
pctx := passthroughContext{
BaseContext: render.NewBaseContext(ctx, renderer, node, src, ordinal),
inner: s,
typ: typ,
AttributesHolder: attributes.New(node.Attributes(), attributes.AttributesOwnerGeneral),
@@ -157,10 +157,10 @@ type passthroughContext struct {
*attributes.AttributesHolder
}
func (p *passthroughContext) Type() string {
func (p passthroughContext) Type() string {
return p.typ
}
func (p *passthroughContext) Inner() string {
func (p passthroughContext) Inner() string {
return p.inner
}
+9 -41
View File
@@ -48,8 +48,7 @@ func newLinks(cfg goldmark_config.Config) goldmark.Extender {
}
type linkContext struct {
page any
pageInner any
hooks.BaseContext
destination string
title string
text hstring.HTML
@@ -61,14 +60,6 @@ func (ctx linkContext) Destination() string {
return ctx.destination
}
func (ctx linkContext) Page() any {
return ctx.page
}
func (ctx linkContext) PageInner() any {
return ctx.pageInner
}
func (ctx linkContext) Text() hstring.HTML {
return ctx.text
}
@@ -83,7 +74,6 @@ func (ctx linkContext) Title() string {
type imageLinkContext struct {
linkContext
ordinal int
isBlock bool
}
@@ -91,13 +81,8 @@ func (ctx imageLinkContext) IsBlock() bool {
return ctx.isBlock
}
func (ctx imageLinkContext) Ordinal() int {
return ctx.ordinal
}
type headingContext struct {
page any
pageInner any
hooks.BaseContext
level int
anchor string
text hstring.HTML
@@ -105,14 +90,6 @@ type headingContext struct {
*attributes.AttributesHolder
}
func (ctx headingContext) Page() any {
return ctx.page
}
func (ctx headingContext) PageInner() any {
return ctx.pageInner
}
func (ctx headingContext) Level() int {
return ctx.level
}
@@ -188,22 +165,18 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
// internal attributes before rendering.
attrs := r.filterInternalAttributes(n.Attributes())
page, pageInner := render.GetPageAndPageInner(ctx)
err := lr.RenderLink(
ctx.RenderContext().Ctx,
w,
imageLinkContext{
linkContext: linkContext{
page: page,
pageInner: pageInner,
BaseContext: render.NewBaseContext(ctx, lr, node, source, ordinal),
destination: string(n.Destination),
title: string(n.Title),
text: hstring.HTML(text),
plainText: render.TextPlain(n, source),
AttributesHolder: attributes.New(attrs, attributes.AttributesOwnerGeneral),
},
ordinal: ordinal,
isBlock: isBlock,
},
)
@@ -277,15 +250,13 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
}
text := ctx.PopRenderedString()
page, pageInner := render.GetPageAndPageInner(ctx)
ordinal := ctx.GetAndIncrementOrdinal(node.Kind())
err := lr.RenderLink(
ctx.RenderContext().Ctx,
w,
linkContext{
page: page,
pageInner: pageInner,
BaseContext: render.NewBaseContext(ctx, lr, node, source, ordinal),
destination: string(n.Destination),
title: string(n.Title),
text: hstring.HTML(text),
@@ -420,14 +391,13 @@ func (r *hookedRenderer) renderAutoLink(w util.BufWriter, source []byte, node as
url = "mailto:" + url
}
page, pageInner := render.GetPageAndPageInner(ctx)
ordinal := ctx.GetAndIncrementOrdinal(n.Kind())
err := lr.RenderLink(
ctx.RenderContext().Ctx,
w,
linkContext{
page: page,
pageInner: pageInner,
BaseContext: render.NewBaseContext(ctx, lr, node, source, ordinal),
destination: url,
text: hstring.HTML(label),
plainText: label,
@@ -508,15 +478,13 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
if anchori, ok := n.AttributeString("id"); ok {
anchor, _ = anchori.([]byte)
}
page, pageInner := render.GetPageAndPageInner(ctx)
ordinal := ctx.GetAndIncrementOrdinal(n.Kind())
err := hr.RenderHeading(
ctx.RenderContext().Ctx,
w,
headingContext{
page: page,
pageInner: pageInner,
BaseContext: render.NewBaseContext(ctx, hr, node, source, ordinal),
level: n.Level,
anchor: string(anchor),
text: hstring.HTML(text),
+4 -4
View File
@@ -76,8 +76,8 @@ func (r *htmlRenderer) renderTable(w util.BufWriter, source []byte, n ast.Node,
ordinal := ctx.GetAndIncrementOrdinal(gast.KindTable)
tctx := &tableContext{
BaseContext: render.NewBaseContext(ctx, renderer, n, source, nil, ordinal),
tctx := tableContext{
BaseContext: render.NewBaseContext(ctx, renderer, n, source, ordinal),
AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral),
tHead: table.THead,
tBody: table.TBody,
@@ -166,10 +166,10 @@ type tableContext struct {
tBody []hooks.TableRow
}
func (c *tableContext) THead() []hooks.TableRow {
func (c tableContext) THead() []hooks.TableRow {
return c.tHead
}
func (c *tableContext) TBody() []hooks.TableRow {
func (c tableContext) TBody() []hooks.TableRow {
return c.tBody
}