]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
markup: Add blockquote render hooks
authorBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Mon, 5 Aug 2024 14:51:16 +0000 (16:51 +0200)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Wed, 7 Aug 2024 16:28:23 +0000 (18:28 +0200)
Closes #12590

hugolib/page__per_output.go
hugolib/site.go
markup/converter/hooks/hooks.go
markup/goldmark/blockquotes/blockquotes.go [new file with mode: 0644]
markup/goldmark/blockquotes/blockquotes_integration_test.go [new file with mode: 0644]
markup/goldmark/blockquotes/blockquotes_test.go [new file with mode: 0644]
markup/goldmark/codeblocks/render.go
markup/goldmark/convert.go
markup/goldmark/passthrough/passthrough.go

index 59ba722a88b3b5e21b5ae32d14719e5f69a5aafc..59cb574dfe03b7ad2f2f0f5a799a18e296702ef0 100644 (file)
@@ -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 {
index 0b089767aecd49ea9c59ef67cac1304262a588e4..bbf110d1d8cb5acd4f4e4177ff3f530c62aa3b76 100644 (file)
@@ -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)
 }
index 1e335fa466f30ba6d939a9f4915be536b1c84ad0..29e848d80cc59e3511c394a242cba3519a71289a 100644 (file)
@@ -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 (file)
index 0000000..f4c908e
--- /dev/null
@@ -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: <p>[!NOTE]<br>\n but preserve leading paragraph.
+               // We could possibly complicate this by moving this to the parser, but
+               // keep it simple for now.
+               texts = "<p>" + 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("<blockquote")
+               html.RenderAttributes(w, n, html.BlockquoteAttributeFilter)
+               _ = w.WriteByte('>')
+       } else {
+               _, _ = w.WriteString("<blockquote>\n")
+       }
+
+       _, _ = w.WriteString(text)
+
+       _, _ = w.WriteString("</blockquote>\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(`^<p>\[!(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 (file)
index 0000000..45c7caa
--- /dev/null
@@ -0,0 +1,83 @@
+// 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 (
+       "path/filepath"
+       "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: |<p>This is a note with some whitespace after the alert type.</p>\n|alert|",
+               "Blockquote Alert: |<p>This is a tip.</p>",
+               "Blockquote Alert: |<p>This is a caution with some whitespace before the alert type.</p>\n|alert|",
+               "Blockquote: |<p>A regular blockquote.</p>\n|regular|",
+               "Blockquote Alert Attributes: |<p>This is a tip with attributes.</p>\n|map[class:foo bar id:baz]|",
+               filepath.FromSlash("/content/p1.md:20:3"),
+               "Blockquote Alert Page: |<p>This is a tip with attributes.</p>\n|p1|p1|",
+       )
+}
diff --git a/markup/goldmark/blockquotes/blockquotes_test.go b/markup/goldmark/blockquotes/blockquotes_test.go
new file mode 100644 (file)
index 0000000..5b2680a
--- /dev/null
@@ -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("<p>"+test.input), qt.Equals, test.expected)
+       }
+}
index 51f3b20f861acb74c159ef46d1473a3eefc0d7ca..fad3ac458091f2595ec0ea5c7775295ba595834b 100644 (file)
@@ -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, "{")
index efb3100aa6dab5f5bb14ac65e901f948ddbd1b8e..357be732860b5da0b43d69df86e81c7a85db444d 100644 (file)
@@ -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
        )
index 20e42211e729818aaa95e7cafd13450a73abb354..aafb1544b9423fa03911d08f463ae169874f38a1 100644 (file)
@@ -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)