From 6df48e8f73157294898697a065e9522f816d6ada 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 --- common/types/hstring/stringtypes.go | 4 +- hugolib/page__per_output.go | 2 + hugolib/site.go | 4 + markup/converter/hooks/hooks.go | 39 +++- markup/goldmark/blockquotes/blockquotes.go | 89 +-------- markup/goldmark/codeblocks/render.go | 71 +------ markup/goldmark/convert.go | 2 + markup/goldmark/internal/render/context.go | 138 ++++++++++++++ markup/goldmark/passthrough/passthrough.go | 71 +------ markup/goldmark/render_hooks.go | 36 ++-- markup/goldmark/tables/tables.go | 177 ++++++++++++++++++ .../tables/tables_integration_test.go | 67 +++++++ .../_default/_markup/render-table.html | 26 +++ 13 files changed, 492 insertions(+), 234 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/common/types/hstring/stringtypes.go b/common/types/hstring/stringtypes.go index 5e8e3a23dbf..c92feba7b80 100644 --- a/common/types/hstring/stringtypes.go +++ b/common/types/hstring/stringtypes.go @@ -13,7 +13,9 @@ package hstring -type RenderedString string +import "html/template" + +type RenderedString template.HTML func (s RenderedString) String() string { return string(s) diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index f074e8db716..34c32019488 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 { 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..295845c4070 100644 --- a/markup/converter/hooks/hooks.go +++ b/markup/converter/hooks/hooks.go @@ -78,14 +78,30 @@ type CodeblockContext interface { 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. +// TODO1 use this in the render hooks below. +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. @@ -138,6 +154,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 +216,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..52c508d14cd 100644 --- a/markup/goldmark/blockquotes/blockquotes.go +++ b/markup/goldmark/blockquotes/blockquotes.go @@ -72,69 +72,37 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N } pos := ctx.PopPos() - text := ctx.Buffer.Bytes()[pos:] + text := string(ctx.Buffer.Bytes()[pos:]) ctx.Buffer.Truncate(pos) ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote) - texts := string(text) typ := typeRegular - alertType := resolveGitHubAlert(texts) + alertType := resolveGitHubAlert(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, 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( @@ -149,18 +117,6 @@ func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.N 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,13 +136,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 + typ string // This is only used in error situations and is expensive to create, // so delay creation until needed. @@ -205,35 +159,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..f35a054023b 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, 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..227cb196ffe 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 { @@ -91,6 +96,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 +145,104 @@ func (ctx *RenderContextDataHolder) RenderContext() converter.RenderContext { func (ctx *RenderContextDataHolder) DocumentContext() converter.DocumentContext { return ctx.Dctx } + +// SourceSample 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 SourceSample(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, ordinal int) hooks.BaseContext { + page, pageInner := GetPageAndPageInner(rctx) // TODO1 unexport. + b := &hookBase{ + page: page, + pageInner: pageInner, + + getSourceSample: func() []byte { return SourceSample(n, src) }, // TODO1 unexport. + 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..5da783f9881 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" @@ -130,31 +127,19 @@ func (r *htmlRenderer) renderPassthroughBlock(w util.BufWriter, src []byte, node } // Inline and block passthroughs share the same ordinal counter. + // TODO1 move this into NewBaseContext. ordinal := ctx.GetAndIncrementOrdinal(passthrough.KindPassthroughBlock) // Trim the delimiters. 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, 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 +149,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 +165,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..592ecffb961 100644 --- a/markup/goldmark/render_hooks.go +++ b/markup/goldmark/render_hooks.go @@ -190,13 +190,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 +213,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 { @@ -292,12 +282,14 @@ func (r *hookedRenderer) renderLink(w util.BufWriter, source []byte, node ast.No text := ctx.Buffer.Bytes()[pos:] ctx.Buffer.Truncate(pos) + 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 +350,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, @@ -443,12 +437,14 @@ func (r *hookedRenderer) renderHeading(w util.BufWriter, source []byte, node ast 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..3e39f7d3b69 --- /dev/null +++ b/markup/goldmark/tables/tables.go @@ -0,0 +1,177 @@ +// 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("renderer not found") + } + + ordinal := ctx.GetAndIncrementOrdinal(gast.KindTable) + + tctx := &tableContext{ + BaseContext: render.NewBaseContext(ctx, renderer, n, source, 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) + + pos := ctx.PopPos() + text := string(ctx.Buffer.Bytes()[pos:]) + ctx.Buffer.Truncate(pos) + + 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 00000000000..fba818802f8 --- /dev/null +++ b/markup/goldmark/tables/tables_integration_test.go @@ -0,0 +1,67 @@ +// 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 -- +-- content/p1.md -- +## Table + +| 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/_default/single.html -- +{{ .Content }} +-- layouts/_default/_markup/render-table.html -- + + + {{ range .THead }} + + {{ range . }} + + {{ end }} + + {{ end }} + + + {{ range .TBody }} + + {{ range . }} + + {{ end }} + + {{ end }} + +
+ {{ .Text }} +
+ {{ .Text }} +
+` + b := hugolib.Test(t, files) + + b.AssertFileContent("public/p1/index.html", "Python") +} 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..f85ff5eee56 --- /dev/null +++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-table.html @@ -0,0 +1,26 @@ + + + {{ range .THead }} + + {{ range . }} + + {{ end }} + + {{ end }} + + + {{ range .TBody }} + + {{ range . }} + + {{ end }} + + {{ end }} + +
+ {{ .Text }} +
+ {{ .Text }} +