Repository: gohugoio/hugo (language: go)

Issue:
transform.toMath, invalid syntax: incorrect position in source file printed out
As discussed in this [thread](https://discourse.gohugo.io/t/tableofcontents-with-passthrough-extension-empty-list-item/53532) on hugo's discourse board:

I'm using this passthrough render hook to render equations using function `transform.toMath`:

```
{{- with try (transform.ToMath .Inner ) }}
  {{- with .Err }}
    {{ errorf "KaTeX: %s: see %s." . $.Position }}
  {{- else }}
    {{- .Value }}
  {{- end }}
{{- end -}}
``` 

With an invalid syntax given for the TeX content to rendered, I'm getting:

```
ERROR KaTeX: template: _default/_markup/render-passthrough.html:1:23:
executing "_default/_markup/render-passthrough.html" at <transform.ToMath>:
error calling ToMath: KaTeX parse error: Undefined control sequence:
\foo at position 1: \̲f̲o̲o̲:
see "/path/to/site/content/lang/section/subsection/index.md:0:1".
```

**Bug**

The position given in the last line is always `0:1`, regardless of the actual position of the offending code in the source file.

**Test of render hooks**

As requested in the discourse thread, I tested all available render hooks, this is the result:

| Render hook    | Context has Position | Position correct |
| -------- |:-------:|:-------:|
| Block quote  |  yes    |  yes    |
| Code blocks | yes     |  yes    |
| Headings    | no  |  -  |
| Images    | no  |  -  |
| Links    | no  |  -  |
| Pass through    | yes    |  no    |
| Table    | yes  |  no   |

### What version of Hugo are you using (`hugo version`)?

<pre>
$ hugo version
hugo v0.144.0-DEV-9c2f8ec61beb9521fb606848de2d14aa180facd7+extended linux/amd64
</pre>

### Does this issue reproduce with the latest release?

Yes.


Discussion / hints:

Fix patch:
diff --git a/markup/goldmark/internal/extensions/attributes/attributes.go b/markup/goldmark/internal/extensions/attributes/attributes.go
index 526635f4575..f5f6f97b4de 100644
--- a/markup/goldmark/internal/extensions/attributes/attributes.go
+++ b/markup/goldmark/internal/extensions/attributes/attributes.go
@@ -1,6 +1,8 @@
 package attributes
 
 import (
+	"strings"
+
 	"github.com/gohugoio/hugo/markup/goldmark/goldmark_config"
 	"github.com/gohugoio/hugo/markup/goldmark/internal/render"
 	"github.com/yuin/goldmark"
@@ -181,12 +183,17 @@ func (a *transformer) generateAutoID(n ast.Node, reader text.Reader, pc parser.C
 }
 
 // Markdown settext headers can have multiple lines, use the last line for the ID.
-func textHeadingID(node *ast.Heading, reader text.Reader) []byte {
-	var line []byte
-	lastIndex := node.Lines().Len() - 1
-	if lastIndex > -1 {
-		lastLine := node.Lines().At(lastIndex)
-		line = lastLine.Value(reader.Source())
+func textHeadingID(n *ast.Heading, reader text.Reader) []byte {
+	text := render.TextPlain(n, reader.Source())
+	if n.Lines().Len() > 1 {
+
+		// For multiline headings, Goldmark's extension for headings returns the last line.
+		// We have a slightly different approach, but in most cases the end result should be the same.
+		// Instead of looking at the text segments in Lines (see #13405 for issues with that),
+		// we split the text above and use the last line.
+		parts := strings.Split(text, "\n")
+		text = parts[len(parts)-1]
 	}
-	return line
+
+	return []byte(text)
 }
diff --git a/markup/goldmark/internal/render/context.go b/markup/goldmark/internal/render/context.go
index 469ea72e3cd..fd8e788ede4 100644
--- a/markup/goldmark/internal/render/context.go
+++ b/markup/goldmark/internal/render/context.go
@@ -1,4 +1,4 @@
-// Copyright 2024 The Hugo Authors. All rights reserved.
+// Copyright 2025 The Hugo Authors. All rights reserved.
 //
 // Licensed under the Apache License, Version 2.0 (the "License");
 // you may not use this file except in compliance with the License.
@@ -19,7 +19,9 @@ import (
 	"strings"
 	"sync"
 
+	"github.com/gohugoio/hugo-goldmark-extensions/passthrough"
 	bp "github.com/gohugoio/hugo/bufferpool"
+	east "github.com/yuin/goldmark-emoji/ast"
 
 	htext "github.com/gohugoio/hugo/common/text"
 	"github.com/gohugoio/hugo/tpl"
@@ -162,21 +164,44 @@ func (ctx *RenderContextDataHolder) DocumentContext() converter.DocumentContext
 // 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
 
-	// Extract a source sample to use for position information.
-	if nn := n.FirstChild(); nn != nil {
+	getStartStop := func(n ast.Node) (int, int) {
+		if n == nil {
+			return 0, 0
+		}
+
 		var start, stop int
-		for i := 0; i < nn.Lines().Len() && i < 2; i++ {
-			line := nn.Lines().At(i)
+		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
 }
 
@@ -282,6 +307,7 @@ func textPlainTo(c ast.Node, source []byte, buf *bytes.Buffer) {
 	if c == nil {
 		return
 	}
+
 	switch c := c.(type) {
 	case *ast.RawHTML:
 		s := strings.TrimSpace(tpl.StripHTML(string(c.Segments.Value(source))))
@@ -290,6 +316,11 @@ func textPlainTo(c ast.Node, source []byte, buf *bytes.Buffer) {
 		buf.Write(c.Value)
 	case *ast.Text:
 		buf.Write(c.Segment.Value(source))
+		if c.HardLineBreak() || c.SoftLineBreak() {
+			buf.WriteByte('\n')
+		}
+	case *east.Emoji:
+		buf.WriteString(string(c.ShortName))
 	default:
 		textPlainTo(c.FirstChild(), source, buf)
 	}


Test patch:
diff --git a/hugolib/content_render_hooks_test.go b/hugolib/content_render_hooks_test.go
index de8eb720f22..9df8d2e2e05 100644
--- a/hugolib/content_render_hooks_test.go
+++ b/hugolib/content_render_hooks_test.go
@@ -350,3 +350,31 @@ Image: ![alt-"<>&](/destination-"<> 'title-"<>&')
 		})
 	}
 }
+
+// Issue 13410.
+func TestRenderHooksMultilineTitlePlainText(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+-- content/p1.md --
+---
+title: "p1"
+---
+
+First line.
+Second line.
+----------------
+-- layouts/_default/_markup/render-heading.html --
+Plain text: {{ .PlainText }}|Text: {{ .Text }}|
+-- layouts/_default/single.html --
+Content: {{ .Content}}|
+}
+`
+	b := Test(t, files)
+
+	b.AssertFileContent("public/p1/index.html",
+		"Content: Plain text: First line.\nSecond line.|",
+		"|Text: First line.\nSecond line.||\n",
+	)
+}
diff --git a/markup/goldmark/goldmark_integration_test.go b/markup/goldmark/goldmark_integration_test.go
index 184d0d7fc8c..a656009515e 100644
--- a/markup/goldmark/goldmark_integration_test.go
+++ b/markup/goldmark/goldmark_integration_test.go
@@ -157,7 +157,7 @@ title: "p1"
 	b := hugolib.Test(t, files)
 
 	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>",
+		"<h2 id=\"hello-test\">\n  Hello <a href=\"https://example.com\">Test</a>\n\n  <a class=\"anchor\" href=\"#hello-test\">#</a>\n</h2>",
 	)
 }
 
diff --git a/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go b/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go
index fcce68ac238..90cf3d08419 100644
--- a/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go
+++ b/markup/goldmark/internal/extensions/attributes/attributes_integration_test.go
@@ -47,10 +47,12 @@ foo [something](/a/b/) bar
 Ā ā Ă ă Ą ą Ć ć Ĉ ĉ Ċ ċ Č č Ď
 : Testing accents.
 
-Mutiline set text header
+Multiline set text header
 Second line
 ---------------
 
+## Example [hyperlink](https://example.com/) in a header
+
 -- layouts/_default/single.html --
 {{ .Content }}|Identifiers: {{ .Fragments.Identifiers }}|
 `
@@ -68,7 +70,8 @@ Second line
 		`<dt id="my-title-1">My Title</dt>`,
 		`<dt id="term">良善天父</dt>`,
 		`<dt id="a-a-a-a-a-a-c-c-c-c-c-c-c-c-d">Ā ā Ă ă Ą ą Ć ć Ĉ ĉ Ċ ċ Č č Ď</dt>`,
-		`<h2 id="second-line">Mutiline set text header`,
-		"|Identifiers: [a-a-a-a-a-a-c-c-c-c-c-c-c-c-d base-name base-name-1 foo-something-bar foobar my-title my-title-1 second-line term title-with-id title-with-id]|",
+		`<h2 id="second-line">`,
+		`<h2 id="example-hyperlink-in-a-header">`,
+		"|Identifiers: [a-a-a-a-a-a-c-c-c-c-c-c-c-c-d base-name base-name-1 example-hyperlink-in-a-header foo-something-bar foobar my-title my-title-1 second-line term title-with-id title-with-id]|",
 	)
 }
diff --git a/markup/goldmark/toc_integration_test.go b/markup/goldmark/toc_integration_test.go
index 3b48dac6cfc..7ce2e86643f 100644
--- a/markup/goldmark/toc_integration_test.go
+++ b/markup/goldmark/toc_integration_test.go
@@ -239,12 +239,12 @@ title: p7 (emoji)
 
 	// image
 	b.AssertFileContent("public/p3/index.html", `
-<li><a href="#an-image-kittenajpg">An image <img src="a.jpg" alt="kitten" /></a></li>
+<li><a href="#an-image-kitten">An image <img src="a.jpg" alt="kitten" /></a></li>
 `)
 
 	// raw html
 	b.AssertFileContent("public/p4/index.html", `
-<li><a href="#some-spanrawspan-html">Some <span>raw</span> HTML</a></li>
+<li><a href="#some-raw-html">Some <span>raw</span> HTML</a></li>
 `)
 
 	// typographer
diff --git a/tpl/transform/transform_integration_test.go b/tpl/transform/transform_integration_test.go
index 276b9b059f7..b9a98185c80 100644
--- a/tpl/transform/transform_integration_test.go
+++ b/tpl/transform/transform_integration_test.go
@@ -263,6 +263,56 @@ $$%s$$
 	})
 }
 
