Repository: gohugoio/hugo (language: go)

Issue:
Bug with using titles with links together with `render-heading.html` and `render-link.html`
### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.92.2-CDF6A0D6+extended windows/amd64 BuildDate=2022-02-11T14:17:39Z VendorInfo=gohugoio
</pre>

### Does this issue reproduce with the latest release?

Yes.

---

When using `layouts/_default/_markup/render-heading.html` and `layouts/_default/_markup/render-link.html` together in a title, then the rendering seems to glitch out:

`layouts/_default/_markup/render-heading.html`:
``` html
<h{{ .Level }} id="{{ .Anchor | safeURL }}">
  {{ .Text | safeHTML }}
  <a class="anchor" href="#{{ .Anchor | safeURL }}">#</a>
</h{{ .Level }}>
```

`layouts/_default/_markup/render-link.html`:
``` html
<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}>{{ .Text | safeHTML }}</a>
```

`content/posts/example.md`:
```
## Hello [Test](https://example.com)
```

Ends up generating:
```
Hello <h2 id="hello-testhttpsexamplecom">
  <a href="https://example.com">Test</a>
  <a class="anchor" href="#hello-testhttpsexamplecom">#</a>
</h2>
```

The text "Hello" appears outside the `h2` for some reason, however, I would expect generating something like:
```
<h2 id="hello-testhttpsexamplecom">Hello 
  <a href="https://example.com">Test</a>
  <a class="anchor" href="#hello-testhttpsexamplecom">#</a>
</h2>
```


Discussion / hints:
Confirmed. MRE...

```
git clone --single-branch -b hugo-github-issue-9504 https://github.com/jmooring/hugo-testing hugo-github-issue-9504
cd hugo-github-issue-9504
hugo server
```
The issue seems to be related to ctx.pos being shared between renderLink and renderHeading https://github.com/gohugoio/hugo/blob/master/markup/goldmark/render_hooks.go#L266.

A superficial fix, that seems to correct it. Obviously, might introduce a ton of allocations. I'll leave the proper fix to you, since figuring out where to place, how to name etc. can take a bunch of time to figure out :).

``` diff
diff --git a/markup/goldmark/convert.go b/markup/goldmark/convert.go
index dcaf8d3e..10bcfc3d 100644
--- a/markup/goldmark/convert.go
+++ b/markup/goldmark/convert.go
@@ -199,10 +199,29 @@ func (b *bufWriter) Flush() error {
 
 type renderContext struct {
 	*bufWriter
-	pos int
+	pos []renderContextPos
 	renderContextData
 }
 
+type renderContextPos struct {
+	pos  int
+	node ast.Node
+}
+
+func (ctx *renderContext) push(pos int, node ast.Node) {
+	ctx.pos = append(ctx.pos, renderContextPos{pos: pos, node: node})
+}
+
+func (ctx *renderContext) pop(node ast.Node) int {
+	i := len(ctx.pos) - 1
+	pos := ctx.pos[i]
+	ctx.pos = ctx.pos[:i]
+	if pos.node != node {
+		panic("stack mismatch")
+	}
+	return pos.pos
+}
+
 type renderContextData interface {
 	RenderContext() converter.RenderContext
 	DocumentContext() converter.DocumentContext
diff --git a/markup/goldmark/render_hooks.go b/markup/goldmark/render_hooks.go
index e6d959ab..c6ce7379 100644
--- a/markup/goldmark/render_hooks.go
+++ b/markup/goldmark/render_hooks.go
@@ -197,12 +197,12 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.push(ctx.Buffer.Len(), node)
 		return ast.WalkContinue, nil
 	}
-
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	pos := ctx.pop(node)
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 
 	err := h.ImageRenderer.RenderLink(
 		w,
@@ -263,12 +263,13 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.push(ctx.Buffer.Len(), node)
 		return ast.WalkContinue, nil
 	}
 
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	pos := ctx.pop(node)
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 
 	err := h.LinkRenderer.RenderLink(
 		w,
@@ -395,12 +396,13 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.push(ctx.Buffer.Len(), node)
 		return ast.WalkContinue, nil
 	}
+	pos := ctx.pop(node)
 
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 	// All ast.Heading nodes are guaranteed to have an attribute called "id"
 	// that is an array of bytes that encode a valid string.
 	anchori, _ := n.AttributeString("id")
```

