Add Markdown render hooks for tables

Fixes #9316
Fixes #12811
This commit is contained in:
Bjørn Erik Pedersen 2024-08-30 10:58:43 +02:00
parent b63f24adc7
commit f738669a4d
13 changed files with 651 additions and 271 deletions

View file

@ -296,6 +296,8 @@ func (pco *pageContentOutput) initRenderHooks() error {
if id != nil { if id != nil {
layoutDescriptor.KindVariants = id.(string) layoutDescriptor.KindVariants = id.(string)
} }
case hooks.TableRendererType:
layoutDescriptor.Kind = "render-table"
case hooks.CodeBlockRendererType: case hooks.CodeBlockRendererType:
layoutDescriptor.Kind = "render-codeblock" layoutDescriptor.Kind = "render-codeblock"
if id != nil { if id != nil {
@ -334,13 +336,23 @@ func (pco *pageContentOutput) initRenderHooks() error {
templ, found1 := getHookTemplate(pco.po.f) 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. // 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 { if f.Name == pco.po.f.Name {
continue continue
} }
templ2, found2 := getHookTemplate(f) templ2, found2 := getHookTemplate(f)
if found2 { if found2 {
if !found1 { if !found1 {
templ = templ2 templ = templ2

View file

@ -930,6 +930,10 @@ func (hr hookRendererTemplate) RenderBlockquote(cctx context.Context, w hugio.Fl
return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) 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 { func (hr hookRendererTemplate) ResolvePosition(ctx any) text.Position {
return hr.resolvePosition(ctx) return hr.resolvePosition(ctx)
} }

View file

@ -61,9 +61,8 @@ type ImageLinkContext interface {
// CodeblockContext is the context passed to a code block render hook. // CodeblockContext is the context passed to a code block render hook.
type CodeblockContext interface { type CodeblockContext interface {
BaseContext
AttributesProvider AttributesProvider
text.Positioner
PageProvider
// Chroma highlighting processing options. This will only be filled if Type is a known Chroma Lexer. // Chroma highlighting processing options. This will only be filled if Type is a known Chroma Lexer.
Options() map[string]any Options() map[string]any
@ -73,19 +72,31 @@ type CodeblockContext interface {
// The text between the code fences. // The text between the code fences.
Inner() string Inner() string
}
// Zero-based ordinal for all code blocks in the current document. // 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 elements of this kind in the current document.
Ordinal() int Ordinal() int
} }
// BlockquoteContext is the context passed to a blockquote render hook. // BlockquoteContext is the context passed to a blockquote render hook.
type BlockquoteContext interface { type BlockquoteContext interface {
AttributesProvider BaseContext
text.Positioner
PageProvider
// Zero-based ordinal for all block quotes in the current document. AttributesProvider
Ordinal() int
// The blockquote text. // The blockquote text.
// If type is "alert", this will be the alert 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. // PassThroughContext is the context passed to a passthrough render hook.
type PassthroughContext interface { type PassthroughContext interface {
BaseContext
AttributesProvider AttributesProvider
text.Positioner
PageProvider
// Currently one of "inline" or "block". // Currently one of "inline" or "block".
Type() string Type() string
// The inner content of the passthrough element, excluding the delimiters. // The inner content of the passthrough element, excluding the delimiters.
Inner() string Inner() string
// Zero-based ordinal for all passthrough elements in the document.
Ordinal() int
} }
type AttributesOptionsSliceProvider interface { type AttributesOptionsSliceProvider interface {
@ -138,6 +145,10 @@ type BlockquoteRenderer interface {
RenderBlockquote(cctx context.Context, w hugio.FlexiWriter, ctx BlockquoteContext) error 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 { type PassthroughRenderer interface {
RenderPassthrough(cctx context.Context, w io.Writer, ctx PassthroughContext) error RenderPassthrough(cctx context.Context, w io.Writer, ctx PassthroughContext) error
} }
@ -196,6 +207,19 @@ const (
CodeBlockRendererType CodeBlockRendererType
PassthroughRendererType PassthroughRendererType
BlockquoteRendererType BlockquoteRendererType
TableRendererType
) )
type GetRendererFunc func(t RendererType, id any) any 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
}

View file

@ -16,10 +16,8 @@ package blockquotes
import ( import (
"regexp" "regexp"
"strings" "strings"
"sync"
"github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/herrors"
htext "github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/common/types/hstring" "github.com/gohugoio/hugo/common/types/hstring"
"github.com/gohugoio/hugo/markup/converter/hooks" "github.com/gohugoio/hugo/markup/converter/hooks"
"github.com/gohugoio/hugo/markup/goldmark/internal/render" "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 return ast.WalkContinue, nil
} }
pos := ctx.PopPos() text := ctx.PopRenderedString()
text := ctx.Buffer.Bytes()[pos:]
ctx.Buffer.Truncate(pos)
ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote) ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote)
texts := string(text)
typ := typeRegular typ := typeRegular
alertType := resolveGitHubAlert(texts) alertType := resolveGitHubAlert(string(text))
if alertType != "" { if alertType != "" {
typ = typeAlert typ = typeAlert
} }
renderer := ctx.RenderContext().GetRenderer(hooks.BlockquoteRendererType, typ) renderer := ctx.RenderContext().GetRenderer(hooks.BlockquoteRendererType, typ)
if renderer == nil { if renderer == nil {
return r.renderBlockquoteDefault(w, n, texts) return r.renderBlockquoteDefault(w, n, text)
} }
if typ == typeAlert { if typ == typeAlert {
// Trim preamble: <p>[!NOTE]<br>\n but preserve leading paragraph. // Trim preamble: <p>[!NOTE]<br>\n but preserve leading paragraph.
// We could possibly complicate this by moving this to the parser, but // We could possibly complicate this by moving this to the parser, but
// keep it simple for now. // keep it simple for now.
texts = "<p>" + texts[strings.Index(texts, "\n")+1:] text = "<p>" + text[strings.Index(text, "\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]
} }
bqctx := &blockquoteContext{ bqctx := &blockquoteContext{
page: ctx.DocumentContext().Document, BaseContext: render.NewBaseContext(ctx, renderer, n, src, nil, ordinal),
pageInner: r.getPageInner(ctx),
typ: typ, typ: typ,
alertType: alertType, alertType: alertType,
text: hstring.RenderedString(texts), text: hstring.RenderedString(text),
sourceRef: sourceRef,
ordinal: ordinal,
AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral), 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) cr := renderer.(hooks.BlockquoteRenderer)
err := cr.RenderBlockquote( err := cr.RenderBlockquote(
@ -143,24 +107,12 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N
bqctx, bqctx,
) )
if err != nil { if err != nil {
return ast.WalkContinue, herrors.NewFileErrorFromPos(err, bqctx.createPos()) return ast.WalkContinue, herrors.NewFileErrorFromPos(err, bqctx.Position())
} }
return ast.WalkContinue, nil 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. // Code borrowed from goldmark's html renderer.
func (r *htmlRenderer) renderBlockquoteDefault( func (r *htmlRenderer) renderBlockquoteDefault(
w util.BufWriter, n ast.Node, text string, w util.BufWriter, n ast.Node, text string,
@ -180,19 +132,11 @@ func (r *htmlRenderer) renderBlockquoteDefault(
} }
type blockquoteContext struct { type blockquoteContext struct {
page any hooks.BaseContext
pageInner any
text hstring.RenderedString
typ string
sourceRef []byte
alertType string
ordinal int
// This is only used in error situations and is expensive to create, text hstring.RenderedString
// so delay creation until needed. alertType string
pos htext.Position typ string
posInit sync.Once
createPos func() htext.Position
*attributes.AttributesHolder *attributes.AttributesHolder
} }
@ -205,35 +149,10 @@ func (c *blockquoteContext) AlertType() string {
return c.alertType 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 { func (c *blockquoteContext) Text() hstring.RenderedString {
return c.text 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 // 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: // Five types:
// [!NOTE], [!TIP], [!WARNING], [!IMPORTANT], [!CAUTION] // [!NOTE], [!TIP], [!WARNING], [!IMPORTANT], [!CAUTION]

View file

@ -18,7 +18,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"strings" "strings"
"sync"
"github.com/gohugoio/hugo/common/herrors" "github.com/gohugoio/hugo/common/herrors"
htext "github.com/gohugoio/hugo/common/text" 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 { if err != nil {
return ast.WalkStop, &herrors.TextSegmentError{Err: err, Segment: attrStr} return ast.WalkStop, &herrors.TextSegmentError{Err: err, Segment: attrStr}
} }
cbctx := &codeBlockContext{ cbctx := &codeBlockContext{
page: ctx.DocumentContext().Document, BaseContext: render.NewBaseContext(ctx, renderer, node, src, func() []byte { return []byte(s) }, ordinal),
pageInner: r.getPageInner(ctx),
lang: lang, lang: lang,
code: s, code: s,
ordinal: ordinal,
AttributesHolder: attributes.New(attrs, attrtp), 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) cr := renderer.(hooks.CodeBlockRenderer)
err = cr.RenderCodeblock( err = cr.RenderCodeblock(
@ -129,50 +116,20 @@ func (r *htmlRenderer) renderCodeBlock(w util.BufWriter, src []byte, node ast.No
cbctx, cbctx,
) )
if err != nil { if err != nil {
return ast.WalkContinue, herrors.NewFileErrorFromPos(err, cbctx.createPos()) return ast.WalkContinue, herrors.NewFileErrorFromPos(err, cbctx.Position())
} }
return ast.WalkContinue, nil 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 { type codeBlockContext struct {
page any hooks.BaseContext
pageInner any lang string
lang string code 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
*attributes.AttributesHolder *attributes.AttributesHolder
} }
func (c *codeBlockContext) Page() any {
return c.page
}
func (c *codeBlockContext) PageInner() any {
return c.pageInner
}
func (c *codeBlockContext) Type() string { func (c *codeBlockContext) Type() string {
return c.lang return c.lang
} }
@ -181,22 +138,6 @@ func (c *codeBlockContext) Inner() string {
return c.code 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 { func getLang(node *ast.FencedCodeBlock, src []byte) string {
langWithAttributes := string(node.Language(src)) langWithAttributes := string(node.Language(src))
lang, _, _ := strings.Cut(langWithAttributes, "{") lang, _, _ := strings.Cut(langWithAttributes, "{")

View file

@ -26,6 +26,7 @@ import (
"github.com/gohugoio/hugo/markup/goldmark/internal/extensions/attributes" "github.com/gohugoio/hugo/markup/goldmark/internal/extensions/attributes"
"github.com/gohugoio/hugo/markup/goldmark/internal/render" "github.com/gohugoio/hugo/markup/goldmark/internal/render"
"github.com/gohugoio/hugo/markup/goldmark/passthrough" "github.com/gohugoio/hugo/markup/goldmark/passthrough"
"github.com/gohugoio/hugo/markup/goldmark/tables"
"github.com/yuin/goldmark/util" "github.com/yuin/goldmark/util"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
@ -131,6 +132,7 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown {
if cfg.Extensions.Table { if cfg.Extensions.Table {
extensions = append(extensions, extension.Table) extensions = append(extensions, extension.Table)
extensions = append(extensions, tables.New())
} }
if cfg.Extensions.Strikethrough { if cfg.Extensions.Strikethrough {

View file

@ -14,6 +14,7 @@
package goldmark_test package goldmark_test
import ( import (
"context"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@ -30,6 +31,7 @@ import (
"github.com/gohugoio/hugo/markup/markup_config" "github.com/gohugoio/hugo/markup/markup_config"
"github.com/gohugoio/hugo/common/hugio"
"github.com/gohugoio/hugo/common/loggers" "github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/maps" "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) h := highlight.New(mconf.Highlight)
getRenderer := func(t hooks.RendererType, id any) any { getRenderer := func(t hooks.RendererType, id any) any {
if t == hooks.CodeBlockRendererType { switch t {
case hooks.CodeBlockRendererType:
return h return h
case hooks.TableRendererType:
return tableRenderer(0)
} }
return nil return nil
} }
@ -168,8 +174,6 @@ unsafe = true
b := convert(c, testconfig.GetTestConfig(nil, cfg), content) b := convert(c, testconfig.GetTestConfig(nil, cfg), content)
got := string(b.Bytes()) got := string(b.Bytes())
fmt.Println(got)
// Links // Links
c.Assert(got, qt.Contains, `<a href="https://docuapi.netlify.com/">Live Demo here!</a>`) 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>`) c.Assert(got, qt.Contains, `<a href="https://foo.bar/">https://foo.bar/</a>`)
@ -191,7 +195,7 @@ unsafe = true
// Extensions // Extensions
c.Assert(got, qt.Contains, `Autolink: <a href="https://gohugo.io/">https://gohugo.io/</a>`) 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, `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, `<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;`) 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 | | AV | BV |
{.myclass }`, {.myclass }`,
"<table class=\"myclass\">\n<thead>", "Table",
}, },
{ {
"Title and Blockquote", "Title and Blockquote",
@ -741,3 +745,11 @@ escapedSpace=true
c.Assert(got, qt.Contains, "<p>私は太郎です。\nプログラミングが好きです。運動が苦手です。</p>\n") 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
}

View file

@ -16,8 +16,12 @@ package render
import ( import (
"bytes" "bytes"
"math/bits" "math/bits"
"sync"
htext "github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo/markup/converter" "github.com/gohugoio/hugo/markup/converter"
"github.com/gohugoio/hugo/markup/converter/hooks"
"github.com/yuin/goldmark/ast" "github.com/yuin/goldmark/ast"
) )
@ -45,6 +49,7 @@ type Context struct {
positions []int positions []int
pids []uint64 pids []uint64
ordinals map[ast.NodeKind]int ordinals map[ast.NodeKind]int
values map[ast.NodeKind][]any
} }
func (ctx *Context) GetAndIncrementOrdinal(kind ast.NodeKind) int { func (ctx *Context) GetAndIncrementOrdinal(kind ast.NodeKind) int {
@ -67,6 +72,13 @@ func (ctx *Context) PopPos() int {
return p 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. // PushPid pushes a new page ID to the stack.
func (ctx *Context) PushPid(pid uint64) { func (ctx *Context) PushPid(pid uint64) {
ctx.pids = append(ctx.pids, pid) ctx.pids = append(ctx.pids, pid)
@ -91,6 +103,38 @@ func (ctx *Context) PopPid() uint64 {
return p 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 { type ContextData interface {
RenderContext() converter.RenderContext RenderContext() converter.RenderContext
DocumentContext() converter.DocumentContext DocumentContext() converter.DocumentContext
@ -108,3 +152,109 @@ func (ctx *RenderContextDataHolder) RenderContext() converter.RenderContext {
func (ctx *RenderContextDataHolder) DocumentContext() converter.DocumentContext { func (ctx *RenderContextDataHolder) DocumentContext() converter.DocumentContext {
return ctx.Dctx 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()
}

View file

@ -15,9 +15,6 @@ package passthrough
import ( import (
"bytes" "bytes"
"sync"
htext "github.com/gohugoio/hugo/common/text"
"github.com/gohugoio/hugo-goldmark-extensions/passthrough" "github.com/gohugoio/hugo-goldmark-extensions/passthrough"
"github.com/gohugoio/hugo/markup/converter/hooks" "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)] s = s[len(delims.Open) : len(s)-len(delims.Close)]
pctx := &passthroughContext{ pctx := &passthroughContext{
ordinal: ordinal, BaseContext: render.NewBaseContext(ctx, renderer, node, src, nil, ordinal),
page: ctx.DocumentContext().Document,
pageInner: r.getPageInner(ctx),
inner: s, inner: s,
typ: typ, typ: typ,
AttributesHolder: attributes.New(node.Attributes(), attributes.AttributesOwnerGeneral), 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) pr := renderer.(hooks.PassthroughRenderer)
if err := pr.RenderPassthrough(ctx.RenderContext().Ctx, w, pctx); err != nil { if err := pr.RenderPassthrough(ctx.RenderContext().Ctx, w, pctx); err != nil {
@ -164,41 +148,15 @@ func (r *htmlRenderer) renderPassthroughBlock(w util.BufWriter, src []byte, node
return ast.WalkContinue, nil 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 { type passthroughContext struct {
page any hooks.BaseContext
pageInner any
typ string // inner or block typ string // inner or block
inner string 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 *attributes.AttributesHolder
} }
func (p *passthroughContext) Page() any {
return p.page
}
func (p *passthroughContext) PageInner() any {
return p.pageInner
}
func (p *passthroughContext) Type() string { func (p *passthroughContext) Type() string {
return p.typ return p.typ
} }
@ -206,21 +164,3 @@ func (p *passthroughContext) Type() string {
func (p *passthroughContext) Inner() string { func (p *passthroughContext) Inner() string {
return p.inner 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)

View file

@ -169,9 +169,7 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
pos := ctx.PopPos() text := ctx.PopRenderedString()
text := ctx.Buffer.Bytes()[pos:]
ctx.Buffer.Truncate(pos)
var ( var (
isBlock bool isBlock bool
@ -190,13 +188,15 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
// internal attributes before rendering. // internal attributes before rendering.
attrs := r.filterInternalAttributes(n.Attributes()) attrs := r.filterInternalAttributes(n.Attributes())
page, pageInner := render.GetPageAndPageInner(ctx)
err := lr.RenderLink( err := lr.RenderLink(
ctx.RenderContext().Ctx, ctx.RenderContext().Ctx,
w, w,
imageLinkContext{ imageLinkContext{
linkContext: linkContext{ linkContext: linkContext{
page: ctx.DocumentContext().Document, page: page,
pageInner: r.getPageInner(ctx), pageInner: pageInner,
destination: string(n.Destination), destination: string(n.Destination),
title: string(n.Title), title: string(n.Title),
text: hstring.RenderedString(text), text: hstring.RenderedString(text),
@ -211,18 +211,6 @@ func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.N
return ast.WalkContinue, err 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 { func (r *hookedRenderer) filterInternalAttributes(attrs []ast.Attribute) []ast.Attribute {
n := 0 n := 0
for _, x := range attrs { for _, x := range attrs {
@ -288,16 +276,16 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
pos := ctx.PopPos() text := ctx.PopRenderedString()
text := ctx.Buffer.Bytes()[pos:]
ctx.Buffer.Truncate(pos) page, pageInner := render.GetPageAndPageInner(ctx)
err := lr.RenderLink( err := lr.RenderLink(
ctx.RenderContext().Ctx, ctx.RenderContext().Ctx,
w, w,
linkContext{ linkContext{
page: ctx.DocumentContext().Document, page: page,
pageInner: r.getPageInner(ctx), pageInner: pageInner,
destination: string(n.Destination), destination: string(n.Destination),
title: string(n.Title), title: string(n.Title),
text: hstring.RenderedString(text), text: hstring.RenderedString(text),
@ -358,12 +346,14 @@ func (r *hookedRenderer) renderAutoLink(w util.BufWriter, source []byte, node as
url = "mailto:" + url url = "mailto:" + url
} }
page, pageInner := render.GetPageAndPageInner(ctx)
err := lr.RenderLink( err := lr.RenderLink(
ctx.RenderContext().Ctx, ctx.RenderContext().Ctx,
w, w,
linkContext{ linkContext{
page: ctx.DocumentContext().Document, page: page,
pageInner: r.getPageInner(ctx), pageInner: pageInner,
destination: url, destination: url,
text: hstring.RenderedString(label), text: hstring.RenderedString(label),
plainText: label, plainText: label,
@ -435,20 +425,21 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast
return ast.WalkContinue, nil return ast.WalkContinue, nil
} }
pos := ctx.PopPos() text := ctx.PopRenderedString()
text := ctx.Buffer.Bytes()[pos:]
ctx.Buffer.Truncate(pos)
// All ast.Heading nodes are guaranteed to have an attribute called "id" // All ast.Heading nodes are guaranteed to have an attribute called "id"
// that is an array of bytes that encode a valid string. // that is an array of bytes that encode a valid string.
anchori, _ := n.AttributeString("id") anchori, _ := n.AttributeString("id")
anchor := anchori.([]byte) anchor := anchori.([]byte)
page, pageInner := render.GetPageAndPageInner(ctx)
err := hr.RenderHeading( err := hr.RenderHeading(
ctx.RenderContext().Ctx, ctx.RenderContext().Ctx,
w, w,
headingContext{ headingContext{
page: ctx.DocumentContext().Document, page: page,
pageInner: r.getPageInner(ctx), pageInner: pageInner,
level: n.Level, level: n.Level,
anchor: string(anchor), anchor: string(anchor),
text: hstring.RenderedString(text), text: hstring.RenderedString(text),

View file

@ -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
}

View file

@ -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>")
}

View file

@ -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>