+// Issue #13406.
+func TestToMathRenderHookPosition(t *testing.T) {
+	filesTemplate := `
+-- hugo.toml --
+disableKinds = ['rss','section','sitemap','taxonomy','term']
+[markup.goldmark.extensions.passthrough]
+enable = true
+[markup.goldmark.extensions.passthrough.delimiters]
+block  = [['\[', '\]'], ['$$', '$$']]
+inline = [['\(', '\)'], ['$', '$']]
+-- content/p1.md --
+---
+title: p1
+---
+
+Block:
+
+$$1+2$$
+
+Some inline $1+3$ math.
+
+-- layouts/index.html --
+Home.
+-- layouts/_default/single.html --
+Content: {{ .Content }}|
+-- layouts/_default/_markup/render-passthrough.html --
+{{ $opts := dict "throwOnError" true "displayMode" true }}
+{{- with try (transform.ToMath .Inner $opts ) }}
+  {{- with .Err }}
+    {{ errorf "KaTeX: %s: see %s." . $.Position }}
+  {{- else }}
+    {{- .Value }}
+  {{- end }}
+{{- end -}}
+
+`
+
+	// Block math.
+	files := strings.Replace(filesTemplate, "$$1+2$$", "$$\\foo1+2$$", 1)
+	b, err := hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.AssertLogContains("p1.md:6:1")
+
+	// Inline math.
+	files = strings.Replace(filesTemplate, "$1+3$", "$\\foo1+3$", 1)
+	b, err = hugolib.TestE(t, files)
+	b.Assert(err, qt.IsNotNil)
+	b.AssertLogContains("p1.md:8:13")
+}
+
 func TestToMathMacros(t *testing.T) {
 	files := `
 -- hugo.toml --