Repository: gohugoio/hugo (language: go)

Issue:
Render the render hooks when calling .Page.RenderShortcodes
I have no idea if this is possible.

#### Scenario

1. I have an "include" shortcode [as described in the documentation](https://gohugo.io/methods/page/rendershortcodes/).
2. I invoke the shortcode using the `{{% .. %}}` notation so that the headings in the included file appear in the TOC.
3. The shortcode calls `.Page.RenderShortcodes`.
4. The page I'm including is a leaf bundle that contains a markdown image that references a page resource. For example: `![alt](a.jpg)`.
5. I enabled the embedded image render hook so that the `src` attribute of the `img` element will be relative to the site root.

#### Problem

The image render hook is evaluated relative to the _including_ page, not relative to the _included_ page, resulting in a broken image.

The workaround is to call `.Content` instead of `.RenderShortcodes`, but then the headings of the included page do not contribute to the including page's TOC.

#### Minimal example

```text
git clone --single-branch -b hugo-github-issue-12356 https://github.com/jmooring/hugo-testing hugo-github-issue-12356
cd hugo-github-issue-12356
hugo server
```

#### Notes

If this were possible, I think the site author would need to set `markup.goldmark.renderer.unsafe` to `true`, which isn't great, but it's better than a broken image.



Discussion / hints:

Fix patch:
diff --git a/hugolib/content_map_page.go b/hugolib/content_map_page.go
index 336fe197c36..aa32b532071 100644
--- a/hugolib/content_map_page.go
+++ b/hugolib/content_map_page.go
@@ -1603,7 +1603,7 @@ func (sa *sitePagesAssembler) assembleResources() error {
 			targetPaths := ps.targetPaths()
 			baseTarget := targetPaths.SubResourceBaseTarget
 			duplicateResourceFiles := true
-			if ps.s.ContentSpec.Converters.IsGoldmark(ps.m.pageConfig.Markup) {
+			if ps.m.pageConfig.IsGoldmark {
 				duplicateResourceFiles = ps.s.ContentSpec.Converters.GetMarkupConfig().Goldmark.DuplicateResourceFiles
 			}
 
diff --git a/hugolib/page__content.go b/hugolib/page__content.go
index 54f7be9618a..0c6c2b87f09 100644
--- a/hugolib/page__content.go
+++ b/hugolib/page__content.go
@@ -522,6 +522,7 @@ func (c *cachedContent) contentRendered(ctx context.Context, cp *pageContentOutp
 			if err != nil {
 				return nil, err
 			}
+
 			if !ok {
 				return nil, errors.New("invalid state: astDoc is set but RenderContent returned false")
 			}
@@ -626,8 +627,10 @@ func (c *cachedContent) contentToC(ctx context.Context, cp *pageContentOutput) (
 			return nil, err
 		}
 
-		// Callback called from above (e.g. in .RenderString)
+		// Callback called from below (e.g. in .RenderString)
 		ctxCallback := func(cp2 *pageContentOutput, ct2 contentTableOfContents) {
+			cp.otherOutputs[cp2.po.p.pid] = cp2
+
 			// Merge content placeholders
 			for k, v := range ct2.contentPlaceholders {
 				ct.contentPlaceholders[k] = v
diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go
index ebf57f3b302..d8203fe751f 100644
--- a/hugolib/page__meta.go
+++ b/hugolib/page__meta.go
@@ -737,6 +737,8 @@ func (p *pageMeta) applyDefaultValues() error {
 		}
 	}
 
+	p.pageConfig.IsGoldmark = p.s.ContentSpec.Converters.IsGoldmark(p.pageConfig.Markup)
+
 	if p.pageConfig.Title == "" && p.f == nil {
 		switch p.Kind() {
 		case kinds.KindHome:
@@ -794,12 +796,26 @@ func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.
 		path = p.Path()
 	}
 
+	doc := newPageForRenderHook(ps)
+
+	documentLookup := func(id uint64) any {
+		if id == ps.pid {
+			// This prevents infinite recursion in some cases.
+			return doc
+		}
+		if v, ok := ps.pageOutput.pco.otherOutputs[id]; ok {
+			return v.po.p
+		}
+		return nil
+	}
+
 	cpp, err := cp.New(
 		converter.DocumentContext{
-			Document:     newPageForRenderHook(ps),
-			DocumentID:   id,
-			DocumentName: path,
-			Filename:     filename,
+			Document:       doc,
+			DocumentLookup: documentLookup,
+			DocumentID:     id,
+			DocumentName:   path,
+			Filename:       filename,
 		},
 	)
 	if err != nil {
diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go
index 7220c86fe43..79a5e94f3b4 100644
--- a/hugolib/page__per_output.go
+++ b/hugolib/page__per_output.go
@@ -30,6 +30,7 @@ import (
 	"github.com/spf13/cast"
 
 	"github.com/gohugoio/hugo/markup/converter/hooks"
+	"github.com/gohugoio/hugo/markup/goldmark/hugocontext"
 	"github.com/gohugoio/hugo/markup/highlight/chromalexers"
 	"github.com/gohugoio/hugo/markup/tableofcontents"
 
@@ -68,8 +69,9 @@ var (
 
 func newPageContentOutput(po *pageOutput) (*pageContentOutput, error) {
 	cp := &pageContentOutput{
-		po:          po,
-		renderHooks: &renderHooks{},
+		po:           po,
+		renderHooks:  &renderHooks{},
+		otherOutputs: make(map[uint64]*pageContentOutput),
 	}
 	return cp, nil
 }
@@ -83,6 +85,10 @@ type renderHooks struct {
 type pageContentOutput struct {
 	po *pageOutput
 
+	// Other pages involved in rendering of this page,
+	// typically included with .RenderShortcodes.
+	otherOutputs map[uint64]*pageContentOutput
+
 	contentRenderedVersion int  // Incremented on reset.
 	contentRendered        bool // Set on content render.
 
@@ -165,6 +171,13 @@ func (pco *pageContentOutput) RenderShortcodes(ctx context.Context) (template.HT
 		cb(pco, ct)
 	}
 
+	if tpl.Context.IsInGoldmark.Get(ctx) {
+		// This content will be parsed and rendered by Goldmark.
+		// Wrap it in a special Hugo markup to assign the correct Page from
+		// the stack.
+		c = hugocontext.Wrap(c, pco.po.p.pid)
+	}
+
 	return helpers.BytesToHTML(c), nil
 }
 
diff --git a/hugolib/shortcode.go b/hugolib/shortcode.go
index 7cc2cba278b..af4454a89c2 100644
--- a/hugolib/shortcode.go
+++ b/hugolib/shortcode.go
@@ -321,10 +321,16 @@ func prepareShortcode(
 
 	// Allow the caller to delay the rendering of the shortcode if needed.
 	var fn shortcodeRenderFunc = func(ctx context.Context) ([]byte, bool, error) {
+		if p.m.pageConfig.IsGoldmark && sc.doMarkup {
+			// Signal downwards that the content rendered will be
+			// parsed and rendered by Goldmark.
+			ctx = tpl.Context.IsInGoldmark.Set(ctx, true)
+		}
 		r, err := doRenderShortcode(ctx, level, s, tplVariants, sc, parent, p, isRenderString)
 		if err != nil {
 			return nil, false, toParseErr(err)
 		}
+
 		b, hasVariants, err := r.renderShortcode(ctx)
 		if err != nil {
 			return nil, false, toParseErr(err)
diff --git a/markup/converter/converter.go b/markup/converter/converter.go
index b66cb873004..57980f1384f 100644
--- a/markup/converter/converter.go
+++ b/markup/converter/converter.go
@@ -135,10 +135,11 @@ func (b Bytes) Bytes() []byte {
 
 // DocumentContext holds contextual information about the document to convert.
 type DocumentContext struct {
-	Document     any // May be nil. Usually a page.Page
-	DocumentID   string
-	DocumentName string
-	Filename     string
+	Document       any              // May be nil. Usually a page.Page
+	DocumentLookup func(uint64) any // May be nil.
+	DocumentID     string
+	DocumentName   string
+	Filename       string
 }
 
 // RenderContext holds contextual information about the content to render.
diff --git a/markup/converter/hooks/hooks.go b/markup/converter/hooks/hooks.go
index bdc38f119e1..54babd32086 100644
--- a/markup/converter/hooks/hooks.go
+++ b/markup/converter/hooks/hooks.go
@@ -32,8 +32,7 @@ type AttributesProvider interface {
 
 // LinkContext is the context passed to a link render hook.
 type LinkContext interface {
-	// The Page being rendered.
-	Page() any
+	PageProvider
 
 	// The link URL.
 	Destination() string
@@ -64,6 +63,7 @@ type ImageLinkContext interface {
 type CodeblockContext interface {
 	AttributesProvider
 	text.Positioner
+	PageProvider
 
 	// Chroma highlighting processing options. This will only be filled if Type is a known Chroma Lexer.
 	Options() map[string]any
@@ -76,9 +76,6 @@ type CodeblockContext interface {
 
 	// Zero-based ordinal for all code blocks in the current document.
 	Ordinal() int
-
-	// The owning Page.
-	Page() any
 }
 
 type AttributesOptionsSliceProvider interface {
@@ -101,8 +98,7 @@ type IsDefaultCodeBlockRendererProvider interface {
 // HeadingContext contains accessors to all attributes that a HeadingRenderer
 // can use to render a heading.
 type HeadingContext interface {
-	// Page is the page containing the heading.
-	Page() any
+	PageProvider
 	// Level is the level of the header (i.e. 1 for top-level, 2 for sub-level, etc.).
 	Level() int
 	// Anchor is the HTML id assigned to the heading.
@@ -116,6 +112,16 @@ type HeadingContext interface {
 	AttributesProvider
 }
 
+type PageProvider interface {
+	// Page is the page being rendered.
+	Page() any
+
+	// PageInner may be different than Page when .RenderShortcodes is in play.
+	// The main use case for this is to include other pages' markdown into the current page
+	// but resolve resources and pages relative to the original.
+	PageInner() any
+}
+
 // HeadingRenderer describes a uniquely identifiable rendering hook.
 type HeadingRenderer interface {
 	// RenderHeading writes the rendered content to w using the data in w.
diff --git a/markup/goldmark/codeblocks/render.go b/markup/goldmark/codeblocks/render.go
index 67053640dfa..5dfa8262fed 100644
--- a/markup/goldmark/codeblocks/render.go
+++ b/markup/goldmark/codeblocks/render.go
@@ -108,6 +108,7 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
 	}
 	cbctx := &codeBlockContext{
 		page:             ctx.DocumentContext().Document,
+		pageInner:        r.getPageInner(ctx),
 		lang:             lang,
 		code:             s,
 		ordinal:          ordinal,
@@ -132,7 +133,6 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
 		w,
 		cbctx,
 	)
-
 	if err != nil {
 		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, cbctx.createPos())
 	}
@@ -140,11 +140,24 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
 	return ast.WalkContinue, nil
 }
 
+func (r *htmlRenderer) getPageInner(rctx *render.Context) any {
+	pid := rctx.PeekPid()
+	if pid > 0 {
+		if lookup := rctx.DocumentContext().DocumentLookup; lookup != nil {
+			if v := rctx.DocumentContext().DocumentLookup(pid); v != nil {
+				return v
+			}
+		}
+	}
+	return rctx.DocumentContext().Document
+}
+
 type codeBlockContext struct {
-	page    any
-	lang    string
-	code    string
-	ordinal int
+	page      any
+	pageInner any
+	lang      string
+	code      string
+	ordinal   int
 
 	// This is only used in error situations and is expensive to create,
 	// to delay creation until needed.
@@ -159,6 +172,10 @@ func (c *codeBlockContext) Page() any {
 	return c.page
 }
 
+func (c *codeBlockContext) PageInner() any {
+	return c.pageInner
+}
+
 func (c *codeBlockContext) Type() string {
 	return c.lang
 }
diff --git a/markup/goldmark/convert.go b/markup/goldmark/convert.go
index 643293c9348..d7180140d91 100644
--- a/markup/goldmark/convert.go
+++ b/markup/goldmark/convert.go
@@ -18,6 +18,7 @@ import (
 	"bytes"
 
 	"github.com/gohugoio/hugo-goldmark-extensions/passthrough"
+	"github.com/gohugoio/hugo/markup/goldmark/hugocontext"
 	"github.com/yuin/goldmark/util"
 
 	"github.com/gohugoio/hugo/markup/goldmark/codeblocks"
@@ -103,6 +104,7 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown {
 		renderer.WithNodeRenderers(util.Prioritized(emoji.NewHTMLRenderer(), 200)))
 	var (
 		extensions = []goldmark.Extender{
+			hugocontext.New(),
 			newLinks(cfg),
 			newTocExtension(tocRendererOptions),
 		}
diff --git a/markup/goldmark/hugocontext/hugocontext.go b/markup/goldmark/hugocontext/hugocontext.go
new file mode 100644
index 00000000000..ed62bb8c6ee
--- /dev/null
+++ b/markup/goldmark/hugocontext/hugocontext.go
@@ -0,0 +1,165 @@
+// Copyright 2024 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package hugocontext
+
+import (
+	"bytes"
+	"fmt"
+	"strconv"
+
+	"github.com/gohugoio/hugo/bufferpool"
+	"github.com/gohugoio/hugo/markup/goldmark/internal/render"
+	"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"
+)
+
+func New() goldmark.Extender {
+	return &hugoContextExtension{}
+}
+
+// Wrap wraps the given byte slice in a Hugo context that used to determine the correct Page
+// in .RenderShortcodes.
+func Wrap(b []byte, pid uint64) []byte {
+	buf := bufferpool.GetBuffer()
+	defer bufferpool.PutBuffer(buf)
+	buf.Write(prefix)
+	buf.WriteString(" pid=")
+	buf.WriteString(strconv.FormatUint(pid, 10))
+	buf.Write(endDelim)
+	buf.WriteByte('\n')
+	buf.Write(b)
+	buf.Write(prefix)
+	buf.Write(closingDelimAndNewline)
+	return buf.Bytes()
+}
+
+var kindHugoContext = ast.NewNodeKind("HugoContext")
+
+// HugoContext is a node that represents a Hugo context.
+type HugoContext struct {
+	ast.BaseInline
+
+	Closing bool
+
+	// Internal page ID. Not persisted.
+	Pid uint64
+}
+
+// Dump implements Node.Dump.
+func (n *HugoContext) Dump(source []byte, level int) {
+	m := map[string]string{}
+	m["Pid"] = fmt.Sprintf("%v", n.Pid)
+	ast.DumpHelper(n, source, level, m, nil)
+}
+
+func (n *HugoContext) parseAttrs(attrBytes []byte) {
+	keyPairs := bytes.Split(attrBytes, []byte(" "))
+	for _, keyPair := range keyPairs {
+		kv := bytes.Split(keyPair, []byte("="))
+		if len(kv) != 2 {
+			continue
+		}
+		key := string(kv[0])
+		val := string(kv[1])
+		switch key {
+		case "pid":
+			pid, _ := strconv.ParseUint(val, 10, 64)
+			n.Pid = pid
+		}
+	}
+}
+
+func (h *HugoContext) Kind() ast.NodeKind {
+	return kindHugoContext
+}
+
+var (
+	prefix                 = []byte("{{__hugo_ctx")
+	endDelim               = []byte("}}")
+	closingDelimAndNewline = []byte("/}}\n")
+)
+
+var _ parser.InlineParser = (*hugoContextParser)(nil)
+
+type hugoContextParser struct{}
+
+func (s *hugoContextParser) Parse(parent ast.Node, block text.Reader, pc parser.Context) ast.Node {
+	line, _ := block.PeekLine()
+	if !bytes.HasPrefix(line, prefix) {
+		return nil
+	}
+	end := bytes.Index(line, endDelim)
+	if end == -1 {
+		return nil
+	}
+
+	block.Advance(end + len(endDelim) + 1) // +1 for the newline
+
+	if line[end-1] == '/' {
+		return &HugoContext{Closing: true}
+	}
+
+	attrBytes := line[len(prefix)+1 : end]
+	h := &HugoContext{}
+	h.parseAttrs(attrBytes)
+	return h
+}
+
+func (a *hugoContextParser) Trigger() []byte {
+	return []byte{'{'}
+}
+
+type hugoContextRenderer struct{}
+
+func (r *hugoContextRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(kindHugoContext, r.handleHugoContext)
+}
+
+func (r *hugoContextRenderer) handleHugoContext(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	if !entering {
+		return ast.WalkContinue, nil
+	}
+
+	hctx := node.(*HugoContext)
+	ctx, ok := w.(*render.Context)
+	if !ok {
+		return ast.WalkContinue, nil
+	}
+	if hctx.Closing {
+		_ = ctx.PopPid()
+	} else {
+		ctx.PushPid(hctx.Pid)
+	}
+	return ast.WalkContinue, nil
+}
+
+type hugoContextExtension struct{}
+
+func (a *hugoContextExtension) Extend(m goldmark.Markdown) {
+	m.Parser().AddOptions(
+		parser.WithInlineParsers(
+			util.Prioritized(&hugoContextParser{}, 50),
+		),
+	)
+
+	m.Renderer().AddOptions(
+		renderer.WithNodeRenderers(
+			util.Prioritized(&hugoContextRenderer{}, 50),
+		),
+	)
+}
diff --git a/markup/goldmark/internal/render/context.go b/markup/goldmark/internal/render/context.go
index 5787143396d..306d26748b1 100644
--- a/markup/goldmark/internal/render/context.go
+++ b/markup/goldmark/internal/render/context.go
@@ -41,6 +41,7 @@ func (b *BufWriter) Flush() error {
 type Context struct {
 	*BufWriter
 	positions []int
+	pids      []uint64
 	ContextData
 }
 
@@ -55,6 +56,30 @@ func (ctx *Context) PopPos() int {
 	return p
 }
 
+// PushPid pushes a new page ID to the stack.
+func (ctx *Context) PushPid(pid uint64) {
+	ctx.pids = append(ctx.pids, pid)
+}
+
+// PeekPid returns the current page ID without removing it from the stack.
+func (ctx *Context) PeekPid() uint64 {
+	if len(ctx.pids) == 0 {
+		return 0
+	}
+	return ctx.pids[len(ctx.pids)-1]
+}
+
+// PopPid pops the last page ID from the stack.
+func (ctx *Context) PopPid() uint64 {
+	if len(ctx.pids) == 0 {
+		return 0
+	}
+	i := len(ctx.pids) - 1
+	p := ctx.pids[i]
+	ctx.pids = ctx.pids[:i]
+	return p
+}
+
 type ContextData interface {
 	RenderContext() converter.RenderContext
 	DocumentContext() converter.DocumentContext
diff --git a/markup/goldmark/render_hooks.go b/markup/goldmark/render_hooks.go
index 8dcdc39c3a4..c127a2c0e86 100644
--- a/markup/goldmark/render_hooks.go
+++ b/markup/goldmark/render_hooks.go
@@ -49,6 +49,7 @@ func newLinks(cfg goldmark_config.Config) goldmark.Extender {
 
 type linkContext struct {
 	page        any
+	pageInner   any
 	destination string
 	title       string
 	text        hstring.RenderedString
@@ -64,6 +65,10 @@ func (ctx linkContext) Page() any {
 	return ctx.page
 }
 
+func (ctx linkContext) PageInner() any {
+	return ctx.pageInner
+}
+
 func (ctx linkContext) Text() hstring.RenderedString {
 	return ctx.text
 }
@@ -92,6 +97,7 @@ func (ctx imageLinkContext) Ordinal() int {
 
 type headingContext struct {
 	page      any
+	pageInner any
 	level     int
 	anchor    string
 	text      hstring.RenderedString
@@ -103,6 +109,10 @@ func (ctx headingContext) Page() any {
 	return ctx.page
 }
 
+func (ctx headingContext) PageInner() any {
+	return ctx.pageInner
+}
+
 func (ctx headingContext) Level() int {
 	return ctx.level
 }
@@ -186,6 +196,7 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
 		imageLinkContext{
 			linkContext: linkContext{
 				page:             ctx.DocumentContext().Document,
+				pageInner:        r.getPageInner(ctx),
 				destination:      string(n.Destination),
 				title:            string(n.Title),
 				text:             hstring.RenderedString(text),
@@ -200,6 +211,18 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
 	return ast.WalkContinue, err
 }
 
+func (r *hookedRenderer) getPageInner(rctx *render.Context) any {
+	pid := rctx.PeekPid()
+	if pid > 0 {
+		if lookup := rctx.DocumentContext().DocumentLookup; lookup != nil {
+			if v := rctx.DocumentContext().DocumentLookup(pid); v != nil {
+				return v
+			}
+		}
+	}
+	return rctx.DocumentContext().Document
+}
+
 func (r *hookedRenderer) filterInternalAttributes(attrs []ast.Attribute) []ast.Attribute {
 	n := 0
 	for _, x := range attrs {
@@ -274,6 +297,7 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
 		w,
 		linkContext{
 			page:             ctx.DocumentContext().Document,
+			pageInner:        r.getPageInner(ctx),
 			destination:      string(n.Destination),
 			title:            string(n.Title),
 			text:             hstring.RenderedString(text),
@@ -339,6 +363,7 @@ func (r *hookedRenderer) renderAutoLink(w util.BufWriter, source []byte, node as
 		w,
 		linkContext{
 			page:             ctx.DocumentContext().Document,
+			pageInner:        r.getPageInner(ctx),
 			destination:      url,
 			text:             hstring.RenderedString(label),
 			plainText:        label,
@@ -423,6 +448,7 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
 		w,
 		headingContext{
 			page:             ctx.DocumentContext().Document,
+			pageInner:        r.getPageInner(ctx),
 			level:            n.Level,
 			anchor:           string(anchor),
 			text:             hstring.RenderedString(text),
diff --git a/resources/page/pagemeta/page_frontmatter.go b/resources/page/pagemeta/page_frontmatter.go
index 4e412c666d4..123dd4b704d 100644
--- a/resources/page/pagemeta/page_frontmatter.go
+++ b/resources/page/pagemeta/page_frontmatter.go
@@ -88,6 +88,9 @@ type PageConfig struct {
 
 	// User defined params.
 	Params maps.Params
+
+	// Compiled values.
+	IsGoldmark bool `json:"-"`
 }
 
 // FrontMatterHandler maps front matter into Page fields and .Params.
diff --git a/tpl/template.go b/tpl/template.go
index e9725bd7412..5ef0eecb840 100644
--- a/tpl/template.go
+++ b/tpl/template.go
@@ -165,10 +165,12 @@ var Context = struct {
 	SetDependencyManagerInCurrentScope func(context.Context, identity.Manager) context.Context
 	DependencyScope                    hcontext.ContextDispatcher[int]
 	Page                               hcontext.ContextDispatcher[page]
+	IsInGoldmark                       hcontext.ContextDispatcher[bool]
 }{
 	DependencyManagerScopedProvider: hcontext.NewContextDispatcher[identity.DependencyManagerScopedProvider](contextKey("DependencyManagerScopedProvider")),
 	DependencyScope:                 hcontext.NewContextDispatcher[int](contextKey("DependencyScope")),
 	Page:                            hcontext.NewContextDispatcher[page](contextKey("Page")),
+	IsInGoldmark:                    hcontext.NewContextDispatcher[bool](contextKey("IsInGoldmark")),
 }
 
 func init() {
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html b/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
index fa78cd00c15..013e31235a4 100644
--- a/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
+++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
@@ -1,7 +1,7 @@
 {{- $u := urls.Parse .Destination -}}
 {{- $src := $u.String -}}
 {{- if not $u.IsAbs -}}
-  {{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}}
+  {{- with or (.PageInner.Resources.Get $u.Path) (resources.Get $u.Path) -}}
     {{- $src = .RelPermalink -}}
   {{- end -}}
 {{- end -}}
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html b/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
index cfc95ab3a2d..8903d3dfbbb 100644
--- a/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
+++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
@@ -1,11 +1,11 @@
 {{- $u := urls.Parse .Destination -}}
 {{- $href := $u.String -}}
 {{- if strings.HasPrefix $u.String "#" }}
-  {{- $href = printf "%s#%s" .Page.RelPermalink $u.Fragment }}
+  {{- $href = printf "%s#%s" .PageInner.RelPermalink $u.Fragment }}
 {{- else if not $u.IsAbs -}}
   {{- with or
-    ($.Page.GetPage $u.Path)
-    ($.Page.Resources.Get $u.Path)
+    ($.PageInner.GetPage $u.Path)
+    ($.PageInner.Resources.Get $u.Path)
     (resources.Get $u.Path)
   -}}
     {{- $href = .RelPermalink -}}


Test patch:
diff --git a/hugolib/rendershortcodes_test.go b/hugolib/rendershortcodes_test.go
index 696ed8f416c..67b1a85ef63 100644
--- a/hugolib/rendershortcodes_test.go
+++ b/hugolib/rendershortcodes_test.go
@@ -200,3 +200,99 @@ Myshort Original.
 	b.Build()
 	b.AssertFileContent("public/p1/index.html", "Edited")
 }
+
+func TestRenderShortcodesNestedPageContextIssue12356(t *testing.T) {
+	t.Parallel()
+
+	files := `
+-- hugo.toml --
+disableKinds = ["taxonomy", "term", "rss", "sitemap", "robotsTXT", "404"]
+-- layouts/_default/_markup/render-image.html --
+{{- with .PageInner.Resources.Get .Destination -}}Image: {{ .RelPermalink }}|{{- end -}}
+-- layouts/_default/_markup/render-link.html --
+{{- with .PageInner.GetPage .Destination -}}Link: {{ .RelPermalink }}|{{- end -}}
+-- layouts/_default/_markup/render-heading.html --
+Heading: {{ .PageInner.Title }}: {{ .PlainText }}|
+-- layouts/_default/_markup/render-codeblock.html --
+CodeBlock: {{ .PageInner.Title }}: {{ .Type }}|
+-- layouts/_default/list.html --
+Content:{{ .Content }}|
+Fragments: {{ with .Fragments }}{{.Identifiers }}{{ end }}|
+-- layouts/_default/single.html --
+Content:{{ .Content }}|
+-- layouts/shortcodes/include.html --
+{{ with site.GetPage (.Get 0) }}
+  {{ .RenderShortcodes }}
+{{ end }}
+-- content/markdown/_index.md --
+---
+title: "Markdown"
+---
+# H1
+|{{% include "/posts/p1" %}}|
+![kitten](pixel3.png "Pixel 3")
+
+§§§go
+fmt.Println("Hello")
+§§§
+
+-- content/markdown2/_index.md --
+---
+title: "Markdown 2"
+---
+|{{< include "/posts/p1" >}}|
+-- content/html/_index.html --
+---
+title: "HTML"
+---
+|{{% include "/posts/p1" %}}|
+
+-- content/posts/p1/index.md --
+---
+title: "p1"
+---
+## H2-p1
+![kitten](pixel1.png "Pixel 1")
+![kitten](pixel2.png "Pixel 2")
+[p2](p2)
+
+§§§bash
+echo "Hello"
+§§§
+
+-- content/posts/p2/index.md --
+---
+title: "p2"
+---
+-- content/posts/p1/pixel1.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- content/posts/p1/pixel2.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- content/markdown/pixel3.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- content/html/pixel4.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+
+`
+
+	b := Test(t, files)
+
+	b.AssertFileContent("public/markdown/index.html",
+		// Images.
+		"Image: /posts/p1/pixel1.png|\nImage: /posts/p1/pixel2.png|\n|\nImage: /markdown/pixel3.png|</p>\n|",
+		// Links.
+		"Link: /posts/p2/|",
+		// Code blocks
+		"CodeBlock: p1: bash|", "CodeBlock: Markdown: go|",
+		// Headings.
+		"Heading: Markdown: H1|", "Heading: p1: H2-p1|",
+		// Fragments.
+		"Fragments: [h1 h2-p1]|",
+		// Check that the special context markup is not rendered.
+		"! hugo_ctx",
+	)
+
+	b.AssertFileContent("public/markdown2/index.html", "! hugo_ctx", "Content:<p>|\n  ![kitten](pixel1.png \"Pixel 1\")\n![kitten](pixel2.png \"Pixel 2\")\n|</p>\n|")
+
+	b.AssertFileContent("public/html/index.html", "! hugo_ctx")
+}
diff --git a/markup/goldmark/hugocontext/hugocontext_test.go b/markup/goldmark/hugocontext/hugocontext_test.go
new file mode 100644
index 00000000000..da96cc3392b
--- /dev/null
+++ b/markup/goldmark/hugocontext/hugocontext_test.go
@@ -0,0 +1,34 @@
+// Copyright 2024 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.
+// You may obtain a copy of the License at
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package hugocontext
+
+import (
+	"testing"
+
+	qt "github.com/frankban/quicktest"
+)
+
+func TestWrap(t *testing.T) {
+	c := qt.New(t)
+
+	b := []byte("test")
+
+	c.Assert(string(Wrap(b, 42)), qt.Equals, "{{__hugo_ctx pid=42}}\ntest{{__hugo_ctx/}}\n")
+}
+
+func BenchmarkWrap(b *testing.B) {
+	for i := 0; i < b.N; i++ {
+		Wrap([]byte("test"), 42)
+	}
+}