Fix patch:
diff --git a/markup/goldmark/convert.go b/markup/goldmark/convert.go
index dcaf8d3e179..c547fe1e0d1 100644
--- a/markup/goldmark/convert.go
+++ b/markup/goldmark/convert.go
@@ -47,8 +47,7 @@ import (
 // Provider is the package entry point.
 var Provider converter.ProviderProvider = provide{}
 
-type provide struct {
-}
+type provide struct{}
 
 func (p provide) New(cfg converter.ProviderConfig) (converter.Provider, error) {
 	md := newMarkdown(cfg)
@@ -199,10 +198,21 @@ func (b *bufWriter) Flush() error {
 
 type renderContext struct {
 	*bufWriter
-	pos int
+	positions []int
 	renderContextData
 }
 
+func (ctx *renderContext) pushPos(n int) {
+	ctx.positions = append(ctx.positions, n)
+}
+
+func (ctx *renderContext) popPos() int {
+	i := len(ctx.positions) - 1
+	p := ctx.positions[i]
+	ctx.positions = ctx.positions[:i]
+	return p
+}
+
 type renderContextData interface {
 	RenderContext() converter.RenderContext
 	DocumentContext() converter.DocumentContext
diff --git a/markup/goldmark/render_hooks.go b/markup/goldmark/render_hooks.go
index e6d959abfe7..5c600204cf9 100644
--- a/markup/goldmark/render_hooks.go
+++ b/markup/goldmark/render_hooks.go
@@ -144,16 +144,13 @@ func (r *hookedRenderer) renderAttributesForNode(w util.BufWriter, node ast.Node
 	renderAttributes(w, false, node.Attributes()...)
 }
 
-var (
-
-	// Attributes with special meaning that does not make sense to render in HTML.
-	attributeExcludes = map[string]bool{
-		"hl_lines":    true,
-		"hl_style":    true,
-		"linenos":     true,
-		"linenostart": true,
-	}
-)
+// Attributes with special meaning that does not make sense to render in HTML.
+var attributeExcludes = map[string]bool{
+	"hl_lines":    true,
+	"hl_style":    true,
+	"linenos":     true,
+	"linenostart": true,
+}
 
 func renderAttributes(w util.BufWriter, skipClass bool, attributes ...ast.Attribute) {
 	for _, attr := range attributes {
@@ -197,12 +194,13 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.pushPos(ctx.Buffer.Len())
 		return ast.WalkContinue, nil
 	}
 
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	pos := ctx.popPos()
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 
 	err := h.ImageRenderer.RenderLink(
 		w,
@@ -263,12 +261,13 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.pushPos(ctx.Buffer.Len())
 		return ast.WalkContinue, nil
 	}
 
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	pos := ctx.popPos()
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 
 	err := h.LinkRenderer.RenderLink(
 		w,
@@ -395,12 +394,13 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
 
 	if entering {
 		// Store the current pos so we can capture the rendered text.
-		ctx.pos = ctx.Buffer.Len()
+		ctx.pushPos(ctx.Buffer.Len())
 		return ast.WalkContinue, nil
 	}
 
-	text := ctx.Buffer.Bytes()[ctx.pos:]
-	ctx.Buffer.Truncate(ctx.pos)
+	pos := ctx.popPos()
+	text := ctx.Buffer.Bytes()[pos:]
+	ctx.Buffer.Truncate(pos)
 	// All ast.Heading nodes are guaranteed to have an attribute called "id"
 	// that is an array of bytes that encode a valid string.
 	anchori, _ := n.AttributeString("id")
@@ -440,8 +440,7 @@ func (r *hookedRenderer) renderHeadingDefault(w util.BufWriter, source []byte, n
 	return ast.WalkContinue, nil
 }
 
-type links struct {
-}
+type links struct{}
 
 // Extend implements goldmark.Extender.
 func (e *links) Extend(m goldmark.Markdown) {


Test patch:
diff --git a/markup/goldmark/integration_test.go b/markup/goldmark/integration_test.go
index fd90a6824e5..0f47f4adabd 100644
--- a/markup/goldmark/integration_test.go
+++ b/markup/goldmark/integration_test.go
@@ -61,6 +61,42 @@ foo
 	`)
 }
 
+// Issue 9504
+func TestLinkInTitle(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- config.toml --
+-- content/p1.md --
+---
+title: "p1"
+---
+## Hello [Test](https://example.com)
+-- layouts/_default/single.html --
+{{ .Content }}
+-- layouts/_default/_markup/render-heading.html --
+<h{{ .Level }} id="{{ .Anchor | safeURL }}">
+  {{ .Text | safeHTML }}
+  <a class="anchor" href="#{{ .Anchor | safeURL }}">#</a>
+</h{{ .Level }}>
+-- layouts/_default/_markup/render-link.html --
+<a href="{{ .Destination | safeURL }}"{{ with .Title}} title="{{ . }}"{{ end }}>{{ .Text | safeHTML }}</a>
+
+`
+
+	b := hugolib.NewIntegrationTestBuilder(
+		hugolib.IntegrationTestConfig{
+			T:           t,
+			TxtarString: files,
+			NeedsOsFS:   false,
+		},
+	).Build()
+
+	b.AssertFileContent("public/p1/index.html",
+		"<h2 id=\"hello-testhttpsexamplecom\">\n  Hello <a href=\"https://example.com\">Test</a>\n\n  <a class=\"anchor\" href=\"#hello-testhttpsexamplecom\">#</a>\n</h2>",
+	)
+}
+
 func BenchmarkSiteWithRenderHooks(b *testing.B) {
 	files := `
 -- config.toml --