diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go index 59ba722a88b..59cb574dfe0 100644 --- a/hugolib/page__per_output.go +++ b/hugolib/page__per_output.go @@ -440,8 +440,8 @@ func (pco *pageContentOutput) initRenderHooks() error { var offset int switch v := ctx.(type) { - case hooks.CodeblockContext: - offset = bytes.Index(source, []byte(v.Inner())) + case hooks.PositionerSourceTargetProvider: + offset = bytes.Index(source, v.PositionerSourceTarget()) } pos := pco.po.p.posFromInput(source, offset) @@ -481,6 +481,11 @@ func (pco *pageContentOutput) initRenderHooks() error { if id != nil { layoutDescriptor.KindVariants = id.(string) } + case hooks.BlockquoteRendererType: + layoutDescriptor.Kind = "render-blockquote" + if id != nil { + layoutDescriptor.KindVariants = id.(string) + } case hooks.CodeBlockRendererType: layoutDescriptor.Kind = "render-codeblock" if id != nil { diff --git a/hugolib/site.go b/hugolib/site.go index 0b089767aec..bbf110d1d8c 100644 --- a/hugolib/site.go +++ b/hugolib/site.go @@ -919,6 +919,10 @@ func (hr hookRendererTemplate) RenderPassthrough(cctx context.Context, w io.Writ return hr.templateHandler.ExecuteWithContext(cctx, hr.templ, w, ctx) } +func (hr hookRendererTemplate) RenderBlockquote(cctx context.Context, w hugio.FlexiWriter, ctx hooks.BlockquoteContext) 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 1e335fa466f..29e848d80cc 100644 --- a/markup/converter/hooks/hooks.go +++ b/markup/converter/hooks/hooks.go @@ -78,6 +78,33 @@ type CodeblockContext interface { Ordinal() int } +// BlockquoteContext is the context passed to a blockquote render hook. +type BlockquoteContext interface { + AttributesProvider + text.Positioner + PageProvider + + // Zero-based ordinal for all block quotes in the current document. + Ordinal() int + + // The blockquote text. + // If type is "alert", this will be the alert text. + Text() hstring.RenderedString + + /// Returns the blockquote type, one of "regular" and "alert". + // Type "alert" indicates that this is a GitHub type alert. + Type() string + + // The GitHub alert type converted to lowercase, e.g. "note". + // Only set if Type is "alert". + AlertType() string +} + +type PositionerSourceTargetProvider interface { + // For internal use. + PositionerSourceTarget() []byte +} + // PassThroughContext is the context passed to a passthrough render hook. type PassthroughContext interface { AttributesProvider @@ -87,6 +114,9 @@ type PassthroughContext interface { // 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 } @@ -104,6 +134,10 @@ type CodeBlockRenderer interface { RenderCodeblock(cctx context.Context, w hugio.FlexiWriter, ctx CodeblockContext) error } +type BlockquoteRenderer interface { + RenderBlockquote(cctx context.Context, w hugio.FlexiWriter, ctx BlockquoteContext) error +} + type PassthroughRenderer interface { RenderPassthrough(cctx context.Context, w io.Writer, ctx PassthroughContext) error } @@ -161,6 +195,7 @@ const ( HeadingRendererType CodeBlockRendererType PassthroughRendererType + BlockquoteRendererType ) type GetRendererFunc func(t RendererType, id any) any diff --git a/markup/goldmark/blockquotes/blockquotes.go b/markup/goldmark/blockquotes/blockquotes.go new file mode 100644 index 00000000000..f4c908e0ba9 --- /dev/null +++ b/markup/goldmark/blockquotes/blockquotes.go @@ -0,0 +1,248 @@ +// 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 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" + "github.com/gohugoio/hugo/markup/internal/attributes" + "github.com/yuin/goldmark" + "github.com/yuin/goldmark/ast" + "github.com/yuin/goldmark/renderer" + "github.com/yuin/goldmark/renderer/html" + "github.com/yuin/goldmark/util" +) + +type ( + blockquotesExtension struct{} + htmlRenderer struct{} +) + +func New() goldmark.Extender { + return &blockquotesExtension{} +} + +func (e *blockquotesExtension) 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(ast.KindBlockquote, r.renderBlockquote) +} + +const ( + typeRegular = "regular" + typeAlert = "alert" +) + +func (r *htmlRenderer) renderBlockquote(w util.BufWriter, src []byte, node ast.Node, entering bool) (ast.WalkStatus, error) { + ctx := w.(*render.Context) + + n := node.(*ast.Blockquote) + + 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) + + // Extract a source sample to use for position information. + nn := n.FirstChild() + 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] + + ordinal := ctx.GetAndIncrementOrdinal(ast.KindBlockquote) + + texts := string(text) + typ := typeRegular + alertType := resolveGitHubAlert(texts) + if alertType != "" { + typ = typeAlert + } + + renderer := ctx.RenderContext().GetRenderer(hooks.BlockquoteRendererType, typ) + if renderer == nil { + return r.renderBlockquoteDefault(w, n, texts) + } + + 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:] + } + + bqctx := &blockquoteContext{ + page: ctx.DocumentContext().Document, + pageInner: r.getPageInner(ctx), + typ: typ, + alertType: alertType, + text: hstring.RenderedString(texts), + sourceRef: sourceRef, + ordinal: ordinal, + 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( + ctx.RenderContext().Ctx, + w, + bqctx, + ) + if err != nil { + return ast.WalkContinue, herrors.NewFileErrorFromPos(err, bqctx.createPos()) + } + + 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, +) (ast.WalkStatus, error) { + if n.Attributes() != nil { + _, _ = w.WriteString("') + } else { + _, _ = w.WriteString("

\n") + } + + _, _ = w.WriteString(text) + + _, _ = w.WriteString("
\n") + return ast.WalkContinue, nil +} + +type blockquoteContext struct { + page any + 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, + // so delay creation until needed. + pos htext.Position + posInit sync.Once + createPos func() htext.Position + + *attributes.AttributesHolder +} + +func (c *blockquoteContext) Type() string { + return c.typ +} + +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] +var gitHubAlertRe = regexp.MustCompile(`^

\[!(NOTE|TIP|WARNING|IMPORTANT|CAUTION)\]`) + +// resolveGitHubAlert returns one of note, tip, warning, important or caution. +// An empty string if no match. +func resolveGitHubAlert(s string) string { + m := gitHubAlertRe.FindStringSubmatch(s) + if len(m) == 2 { + return strings.ToLower(m[1]) + } + return "" +} diff --git a/markup/goldmark/blockquotes/blockquotes_integration_test.go b/markup/goldmark/blockquotes/blockquotes_integration_test.go new file mode 100644 index 00000000000..8e672f07033 --- /dev/null +++ b/markup/goldmark/blockquotes/blockquotes_integration_test.go @@ -0,0 +1,82 @@ +// 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 blockquotes_test + +import ( + "testing" + + "github.com/gohugoio/hugo/hugolib" +) + +func TestBlockquoteHook(t *testing.T) { + t.Parallel() + + files := ` +-- hugo.toml -- +[markup] + [markup.goldmark] + [markup.goldmark.parser] + [markup.goldmark.parser.attribute] + block = true + title = true +-- layouts/_default/_markup/render-blockquote.html -- +Blockquote: |{{ .Text | safeHTML }}|{{ .Type }}| +-- layouts/_default/_markup/render-blockquote-alert.html -- +{{ $text := .Text | safeHTML }} +Blockquote Alert: |{{ $text }}|{{ .Type }}| +Blockquote Alert Attributes: |{{ $text }}|{{ .Attributes }}| +Blockquote Alert Page: |{{ $text }}|{{ .Page.Title }}|{{ .PageInner.Title }}| +{{ if .Attributes.showpos }} +Blockquote Alert Position: |{{ $text }}|{{ .Position | safeHTML }}| +{{ end }} +-- layouts/_default/single.html -- +Content: {{ .Content }} +-- content/p1.md -- +--- +title: "p1" +--- + +> [!NOTE] +> This is a note with some whitespace after the alert type. + + +> [!TIP] +> This is a tip. + +> [!CAUTION] +> This is a caution with some whitespace before the alert type. + +> A regular blockquote. + +> [!TIP] +> This is a tip with attributes. +{class="foo bar" id="baz"} + +> [!NOTE] +> Note triggering showing the position. +{showpos="true"} + +` + + b := hugolib.Test(t, files) + b.AssertFileContent("public/p1/index.html", + "Blockquote Alert: |

This is a note with some whitespace after the alert type.

\n|alert|", + "Blockquote Alert: |

This is a tip.

", + "Blockquote Alert: |

This is a caution with some whitespace before the alert type.

\n|alert|", + "Blockquote: |

A regular blockquote.

\n|regular|", + "Blockquote Alert Attributes: |

This is a tip with attributes.

\n|map[class:foo bar id:baz]|", + "Blockquote Alert Position: |

Note triggering showing the position.

\n|\"/content/p1.md:20:3\"|", + "Blockquote Alert Page: |

This is a tip with attributes.

\n|p1|p1|", + ) +} diff --git a/markup/goldmark/blockquotes/blockquotes_test.go b/markup/goldmark/blockquotes/blockquotes_test.go new file mode 100644 index 00000000000..5b2680a0dbd --- /dev/null +++ b/markup/goldmark/blockquotes/blockquotes_test.go @@ -0,0 +1,60 @@ +// 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 blockquotes + +import ( + "testing" + + qt "github.com/frankban/quicktest" +) + +func TestResolveGitHubAlert(t *testing.T) { + t.Parallel() + + c := qt.New(t) + + tests := []struct { + input string + expected string + }{ + { + input: "[!NOTE]", + expected: "note", + }, + { + input: "[!WARNING]", + expected: "warning", + }, + { + input: "[!TIP]", + expected: "tip", + }, + { + input: "[!IMPORTANT]", + expected: "important", + }, + { + input: "[!CAUTION]", + expected: "caution", + }, + { + input: "[!FOO]", + expected: "", + }, + } + + for _, test := range tests { + c.Assert(resolveGitHubAlert("

"+test.input), qt.Equals, test.expected) + } +} diff --git a/markup/goldmark/codeblocks/render.go b/markup/goldmark/codeblocks/render.go index 51f3b20f861..fad3ac45809 100644 --- a/markup/goldmark/codeblocks/render.go +++ b/markup/goldmark/codeblocks/render.go @@ -147,6 +147,8 @@ func (r *htmlRenderer) getPageInner(rctx *render.Context) any { return rctx.DocumentContext().Document } +var _ hooks.PositionerSourceTargetProvider = (*codeBlockContext)(nil) + type codeBlockContext struct { page any pageInner any @@ -190,6 +192,11 @@ func (c *codeBlockContext) Position() htext.Position { 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 efb3100aa6d..357be732860 100644 --- a/markup/goldmark/convert.go +++ b/markup/goldmark/convert.go @@ -18,6 +18,7 @@ import ( "bytes" "github.com/gohugoio/hugo-goldmark-extensions/extras" + "github.com/gohugoio/hugo/markup/goldmark/blockquotes" "github.com/gohugoio/hugo/markup/goldmark/codeblocks" "github.com/gohugoio/hugo/markup/goldmark/goldmark_config" "github.com/gohugoio/hugo/markup/goldmark/hugocontext" @@ -107,6 +108,7 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown { hugocontext.New(), newLinks(cfg), newTocExtension(tocRendererOptions), + blockquotes.New(), } parserOptions []parser.Option ) diff --git a/markup/goldmark/passthrough/passthrough.go b/markup/goldmark/passthrough/passthrough.go index 20e42211e72..aafb1544b94 100644 --- a/markup/goldmark/passthrough/passthrough.go +++ b/markup/goldmark/passthrough/passthrough.go @@ -217,3 +217,10 @@ func (p *passthroughContext) Position() htext.Position { }) return p.pos } + +// For internal use. +func (p *passthroughContext) PositionerSourceTarget() []byte { + return []byte(p.inner) +} + +var _ hooks.PositionerSourceTargetProvider = (*passthroughContext)(nil)