From f738669a4d09ca04619f4d0f89d90c9b414e9f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bj=C3=B8rn=20Erik=20Pedersen?= Date: Fri, 30 Aug 2024 10:58:43 +0200 Subject: [PATCH] Add Markdown render hooks for tables Fixes #9316 Fixes #12811 --- hugolib/page__per_output.go | 16 +- hugolib/site.go | 4 + markup/converter/hooks/hooks.go | 50 +++-- markup/goldmark/blockquotes/blockquotes.go | 103 ++-------- markup/goldmark/codeblocks/render.go | 71 +------ markup/goldmark/convert.go | 2 + markup/goldmark/convert_test.go | 22 ++- markup/goldmark/internal/render/context.go | 150 +++++++++++++++ markup/goldmark/passthrough/passthrough.go | 70 +------ markup/goldmark/render_hooks.go | 49 ++--- markup/goldmark/tables/tables.go | 175 +++++++++++++++++ .../tables/tables_integration_test.go | 181 ++++++++++++++++++ .../_default/_markup/render-table.html | 29 +++ 13 files changed, 651 insertions(+), 271 deletions(-) create mode 100644 markup/goldmark/tables/tables.go create mode 100644 markup/goldmark/tables/tables_integration_test.go create mode 100644 tpl/tplimpl/embedded/templates/_default/_markup/render-table.html diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index f074e8db7..6ebddbe44 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 a93bbdbe6..d0546e910 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 29e848d80..9487fd0a7 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. +// 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 } // BlockquoteContext is the context passed to a blockquote render hook. type BlockquoteContext interface { - AttributesProvider - text.Positioner - PageProvider + BaseContext - // Zero-based ordinal for all block quotes in the current document. - Ordinal() int + 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 d26c92669..a261ec4fe 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:

[!NOTE]
\n but preserve leading paragraph. // We could possibly complicate this by moving this to the parser, but // keep it simple for now. - texts = "

" + 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 = "

" + 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 - text hstring.RenderedString - typ string - sourceRef []byte - alertType string - ordinal int + hooks.BaseContext - // 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 + text hstring.RenderedString + alertType string + 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 fad3ac458..4164f0e0a 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 357be7328..5c31eee40 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/convert_test.go b/markup/goldmark/convert_test.go index 4c5039e74..6048bce39 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, `Live Demo here!`) c.Assert(got, qt.Contains, `https://foo.bar/`) @@ -191,7 +195,7 @@ unsafe = true // Extensions c.Assert(got, qt.Contains, `Autolink: https://gohugo.io/`) c.Assert(got, qt.Contains, `Strikethrough:Hi Hello, world`) - c.Assert(got, qt.Contains, `foo`) + c.Assert(got, qt.Contains, `Table`) c.Assert(got, qt.Contains, `

  • Push my commits to GitHub
  • `) c.Assert(got, qt.Contains, `Straight double “quotes” and single ‘quotes’`) @@ -378,7 +382,7 @@ func TestConvertAttributes(t *testing.T) { | ------------- |:-------------:| -----:| | AV | BV | {.myclass }`, - "\n", + "Table", }, { "Title and Blockquote", @@ -741,3 +745,11 @@ escapedSpace=true c.Assert(got, qt.Contains, "

    私は太郎です。\nプログラミングが好きです。運動が苦手です。

    \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/internal/render/context.go b/markup/goldmark/internal/render/context.go index 712e1f053..b8cf9ba54 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 aafb1544b..4d72e7c80 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,41 +148,15 @@ 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 + hooks.BaseContext + + typ string // inner or block + inner string - // 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 } -func (p *passthroughContext) Page() any { - return p.page -} - -func (p *passthroughContext) PageInner() any { - return p.pageInner -} - func (p *passthroughContext) Type() string { return p.typ } @@ -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 c127a2c0e..790b2f4ca 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 000000000..61c9b893f --- /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/markup/goldmark/tables/tables_integration_test.go b/markup/goldmark/tables/tables_integration_test.go new file mode 100644 index 000000000..8055265c8 --- /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", "
    ") +} + +// 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", "
    ") + b.AssertFileContent("public/index.html", "
    ") +} + +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", "
    ") +} 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 000000000..307f0a5a3 --- /dev/null +++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html @@ -0,0 +1,29 @@ +
    + + {{- range .THead }} + + {{- range . }} + + {{- end }} + + {{- end }} + + + {{- range .TBody }} + + {{- range . }} + + {{- end }} + + {{- end }} + +
    + {{- .Text | safeHTML -}} +
    + {{- .Text | safeHTML -}} +