diff --git a/docs/content/en/templates/render-hooks.md b/docs/content/en/templates/render-hooks.md index 69b34cc9b97..d2dce75934d 100644 --- a/docs/content/en/templates/render-hooks.md +++ b/docs/content/en/templates/render-hooks.md @@ -25,6 +25,8 @@ The hook kinds currently supported are: * `link` * `heading` * `codeblock`{{< new-in "0.93.0" >}} +* `list`{{< new-in "0.99.0" >}} +* `listitem`{{< new-in "0.99.0" >}} You can define [Output-Format-](/templates/output-formats) and [language-](/content-management/multilingual/)specific templates if needed. Your `layouts` folder may look like this: @@ -179,3 +181,98 @@ Page Position : Useful in error logging as it prints the filename and position (linenumber, column), e.g. `{{ errorf "error in code block: %s" .Position }}`. + +## Render Hooks for Lists and List Items + +{{< new-in "0.99.0" >}} + +You can add a hook template for lists and list items e.g. for different output types + +```goat { class="black f7" } +layouts +└── _default + └── _markup + └── render-listitem.html + └── render-listitem.json + └── render-list.html + └── render-list.json +``` + +The `render-list` template will receive this context: + +Page +: The [Page](/variables/page/) being rendered. + +Text +: The rendered (HTML) list content. + +PlainText +: The plain variant of the above. + +IsOrdered (bool) +: If this is an ordered list. + +Parent +: The Parent node of the list. + +Attributes (map) {{< new-in "0.82.0" >}} +: A map of attributes (e.g. `id`, `class`) + + +The `render-listitem` template will receive this context: + +Page +: The [Page](/variables/page/) being rendered. + +Text +: The rendered (HTML) list content. + +PlainText +: The plain variant of the above. + +IsFirst (bool) +: If this is the first item in the list. + +IsLast (bool) +: If this is the last item in the list. + +Parent +: The Parent node of the item, the list node. + +### ListItem rendered as JSON-LD example: + +```md +1. Do This +2. Then That +``` + +Here is a code example for how the render-listitem.json template could look: + +{{< code file="layouts/_default/_markup/render-list.html" >}} +{{- if eq .Parent.IsOrdered true -}} +{ + "@type": "HowToStep", + "text": "{{ .Text | plainify}}" +}{{ if not .IsLast }},{{ end }}{{ print "\n"}} +{{- else -}} +{{- if not .IsLast -}} + {{ printf "\"%s\",\n" .Text | plainify}} +{{- else -}} + {{ printf "\"%s\"" .Text | plainify}} +{{- end -}} +{{- end -}} +{{< /code >}} + + +The rendered html will be + +```js +{ + "@type": "HowToStep", + "text": "Do This" +}, +{ + "@type": "HowToStep", + "text": "Then That" +} +``` \ No newline at end of file diff --git a/hugolib/content_render_hooks_test.go b/hugolib/content_render_hooks_test.go index 5b2121ef807..36342d1638a 100644 --- a/hugolib/content_render_hooks_test.go +++ b/hugolib/content_render_hooks_test.go @@ -97,7 +97,8 @@ Inner Block: {{ .Inner | .Page.RenderString (dict "display" "block" ) }} b.WithTemplatesAdded("_default/_markup/render-image.html", `IMAGE: {{ .Page.Title }}||{{ .Destination | safeURL }}|Title: {{ .Title | safeHTML }}|Text: {{ .Text | safeHTML }}|END`) b.WithTemplatesAdded("_default/_markup/render-heading.html", `HEADING: {{ .Page.Title }}||Level: {{ .Level }}|Anchor: {{ .Anchor | safeURL }}|Text: {{ .Text | safeHTML }}|Attributes: {{ .Attributes }}|END`) b.WithTemplatesAdded("docs/_markup/render-heading.html", `Docs Level: {{ .Level }}|END`) - + b.WithTemplatesAdded("_default/_markup/render-list.html", `LIST: {{ .Text | safeHTML }}|{{ .Attributes }}|{{ .IsOrdered }} `) + b.WithTemplatesAdded("_default/_markup/render-listitem.html", `LISTITEM: {{ .Text | safeHTML }} {{ .IsFirst }} {{ .IsLast }} `) b.WithContent("customview/p1.md", `--- title: Custom View --- @@ -195,6 +196,21 @@ title: Doc With Heading # Docs lvl 1 +`, "blog/p9.md", `--- +title: With List Items +--- +- Dog +- Cat +- Mouse **Fat** +- Bird +{.animal} +`, "blog/p10.md", `--- +title: With Ordered List +--- +1. Car +2. Boat +3. Plane +{.transportation} `, ) @@ -209,7 +225,7 @@ title: No Template } counters := &testCounters{} b.Build(BuildCfg{testCounters: counters}) - b.Assert(int(counters.contentRenderCounter), qt.Equals, 45) + b.Assert(int(counters.contentRenderCounter), qt.Equals, 47) b.AssertFileContent("public/blog/p1/index.html", ` Cool Page|https://www.google.com|Title: Google's Homepage|Text: First Link|END @@ -264,6 +280,14 @@ SHORT3| // https://github.com/gohugoio/hugo/issues/7349 b.AssertFileContent("public/docs/p8/index.html", "Docs Level: 1") + + b.AssertFileContent("public/blog/p9/index.html", "LISTITEM: Dog") + b.AssertFileContent("public/blog/p9/index.html", "LISTITEM: Cat") + b.AssertFileContent("public/blog/p9/index.html", "LISTITEM: Mouse Fat") + b.AssertFileContent("public/blog/p9/index.html", "class:animal") + + b.AssertFileContent("public/blog/p10/index.html", "LIST:") + b.AssertFileContent("public/blog/p10/index.html", "class:transportation") } func TestRenderHooksDeleteTemplate(t *testing.T) { diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index 3e61a45133a..4f1af15b5ba 100644 --- a/hugolib/page__per_output.go +++ b/hugolib/page__per_output.go @@ -646,6 +646,10 @@ func (p *pageContentOutput) initRenderHooks() error { layoutDescriptor.KindVariants = lang } } + case hooks.ListItemRendererType: + layoutDescriptor.Kind = "render-listitem" + case hooks.ListRendererType: + layoutDescriptor.Kind = "render-list" } getHookTemplate := func(f output.Format) (tpl.Template, bool) { diff --git a/hugolib/site.go b/hugolib/site.go index 1b0c48cbcd2..8ebdbb4a615 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -1810,6 +1810,14 @@ func (hr hookRendererTemplate) RenderCodeblock(cctx context.Context, w hugio.Fle return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) } +func (hr hookRendererTemplate) RenderListItem(cctx context.Context, w io.Writer, ctx hooks.ListItemContext) error { + return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) +} + +func (hr hookRendererTemplate) RenderList(cctx context.Context, w io.Writer, ctx hooks.ListContext) 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 55d7c1127fc..026b2fdd33d 100644 --- a/markup/converter/hooks/hooks.go +++ b/markup/converter/hooks/hooks.go @@ -124,6 +124,35 @@ type HeadingRenderer interface { identity.Provider } +type ListItemContext interface { + Page() interface{} + Text() hstring.RenderedString + PlainText() string + IsFirst() bool + IsLast() bool + Parent() interface{} +} + +type ListItemRenderer interface { + RenderListItem(cctx context.Context, w io.Writer, ctx ListItemContext) error + identity.Provider +} + +type ListContext interface { + Page() interface{} + Text() hstring.RenderedString + PlainText() string + IsOrdered() bool + Parent() interface{} + + AttributesProvider +} + +type ListRenderer interface { + RenderList(cctx context.Context, w io.Writer, ctx ListContext) error + identity.Provider +} + // ElementPositionResolver provides a way to resolve the start Position // of a markdown element in the original source document. // This may be both slow and approximate, so should only be @@ -139,6 +168,8 @@ const ( ImageRendererType HeadingRendererType CodeBlockRendererType + ListItemRendererType + ListRendererType ) type GetRendererFunc func(t RendererType, id any) any diff --git a/markup/goldmark/render_hooks.go b/markup/goldmark/render_hooks.go index 0bd800dc0d9..7cc03faeafb 100644 --- a/markup/goldmark/render_hooks.go +++ b/markup/goldmark/render_hooks.go @@ -15,6 +15,7 @@ package goldmark import ( "bytes" + "fmt" "strings" "github.com/gohugoio/hugo/common/types/hstring" @@ -119,6 +120,68 @@ func (ctx headingContext) PlainText() string { return ctx.plainText } +type listItemContext struct { + page interface{} + text hstring.RenderedString + plainText string + isFirst bool + isLast bool + parent interface{} +} + +func (ctx listItemContext) Page() interface{} { + return ctx.page +} + +func (ctx listItemContext) Text() hstring.RenderedString { + return ctx.text +} + +func (ctx listItemContext) PlainText() string { + return ctx.plainText +} + +func (ctx listItemContext) IsFirst() bool { + return ctx.isFirst +} + +func (ctx listItemContext) IsLast() bool { + return ctx.isLast +} + +func (ctx listItemContext) Parent() interface{} { + return ctx.parent +} + +type listContext struct { + page interface{} + text hstring.RenderedString + plainText string + isOrdered bool + parent interface{} + *attributes.AttributesHolder +} + +func (ctx listContext) Page() interface{} { + return ctx.page +} + +func (ctx listContext) Text() hstring.RenderedString { + return ctx.text +} + +func (ctx listContext) PlainText() string { + return ctx.plainText +} + +func (ctx listContext) IsOrdered() bool { + return ctx.isOrdered +} + +func (ctx listContext) Parent() interface{} { + return ctx.parent +} + type hookedRenderer struct { linkifyProtocol []byte html.Config @@ -134,6 +197,8 @@ func (r *hookedRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) reg.Register(ast.KindAutoLink, r.renderAutoLink) reg.Register(ast.KindImage, r.renderImage) reg.Register(ast.KindHeading, r.renderHeading) + reg.Register(ast.KindListItem, r.renderListItem) + reg.Register(ast.KindList, r.renderList) } func (r *hookedRenderer) renderImage(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { @@ -465,6 +530,139 @@ func (r *hookedRenderer) renderHeadingDefault(w util.BufWriter, source []byte, n return ast.WalkContinue, nil } +func (r *hookedRenderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.ListItem) + var hli hooks.ListItemRenderer + + ctx, ok := w.(*render.Context) + if ok { + h := ctx.RenderContext().GetRenderer(hooks.ListItemRendererType, nil) + ok = h != nil + if ok { + hli = h.(hooks.ListItemRenderer) + } + } + + if !ok { + return r.renderListItemDefault(w, source, node, entering) + } + + if entering { + // Store the current pos so we can capture the rendered text. + ctx.PushPos(ctx.Buffer.Len()) + return ast.WalkContinue, nil + } + + pos := ctx.PopPos() + text := ctx.Buffer.Bytes()[pos:] + ctx.Buffer.Truncate(pos) + + err := hli.RenderListItem( + ctx.RenderContext().Ctx, + w, + listItemContext{ + page: ctx.DocumentContext().Document, + text: hstring.RenderedString(text), + plainText: string(n.Text(source)), + isFirst: n.PreviousSibling() == nil, + isLast: n.NextSibling() == nil, + parent: n.Parent(), + }, + ) + + ctx.AddIdentity(hli) + + return ast.WalkContinue, err +} + +// Fall back to the default Goldmark render funcs. Method below borrowed from: +// https://github.com/yuin/goldmark/blob/5588d92a56fe1642791cf4aa8e9eae8227cfeecd/renderer/html/html.go#L353 +func (r *hookedRenderer) renderListItemDefault(w util.BufWriter, source []byte, n ast.Node, entering bool) (ast.WalkStatus, error) { + if entering { + _, _ = w.WriteString("
  • ") + fc := n.FirstChild() + if fc != nil { + if _, ok := fc.(*ast.TextBlock); !ok { + _ = w.WriteByte('\n') + } + } + } else { + _, _ = w.WriteString("
  • \n") + } + return ast.WalkContinue, nil +} + +// Fall back to the default Goldmark render funcs. Method below borrowed from: +// https://github.com/yuin/goldmark/blob/5588d92a56fe1642791cf4aa8e9eae8227cfeecd/renderer/html/html.go#L324 +func (r *hookedRenderer) renderListDefault(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.List) + tag := "ul" + if n.IsOrdered() { + tag = "ol" + } + if entering { + _ = w.WriteByte('<') + _, _ = w.WriteString(tag) + if n.IsOrdered() && n.Start != 1 { + fmt.Fprintf(w, " start=\"%d\"", n.Start) + } + if n.Attributes() != nil { + html.RenderAttributes(w, n, html.ListAttributeFilter) + } + _, _ = w.WriteString(">\n") + } else { + _, _ = w.WriteString("\n") + } + return ast.WalkContinue, nil +} + +func (r *hookedRenderer) renderList(w util.BufWriter, source []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + n := node.(*ast.List) + var hli hooks.ListRenderer + + ctx, ok := w.(*render.Context) + if ok { + h := ctx.RenderContext().GetRenderer(hooks.ListRendererType, nil) + ok = h != nil + if ok { + hli = h.(hooks.ListRenderer) + } + } + + if !ok { + return r.renderListDefault(w, source, node, entering) + } + + if entering { + // Store the current pos so we can capture the rendered text. + ctx.PushPos(ctx.Buffer.Len()) + return ast.WalkContinue, nil + } + + pos := ctx.PopPos() + text := ctx.Buffer.Bytes()[pos:] + ctx.Buffer.Truncate(pos) + + err := hli.RenderList( + ctx.RenderContext().Ctx, + w, + listContext{ + page: ctx.DocumentContext().Document, + text: hstring.RenderedString(text), + plainText: string(n.Text(source)), + isOrdered: n.IsOrdered(), + parent: n.Parent(), + AttributesHolder: attributes.New(n.Attributes(), attributes.AttributesOwnerGeneral), + }, + ) + + ctx.AddIdentity(hli) + + return ast.WalkContinue, err +} + type links struct { cfg goldmark_config.Config }