Repository: gohugoio/hugo (language: go)

Issue:
Render hook template lookup doesn't always fallback to other output format variants
I found this debugging an issue with #9316.

I have a failing test case for this, but it's a little hard to explain. It should be rare, but it became visible with the new table render hooks, where we don't have any Goldmark defaults to fall back on.


Discussion / hints:

Fix patch:
diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go
index f074e8db716..6ebddbe442e 100644
--- a/hugolib/page__per_output.go
+++ b/hugolib/page__per_output.go
@@ -296,6 +296,8 @@ func (pco *pageContentOutput) initRenderHooks() error {
 				if id != nil {
 					layoutDescriptor.KindVariants = id.(string)
 				}
+			case hooks.TableRendererType:
+				layoutDescriptor.Kind = "render-table"
 			case hooks.CodeBlockRendererType:
 				layoutDescriptor.Kind = "render-codeblock"
 				if id != nil {
@@ -334,13 +336,23 @@ func (pco *pageContentOutput) initRenderHooks() error {
 
 			templ, found1 := getHookTemplate(pco.po.f)
 
-			if pco.po.p.reusePageOutputContent() {
+			if !found1 || pco.po.p.reusePageOutputContent() {
+				// Some hooks may only be available in HTML, and if
+				// this site is configured to not have HTML output, we need to
+				// make sure we have a fallback. This should be very rare.
+				candidates := pco.po.p.s.renderFormats
+				if pco.po.f.MediaType.FirstSuffix.Suffix != "html" {
+					if _, found := candidates.GetBySuffix("html"); !found {
+						candidates = append(candidates, output.HTMLFormat)
+					}
+				}
 				// Check if some of the other output formats would give a different template.
-				for _, f := range pco.po.p.s.renderFormats {
+				for _, f := range candidates {
 					if f.Name == pco.po.f.Name {
 						continue
 					}
 					templ2, found2 := getHookTemplate(f)
+
 					if found2 {
 						if !found1 {
 							templ = templ2
diff --git a/hugolib/site.go b/hugolib/site.go
index a93bbdbe63b..d0546e9107f 100644
--- a/hugolib/site.go
+++ b/hugolib/site.go
@@ -930,6 +930,10 @@ func (hr hookRendererTemplate) RenderBlockquote(cctx context.Context, w hugio.Fl
 	return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx)
 }
 
+func (hr hookRendererTemplate) RenderTable(cctx context.Context, w hugio.FlexiWriter, ctx hooks.TableContext) error {
+	return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx)
+}
+
 func (hr hookRendererTemplate) ResolvePosition(ctx any) text.Position {
 	return hr.resolvePosition(ctx)
 }
diff --git a/markup/converter/hooks/hooks.go b/markup/converter/hooks/hooks.go
index 29e848d80cc..9487fd0a710 100644
--- a/markup/converter/hooks/hooks.go
+++ b/markup/converter/hooks/hooks.go
@@ -61,9 +61,8 @@ type ImageLinkContext interface {
 
 // CodeblockContext is the context passed to a code block render hook.
 type CodeblockContext interface {
+	BaseContext
 	AttributesProvider
-	text.Positioner
-	PageProvider
 
 	// Chroma highlighting processing options. This will only be filled if Type is a known Chroma Lexer.
 	Options() map[string]any
@@ -73,19 +72,31 @@ type CodeblockContext interface {
 
 	// The text between the code fences.
 	Inner() string
-
-	// Zero-based ordinal for all code blocks in the current document.
-	Ordinal() int
 }
 
-// BlockquoteContext is the context passed to a blockquote render hook.
-type BlockquoteContext interface {
+// TableContext is the context passed to a table render hook.
+type TableContext interface {
+	BaseContext
 	AttributesProvider
+
+	THead() []TableRow
+	TBody() []TableRow
+}
+
+// BaseContext is the base context used in most render hooks.
+type BaseContext interface {
 	text.Positioner
 	PageProvider
 
-	// Zero-based ordinal for all block quotes in the current document.
+	// Zero-based ordinal for all elements of this kind in the current document.
 	Ordinal() int
+}
+
+// BlockquoteContext is the context passed to a blockquote render hook.
+type BlockquoteContext interface {
+	BaseContext
+
+	AttributesProvider
 
 	// The blockquote text.
 	// If type is "alert", this will be the alert text.
@@ -107,18 +118,14 @@ type PositionerSourceTargetProvider interface {
 
 // PassThroughContext is the context passed to a passthrough render hook.
 type PassthroughContext interface {
+	BaseContext
 	AttributesProvider
-	text.Positioner
-	PageProvider
 
 	// Currently one of "inline" or "block".
 	Type() string
 
 	// The inner content of the passthrough element, excluding the delimiters.
 	Inner() string
-
-	// Zero-based ordinal for all passthrough elements in the document.
-	Ordinal() int
 }
 
 type AttributesOptionsSliceProvider interface {
@@ -138,6 +145,10 @@ type BlockquoteRenderer interface {
 	RenderBlockquote(cctx context.Context, w hugio.FlexiWriter, ctx BlockquoteContext) error
 }
 
+type TableRenderer interface {
+	RenderTable(cctx context.Context, w hugio.FlexiWriter, ctx TableContext) error
+}
+
 type PassthroughRenderer interface {
 	RenderPassthrough(cctx context.Context, w io.Writer, ctx PassthroughContext) error
 }
@@ -196,6 +207,19 @@ const (
 	CodeBlockRendererType
 	PassthroughRendererType
 	BlockquoteRendererType
+	TableRendererType
 )
 
 type GetRendererFunc func(t RendererType, id any) any
+
+type TableCell struct {
+	Text      hstring.RenderedString
+	Alignment string // left, center, or right
+}
+
+type TableRow []TableCell
+
+type Table struct {
+	THead []TableRow
+	TBody []TableRow
+}
diff --git a/markup/goldmark/blockquotes/blockquotes.go b/markup/goldmark/blockquotes/blockquotes.go
index d26c92669fd..a261ec4fe39 100644
--- a/markup/goldmark/blockquotes/blockquotes.go
+++ b/markup/goldmark/blockquotes/blockquotes.go
@@ -16,10 +16,8 @@ package blockquotes
 import (
 	"regexp"
 	"strings"
-	"sync"
 
 	"github.com/gohugoio/hugo/common/herrors"
-	htext "github.com/gohugoio/hugo/common/text"
 	"github.com/gohugoio/hugo/common/types/hstring"
 	"github.com/gohugoio/hugo/markup/converter/hooks"
 	"github.com/gohugoio/hugo/markup/goldmark/internal/render"
@@ -71,70 +69,36 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
 		return ast.WalkContinue, nil
 	}
 
-	pos := ctx.PopPos()
-	text := ctx.Buffer.Bytes()[pos:]
-	ctx.Buffer.Truncate(pos)
+	text := ctx.PopRenderedString()
 
 	ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote)
 
-	texts := string(text)
 	typ := typeRegular
-	alertType := resolveGitHubAlert(texts)
+	alertType := resolveGitHubAlert(string(text))
 	if alertType != "" {
 		typ = typeAlert
 	}
 
 	renderer := ctx.RenderContext().GetRenderer(hooks.BlockquoteRendererType, typ)
 	if renderer == nil {
-		return r.renderBlockquoteDefault(w, n, texts)
+		return r.renderBlockquoteDefault(w, n, text)
 	}
 
 	if typ == typeAlert {
 		// Trim preamble: <p>[!NOTE]<br>\n but preserve leading paragraph.
 		// We could possibly complicate this by moving this to the parser, but
 		// keep it simple for now.
-		texts = "<p>" + texts[strings.Index(texts, "\n")+1:]
-	}
-
-	var sourceRef []byte
-
-	// Extract a source sample to use for position information.
-	if nn := n.FirstChild(); nn != nil {
-		var start, stop int
-		for i := 0; i < nn.Lines().Len() && i < 2; i++ {
-			line := nn.Lines().At(i)
-			if i == 0 {
-				start = line.Start
-			}
-			stop = line.Stop
-		}
-		// We do not mutate the source, so this is safe.
-		sourceRef = src[start:stop]
+		text = "<p>" + text[strings.Index(text, "\n")+1:]
 	}
 
 	bqctx := &blockquoteContext{
-		page:             ctx.DocumentContext().Document,
-		pageInner:        r.getPageInner(ctx),
+		BaseContext:      render.NewBaseContext(ctx, renderer, n, src, nil, ordinal),
 		typ:              typ,
 		alertType:        alertType,
-		text:             hstring.RenderedString(texts),
-		sourceRef:        sourceRef,
-		ordinal:          ordinal,
+		text:             hstring.RenderedString(text),
 		AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral),
 	}
 
-	bqctx.createPos = func() htext.Position {
-		if resolver, ok := renderer.(hooks.ElementPositionResolver); ok {
-			return resolver.ResolvePosition(bqctx)
-		}
-
-		return htext.Position{
-			Filename:     ctx.DocumentContext().Filename,
-			LineNumber:   1,
-			ColumnNumber: 1,
-		}
-	}
-
 	cr := renderer.(hooks.BlockquoteRenderer)
 
 	err := cr.RenderBlockquote(
@@ -143,24 +107,12 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
 		bqctx,
 	)
 	if err != nil {
-		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, bqctx.createPos())
+		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, bqctx.Position())
 	}
 
 	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
-}
-
 // Code borrowed from goldmark's html renderer.
 func (r *htmlRenderer) renderBlockquoteDefault(
 	w util.BufWriter, n ast.Node, text string,
@@ -180,19 +132,11 @@ func (r *htmlRenderer) renderBlockquoteDefault(
 }
 
 type blockquoteContext struct {
-	page      any
-	pageInner any
+	hooks.BaseContext
+
 	text      hstring.RenderedString
-	typ       string
-	sourceRef []byte
 	alertType string
-	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
+	typ       string
 
 	*attributes.AttributesHolder
 }
@@ -205,35 +149,10 @@ func (c *blockquoteContext) AlertType() string {
 	return c.alertType
 }
 
-func (c *blockquoteContext) Page() any {
-	return c.page
-}
-
-func (c *blockquoteContext) PageInner() any {
-	return c.pageInner
-}
-
 func (c *blockquoteContext) Text() hstring.RenderedString {
 	return c.text
 }
 
-func (c *blockquoteContext) Ordinal() int {
-	return c.ordinal
-}
-
-func (c *blockquoteContext) Position() htext.Position {
-	c.posInit.Do(func() {
-		c.pos = c.createPos()
-	})
-	return c.pos
-}
-
-func (c *blockquoteContext) PositionerSourceTarget() []byte {
-	return c.sourceRef
-}
-
-var _ hooks.PositionerSourceTargetProvider = (*blockquoteContext)(nil)
-
 // https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
 // Five types:
 // [!NOTE], [!TIP], [!WARNING], [!IMPORTANT], [!CAUTION]
diff --git a/markup/goldmark/codeblocks/render.go b/markup/goldmark/codeblocks/render.go
index fad3ac45809..4164f0e0a4a 100644
--- a/markup/goldmark/codeblocks/render.go
+++ b/markup/goldmark/codeblocks/render.go
@@ -18,7 +18,6 @@ import (
 	"errors"
 	"fmt"
 	"strings"
-	"sync"
 
 	"github.com/gohugoio/hugo/common/herrors"
 	htext "github.com/gohugoio/hugo/common/text"
@@ -101,26 +100,14 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
 	if err != nil {
 		return ast.WalkStop, &herrors.TextSegmentError{Err: err, Segment: attrStr}
 	}
+
 	cbctx := &codeBlockContext{
-		page:             ctx.DocumentContext().Document,
-		pageInner:        r.getPageInner(ctx),
+		BaseContext:      render.NewBaseContext(ctx, renderer, node, src, func() []byte { return []byte(s) }, ordinal),
 		lang:             lang,
 		code:             s,
-		ordinal:          ordinal,
 		AttributesHolder: attributes.New(attrs, attrtp),
 	}
 
-	cbctx.createPos = func() htext.Position {
-		if resolver, ok := renderer.(hooks.ElementPositionResolver); ok {
-			return resolver.ResolvePosition(cbctx)
-		}
-		return htext.Position{
-			Filename:     ctx.DocumentContext().Filename,
-			LineNumber:   1,
-			ColumnNumber: 1,
-		}
-	}
-
 	cr := renderer.(hooks.CodeBlockRenderer)
 
 	err = cr.RenderCodeblock(
@@ -129,50 +116,20 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
 		cbctx,
 	)
 	if err != nil {
-		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, cbctx.createPos())
+		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, cbctx.Position())
 	}
 
 	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
-}
-
-var _ hooks.PositionerSourceTargetProvider = (*codeBlockContext)(nil)
-
 type codeBlockContext struct {
-	page      any
-	pageInner any
-	lang      string
-	code      string
-	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
+	hooks.BaseContext
+	lang string
+	code string
 
 	*attributes.AttributesHolder
 }
 
-func (c *codeBlockContext) Page() any {
-	return c.page
-}
-
-func (c *codeBlockContext) PageInner() any {
-	return c.pageInner
-}
-
 func (c *codeBlockContext) Type() string {
 	return c.lang
 }
@@ -181,22 +138,6 @@ func (c *codeBlockContext) Inner() string {
 	return c.code
 }
 
-func (c *codeBlockContext) Ordinal() int {
-	return c.ordinal
-}
-
-func (c *codeBlockContext) Position() htext.Position {
-	c.posInit.Do(func() {
-		c.pos = c.createPos()
-	})
-	return c.pos
-}
-
-// For internal use.
-func (c *codeBlockContext) PositionerSourceTarget() []byte {
-	return []byte(c.code)
-}
-
 func getLang(node *ast.FencedCodeBlock, src []byte) string {
 	langWithAttributes := string(node.Language(src))
 	lang, _, _ := strings.Cut(langWithAttributes, "{")
diff --git a/markup/goldmark/convert.go b/markup/goldmark/convert.go
index 357be732860..5c31eee40d9 100644
--- a/markup/goldmark/convert.go
+++ b/markup/goldmark/convert.go
@@ -26,6 +26,7 @@ import (
 	"github.com/gohugoio/hugo/markup/goldmark/internal/extensions/attributes"
 	"github.com/gohugoio/hugo/markup/goldmark/internal/render"
 	"github.com/gohugoio/hugo/markup/goldmark/passthrough"
+	"github.com/gohugoio/hugo/markup/goldmark/tables"
 	"github.com/yuin/goldmark/util"
 
 	"github.com/yuin/goldmark"
@@ -131,6 +132,7 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown {
 
 	if cfg.Extensions.Table {
 		extensions = append(extensions, extension.Table)
+		extensions = append(extensions, tables.New())
 	}
 
 	if cfg.Extensions.Strikethrough {
diff --git a/markup/goldmark/internal/render/context.go b/markup/goldmark/internal/render/context.go
index 712e1f0531b..b8cf9ba54ef 100644
--- a/markup/goldmark/internal/render/context.go
+++ b/markup/goldmark/internal/render/context.go
@@ -16,8 +16,12 @@ package render
 import (
 	"bytes"
 	"math/bits"
+	"sync"
+
+	htext "github.com/gohugoio/hugo/common/text"
 
 	"github.com/gohugoio/hugo/markup/converter"
+	"github.com/gohugoio/hugo/markup/converter/hooks"
 	"github.com/yuin/goldmark/ast"
 )
 
@@ -45,6 +49,7 @@ type Context struct {
 	positions []int
 	pids      []uint64
 	ordinals  map[ast.NodeKind]int
+	values    map[ast.NodeKind][]any
 }
 
 func (ctx *Context) GetAndIncrementOrdinal(kind ast.NodeKind) int {
@@ -67,6 +72,13 @@ func (ctx *Context) PopPos() int {
 	return p
 }
 
+func (ctx *Context) PopRenderedString() string {
+	pos := ctx.PopPos()
+	text := string(ctx.Bytes()[pos:])
+	ctx.Truncate(pos)
+	return text
+}
+
 // PushPid pushes a new page ID to the stack.
 func (ctx *Context) PushPid(pid uint64) {
 	ctx.pids = append(ctx.pids, pid)
@@ -91,6 +103,38 @@ func (ctx *Context) PopPid() uint64 {
 	return p
 }
 
+func (ctx *Context) PushValue(k ast.NodeKind, v any) {
+	if ctx.values == nil {
+		ctx.values = make(map[ast.NodeKind][]any)
+	}
+	ctx.values[k] = append(ctx.values[k], v)
+}
+
+func (ctx *Context) PopValue(k ast.NodeKind) any {
+	if ctx.values == nil {
+		return nil
+	}
+	v := ctx.values[k]
+	if len(v) == 0 {
+		return nil
+	}
+	i := len(v) - 1
+	r := v[i]
+	ctx.values[k] = v[:i]
+	return r
+}
+
+func (ctx *Context) PeekValue(k ast.NodeKind) any {
+	if ctx.values == nil {
+		return nil
+	}
+	v := ctx.values[k]
+	if len(v) == 0 {
+		return nil
+	}
+	return v[len(v)-1]
+}
+
 type ContextData interface {
 	RenderContext() converter.RenderContext
 	DocumentContext() converter.DocumentContext
@@ -108,3 +152,109 @@ func (ctx *RenderContextDataHolder) RenderContext() converter.RenderContext {
 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 {
+	var sample []byte
+
+	// Extract a source sample to use for position information.
+	if nn := n.FirstChild(); nn != nil {
+		var start, stop int
+		for i := 0; i < nn.Lines().Len() && i < 2; i++ {
+			line := nn.Lines().At(i)
+			if i == 0 {
+				start = line.Start
+			}
+			stop = line.Stop
+		}
+		// 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
+	pid := rctx.PeekPid()
+	if pid > 0 {
+		if lookup := rctx.DocumentContext().DocumentLookup; lookup != nil {
+			if v := rctx.DocumentContext().DocumentLookup(pid); v != nil {
+				return p, v
+			}
+		}
+	}
+	return p, p
+}
+
+// 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)
+		}
+	}
+	page, pageInner := GetPageAndPageInner(rctx)
+	b := &hookBase{
+		page:      page,
+		pageInner: pageInner,
+
+		getSourceSample: getSourceSample,
+		ordinal:         ordinal,
+	}
+
+	b.createPos = func() htext.Position {
+		if resolver, ok := renderer.(hooks.ElementPositionResolver); ok {
+			return resolver.ResolvePosition(b)
+		}
+
+		return htext.Position{
+			Filename:     rctx.DocumentContext().Filename,
+			LineNumber:   1,
+			ColumnNumber: 1,
+		}
+	}
+
+	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
+}
+
+func (c *hookBase) Page() any {
+	return c.page
+}
+
+func (c *hookBase) PageInner() any {
+	return c.pageInner
+}
+
+func (c *hookBase) Ordinal() int {
+	return c.ordinal
+}
+
+func (c *hookBase) Position() htext.Position {
+	c.posInit.Do(func() {
+		c.pos = c.createPos()
+	})
+	return c.pos
+}
+
+// For internal use.
+func (c *hookBase) PositionerSourceTarget() []byte {
+	return c.getSourceSample()
+}
diff --git a/markup/goldmark/passthrough/passthrough.go b/markup/goldmark/passthrough/passthrough.go
index aafb1544b94..4d72e7c80f2 100644
--- a/markup/goldmark/passthrough/passthrough.go
+++ b/markup/goldmark/passthrough/passthrough.go
@@ -15,9 +15,6 @@ package passthrough
 
 import (
 	"bytes"
-	"sync"
-
-	htext "github.com/gohugoio/hugo/common/text"
 
 	"github.com/gohugoio/hugo-goldmark-extensions/passthrough"
 	"github.com/gohugoio/hugo/markup/converter/hooks"
@@ -136,25 +133,12 @@ func (r *htmlRenderer) renderPassthroughBlock(w util.BufWriter, src []byte, node
 	s = s[len(delims.Open) : len(s)-len(delims.Close)]
 
 	pctx := &passthroughContext{
-		ordinal:          ordinal,
-		page:             ctx.DocumentContext().Document,
-		pageInner:        r.getPageInner(ctx),
+		BaseContext:      render.NewBaseContext(ctx, renderer, node, src, nil, ordinal),
 		inner:            s,
 		typ:              typ,
 		AttributesHolder: attributes.New(node.Attributes(), attributes.AttributesOwnerGeneral),
 	}
 
-	pctx.createPos = func() htext.Position {
-		if resolver, ok := renderer.(hooks.ElementPositionResolver); ok {
-			return resolver.ResolvePosition(pctx)
-		}
-		return htext.Position{
-			Filename:     ctx.DocumentContext().Filename,
-			LineNumber:   1,
-			ColumnNumber: 1,
-		}
-	}
-
 	pr := renderer.(hooks.PassthroughRenderer)
 
 	if err := pr.RenderPassthrough(ctx.RenderContext().Ctx, w, pctx); err != nil {
@@ -164,39 +148,13 @@ func (r *htmlRenderer) renderPassthroughBlock(w util.BufWriter, src []byte, node
 	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 passthroughContext struct {
-	page      any
-	pageInner any
-	typ       string // inner or block
-	inner     string
-	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
-	*attributes.AttributesHolder
-}
+	hooks.BaseContext
 
-func (p *passthroughContext) Page() any {
-	return p.page
-}
+	typ   string // inner or block
+	inner string
 
-func (p *passthroughContext) PageInner() any {
-	return p.pageInner
+	*attributes.AttributesHolder
 }
 
 func (p *passthroughContext) Type() string {
@@ -206,21 +164,3 @@ func (p *passthroughContext) Type() string {
 func (p *passthroughContext) Inner() string {
 	return p.inner
 }
-
-func (p *passthroughContext) Ordinal() int {
-	return p.ordinal
-}
-
-func (p *passthroughContext) Position() htext.Position {
-	p.posInit.Do(func() {
-		p.pos = p.createPos()
-	})
-	return p.pos
-}
-
-// For internal use.
-func (p *passthroughContext) PositionerSourceTarget() []byte {
-	return []byte(p.inner)
-}
-
-var _ hooks.PositionerSourceTargetProvider = (*passthroughContext)(nil)
diff --git a/markup/goldmark/render_hooks.go b/markup/goldmark/render_hooks.go
index c127a2c0e86..790b2f4caa7 100644
--- a/markup/goldmark/render_hooks.go
+++ b/markup/goldmark/render_hooks.go
@@ -169,9 +169,7 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
 		return ast.WalkContinue, nil
 	}
 
-	pos := ctx.PopPos()
-	text := ctx.Buffer.Bytes()[pos:]
-	ctx.Buffer.Truncate(pos)
+	text := ctx.PopRenderedString()
 
 	var (
 		isBlock bool
@@ -190,13 +188,15 @@ 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:             ctx.DocumentContext().Document,
-				pageInner:        r.getPageInner(ctx),
+				page:             page,
+				pageInner:        pageInner,
 				destination:      string(n.Destination),
 				title:            string(n.Title),
 				text:             hstring.RenderedString(text),
@@ -211,18 +211,6 @@ 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 {
@@ -288,16 +276,16 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
 		return ast.WalkContinue, nil
 	}
 
-	pos := ctx.PopPos()
-	text := ctx.Buffer.Bytes()[pos:]
-	ctx.Buffer.Truncate(pos)
+	text := ctx.PopRenderedString()
+
+	page, pageInner := render.GetPageAndPageInner(ctx)
 
 	err := lr.RenderLink(
 		ctx.RenderContext().Ctx,
 		w,
 		linkContext{
-			page:             ctx.DocumentContext().Document,
-			pageInner:        r.getPageInner(ctx),
+			page:             page,
+			pageInner:        pageInner,
 			destination:      string(n.Destination),
 			title:            string(n.Title),
 			text:             hstring.RenderedString(text),
@@ -358,12 +346,14 @@ func (r *hookedRenderer) renderAutoLink(w util.BufWriter, source []byte, node as
 		url = "mailto:" + url
 	}
 
+	page, pageInner := render.GetPageAndPageInner(ctx)
+
 	err := lr.RenderLink(
 		ctx.RenderContext().Ctx,
 		w,
 		linkContext{
-			page:             ctx.DocumentContext().Document,
-			pageInner:        r.getPageInner(ctx),
+			page:             page,
+			pageInner:        pageInner,
 			destination:      url,
 			text:             hstring.RenderedString(label),
 			plainText:        label,
@@ -435,20 +425,21 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
 		return ast.WalkContinue, nil
 	}
 
-	pos := ctx.PopPos()
-	text := ctx.Buffer.Bytes()[pos:]
-	ctx.Buffer.Truncate(pos)
+	text := ctx.PopRenderedString()
+
 	// 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")
 	anchor := anchori.([]byte)
 
+	page, pageInner := render.GetPageAndPageInner(ctx)
+
 	err := hr.RenderHeading(
 		ctx.RenderContext().Ctx,
 		w,
 		headingContext{
-			page:             ctx.DocumentContext().Document,
-			pageInner:        r.getPageInner(ctx),
+			page:             page,
+			pageInner:        pageInner,
 			level:            n.Level,
 			anchor:           string(anchor),
 			text:             hstring.RenderedString(text),
diff --git a/markup/goldmark/tables/tables.go b/markup/goldmark/tables/tables.go
new file mode 100644
index 00000000000..61c9b893f5e
--- /dev/null
+++ b/markup/goldmark/tables/tables.go
@@ -0,0 +1,175 @@
+// 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 tables
+
+import (
+	"github.com/gohugoio/hugo/common/herrors"
+	"github.com/gohugoio/hugo/common/types/hstring"
+	"github.com/gohugoio/hugo/markup/converter/hooks"
+	"github.com/gohugoio/hugo/markup/goldmark/internal/render"
+	"github.com/gohugoio/hugo/markup/internal/attributes"
+	"github.com/yuin/goldmark"
+	"github.com/yuin/goldmark/ast"
+	gast "github.com/yuin/goldmark/extension/ast"
+	"github.com/yuin/goldmark/renderer"
+	"github.com/yuin/goldmark/util"
+)
+
+type (
+	ext          struct{}
+	htmlRenderer struct{}
+)
+
+func New() goldmark.Extender {
+	return &ext{}
+}
+
+func (e *ext) Extend(m goldmark.Markdown) {
+	m.Renderer().AddOptions(renderer.WithNodeRenderers(
+		util.Prioritized(newHTMLRenderer(), 100),
+	))
+}
+
+func newHTMLRenderer() renderer.NodeRenderer {
+	r := &htmlRenderer{}
+	return r
+}
+
+func (r *htmlRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
+	reg.Register(gast.KindTable, r.renderTable)
+	reg.Register(gast.KindTableHeader, r.renderHeaderOrRow)
+	reg.Register(gast.KindTableRow, r.renderHeaderOrRow)
+	reg.Register(gast.KindTableCell, r.renderCell)
+}
+
+func (r *htmlRenderer) renderTable(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+	ctx := w.(*render.Context)
+	if entering {
+		// This will be modified below.
+		table := &hooks.Table{}
+		ctx.PushValue(gast.KindTable, table)
+		return ast.WalkContinue, nil
+	}
+
+	v := ctx.PopValue(gast.KindTable)
+	if v == nil {
+		panic("table not found")
+	}
+
+	table := v.(*hooks.Table)
+
+	renderer := ctx.RenderContext().GetRenderer(hooks.TableRendererType, nil)
+	if renderer == nil {
+		panic("table hook renderer not found")
+	}
+
+	ordinal := ctx.GetAndIncrementOrdinal(gast.KindTable)
+
+	tctx := &tableContext{
+		BaseContext:      render.NewBaseContext(ctx, renderer, n, source, nil, ordinal),
+		AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral),
+		tHead:            table.THead,
+		tBody:            table.TBody,
+	}
+
+	cr := renderer.(hooks.TableRenderer)
+
+	err := cr.RenderTable(
+		ctx.RenderContext().Ctx,
+		w,
+		tctx,
+	)
+	if err != nil {
+		return ast.WalkContinue, herrors.NewFileErrorFromPos(err, tctx.Position())
+	}
+
+	return ast.WalkContinue, nil
+}
+
+func (r *htmlRenderer) peekTable(ctx *render.Context) *hooks.Table {
+	v := ctx.PeekValue(gast.KindTable)
+	if v == nil {
+		panic("table not found")
+	}
+	return v.(*hooks.Table)
+}
+
+func (r *htmlRenderer) renderCell(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) {
+	ctx := w.(*render.Context)
+
+	if entering {
+		// Store the current pos so we can capture the rendered text.
+		ctx.PushPos(ctx.Buffer.Len())
+		return ast.WalkContinue, nil
+	}
+
+	n := node.(*gast.TableCell)
+
+	text := ctx.PopRenderedString()
+
+	table := r.peekTable(ctx)
+
+	var alignment string
+	switch n.Alignment {
+	case gast.AlignLeft:
+		alignment = "left"
+	case gast.AlignRight:
+		alignment = "right"
+	case gast.AlignCenter:
+		alignment = "center"
+	default:
+		alignment = "left"
+	}
+
+	cell := hooks.TableCell{Text: hstring.RenderedString(text), Alignment: alignment}
+
+	if node.Parent().Kind() == gast.KindTableHeader {
+		table.THead[len(table.THead)-1] = append(table.THead[len(table.THead)-1], cell)
+	} else {
+		table.TBody[len(table.TBody)-1] = append(table.TBody[len(table.TBody)-1], cell)
+	}
+
+	return ast.WalkContinue, nil
+}
+
+func (r *htmlRenderer) renderHeaderOrRow(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) {
+	ctx := w.(*render.Context)
+	table := r.peekTable(ctx)
+	if entering {
+		if n.Kind() == gast.KindTableHeader {
+			table.THead = append(table.THead, hooks.TableRow{})
+		} else {
+			table.TBody = append(table.TBody, hooks.TableRow{})
+		}
+		return ast.WalkContinue, nil
+	}
+
+	return ast.WalkContinue, nil
+}
+
+type tableContext struct {
+	hooks.BaseContext
+	*attributes.AttributesHolder
+
+	tHead []hooks.TableRow
+	tBody []hooks.TableRow
+}
+
+func (c *tableContext) THead() []hooks.TableRow {
+	return c.tHead
+}
+
+func (c *tableContext) TBody() []hooks.TableRow {
+	return c.tBody
+}
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html b/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html
new file mode 100644
index 00000000000..307f0a5a350
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html
@@ -0,0 +1,29 @@
+<table
+  {{- range $k, $v := .Attributes }}
+    {{- if $v }}
+      {{- printf " %s=%q" $k $v | safeHTMLAttr }}
+    {{- end }}
+  {{- end }}>
+  <thead>
+    {{- range .THead }}
+      <tr>
+        {{- range . }}
+          <th {{ printf "style=%q" (printf "text-align: %s" .Alignment) | safeHTMLAttr }}>
+            {{- .Text | safeHTML -}}
+          </th>
+        {{- end }}
+      </tr>
+    {{- end }}
+  </thead>
+  <tbody>
+    {{- range .TBody }}
+      <tr>
+        {{- range . }}
+          <td {{ printf "style=%q" (printf "text-align: %s" .Alignment) | safeHTMLAttr }}>
+            {{- .Text | safeHTML -}}
+          </td>
+        {{- end }}
+      </tr>
+    {{- end }}
+  </tbody>
+</table>


Test patch:
diff --git a/markup/goldmark/convert_test.go b/markup/goldmark/convert_test.go
index 4c5039e7418..6048bce397a 100644
--- a/markup/goldmark/convert_test.go
+++ b/markup/goldmark/convert_test.go
@@ -14,6 +14,7 @@
 package goldmark_test
 
 import (
+	"context"
 	"fmt"
 	"strings"
 	"testing"
@@ -30,6 +31,7 @@ import (
 
 	"github.com/gohugoio/hugo/markup/markup_config"
 
+	"github.com/gohugoio/hugo/common/hugio"
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 
@@ -60,9 +62,13 @@ func convert(c *qt.C, conf config.AllProvider, content string) converter.ResultR
 	h := highlight.New(mconf.Highlight)
 
 	getRenderer := func(t hooks.RendererType, id any) any {
-		if t == hooks.CodeBlockRendererType {
+		switch t {
+		case hooks.CodeBlockRendererType:
 			return h
+		case hooks.TableRendererType:
+			return tableRenderer(0)
 		}
+
 		return nil
 	}
 
@@ -168,8 +174,6 @@ unsafe = true
 	b := convert(c, testconfig.GetTestConfig(nil, cfg), content)
 	got := string(b.Bytes())
 
-	fmt.Println(got)
-
 	// Links
 	c.Assert(got, qt.Contains, `<a href="https://docuapi.netlify.com/">Live Demo here!</a>`)
 	c.Assert(got, qt.Contains, `<a href="https://foo.bar/">https://foo.bar/</a>`)
@@ -191,7 +195,7 @@ unsafe = true
 	// Extensions
 	c.Assert(got, qt.Contains, `Autolink: <a href="https://gohugo.io/">https://gohugo.io/</a>`)
 	c.Assert(got, qt.Contains, `Strikethrough:<del>Hi</del> Hello, world`)
-	c.Assert(got, qt.Contains, `<th>foo</th>`)
+	c.Assert(got, qt.Contains, `Table`)
 	c.Assert(got, qt.Contains, `<li><input disabled="" type="checkbox"> Push my commits to GitHub</li>`)
 
 	c.Assert(got, qt.Contains, `Straight double &ldquo;quotes&rdquo; and single &lsquo;quotes&rsquo;`)
@@ -378,7 +382,7 @@ func TestConvertAttributes(t *testing.T) {
 | ------------- |:-------------:| -----:|
 | AV      | BV |
 {.myclass }`,
-			"<table class=\"myclass\">\n<thead>",
+			"Table",
 		},
 		{
 			"Title and Blockquote",
@@ -741,3 +745,11 @@ escapedSpace=true
 
 	c.Assert(got, qt.Contains, "<p>私は太郎です。\nプログラミングが好きです。運動が苦手です。</p>\n")
 }
+
+type tableRenderer int
+
+func (hr tableRenderer) RenderTable(cctx context.Context, w hugio.FlexiWriter, ctx hooks.TableContext) error {
+	// This is set up with a render hook in the hugolib package, make it simple here.
+	fmt.Fprintln(w, "Table")
+	return nil
+}
diff --git a/markup/goldmark/tables/tables_integration_test.go b/markup/goldmark/tables/tables_integration_test.go
new file mode 100644
index 00000000000..8055265c855
--- /dev/null
+++ b/markup/goldmark/tables/tables_integration_test.go
@@ -0,0 +1,181 @@
+// 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 tables_test
+
+import (
+	"testing"
+
+	"github.com/gohugoio/hugo/hugolib"
+)
+
+func TestTableHook(t *testing.T) {
+	t.Parallel()
+	files := `
+-- hugo.toml --
+[markup.goldmark.parser.attribute]
+block = true
+title = true
+-- content/p1.md --
+## Table 1
+
+| Item              | In Stock | Price |
+| :---------------- | :------: | ----: |
+| Python Hat        |   True   | 23.99 |
+| SQL Hat           |   True   | 23.99 |
+| Codecademy Tee    |  False   | 19.99 |
+| Codecademy Hoodie |  False   | 42.99 |
+{.foo foo="bar"}
+
+## Table 2
+
+| Month | Savings |
+| -------- | ------- |
+| January | $250 |
+| February | $80 |
+| March | $420 |
+
+-- layouts/_default/single.html --
+{{ .Content }}
+-- layouts/_default/_markup/render-table.html --
+Attributes: {{ .Attributes }}|
+{{ template "print" (dict "what" (printf "table-%d-thead" $.Ordinal) "rows" .THead) }}
+{{ template "print" (dict "what" (printf "table-%d-tbody" $.Ordinal)  "rows" .TBody) }}
+{{ define "print" }}
+ {{ .what }}:{{ range $i, $a := .rows }} {{ $i }}:{{ range $j, $b := . }} {{ $j }}: {{ .Alignment }}: {{ .Text }}|{{ end }}{{ end }}$
+{{ end }}
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/p1/index.html",
+		"Attributes: map[class:foo foo:bar]|",
+		"table-0-thead: 0: 0: left: Item| 1: center: In Stock| 2: right: Price|$",
+		"table-0-tbody: 0: 0: left: Python Hat| 1: center: True| 2: right: 23.99| 1: 0: left: SQL Hat| 1: center: True| 2: right: 23.99| 2: 0: left: Codecademy Tee| 1: center: False| 2: right: 19.99| 3: 0: left: Codecademy Hoodie| 1: center: False| 2: right: 42.99|$",
+	)
+
+	b.AssertFileContent("public/p1/index.html",
+		"table-1-thead: 0: 0: left: Month| 1: left: Savings|$",
+		"table-1-tbody: 0: 0: left: January| 1: left: $250| 1: 0: left: February| 1: left: $80| 2: 0: left: March| 1: left: $420|$",
+	)
+}
+
+func TestTableDefault(t *testing.T) {
+	t.Parallel()
+	files := `
+-- hugo.toml --
+[markup.goldmark.parser.attribute]
+block = true
+title = true
+-- content/p1.md --
+
+## Table 1
+
+| Item              | In Stock | Price |
+| :---------------- | :------: | ----: |
+| Python Hat        |   True   | 23.99 |
+| SQL Hat           |   True   | 23.99 |
+| Codecademy Tee    |  False   | 19.99 |
+| Codecademy Hoodie |  False   | 42.99 |
+{.foo}
+
+
+-- layouts/_default/single.html --
+Summary: {{ .Summary }}
+Content: {{ .Content }}
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/p1/index.html", "<table class=\"foo\">")
+}
+
+// Issue 12811.
+func TestTableDefaultRSSAndHTML(t *testing.T) {
+	t.Parallel()
+	files := `
+-- hugo.toml --
+[outputFormats]
+  [outputFormats.rss]
+    weight = 30
+ [outputFormats.html]
+    weight = 20
+-- content/_index.md --
+---
+title: "Home"
+output: ["rss", "html"]
+---
+
+| Item              | In Stock | Price |
+| :---------------- | :------: | ----: |
+| Python Hat        |   True   | 23.99 |
+| SQL Hat           |   True   | 23.99 |
+| Codecademy Tee    |  False   | 19.99 |
+| Codecademy Hoodie |  False   | 42.99 |
+
+{{< foo >}}
+
+-- layouts/index.html --
+Content: {{ .Content }}
+-- layouts/index.xml --
+Content: {{ .Content }}
+-- layouts/shortcodes/foo.xml --
+foo xml
+-- layouts/shortcodes/foo.html --
+foo html
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.xml", "<table>")
+	b.AssertFileContent("public/index.html", "<table>")
+}
+
+func TestTableDefaultRSSOnly(t *testing.T) {
+	t.Parallel()
+	files := `
+-- hugo.toml --
+[outputs]
+  home = ['rss']
+  section = ['rss']
+  taxonomy = ['rss']
+  term = ['rss']
+  page = ['rss']
+disableKinds = ["taxonomy", "term", "page", "section"]
+-- content/_index.md --
+---
+title: "Home"
+---
+
+## Table 1
+
+| Item              | In Stock | Price |
+| :---------------- | :------: | ----: |
+| Python Hat        |   True   | 23.99 |
+| SQL Hat           |   True   | 23.99 |
+| Codecademy Tee    |  False   | 19.99 |
+| Codecademy Hoodie |  False   | 42.99 |
+
+
+
+
+
+-- layouts/index.xml --
+Content: {{ .Content }}
+
+
+`
+	b := hugolib.Test(t, files)
+
+	b.AssertFileContent("public/index.xml", "<table>")
+}