diff options
author | Bjørn Erik Pedersen <[email protected]> | 2024-08-05 16:51:16 +0200 |
---|---|---|
committer | Bjørn Erik Pedersen <[email protected]> | 2024-08-07 18:28:23 +0200 |
commit | 665ac949bdc2e54d378ef5c00778c830440a1a9d (patch) | |
tree | 3423b71315155a8d855bb806293470824a78ff19 /markup | |
parent | 4c162deb036a1f65769c12e6078ac79b70f5901b (diff) | |
download | hugo-665ac949bdc2e54d378ef5c00778c830440a1a9d.tar.gz hugo-665ac949bdc2e54d378ef5c00778c830440a1a9d.zip |
markup: Add blockquote render hooks
Closes #12590
Diffstat (limited to 'markup')
-rw-r--r-- | markup/converter/hooks/hooks.go | 35 | ||||
-rw-r--r-- | markup/goldmark/blockquotes/blockquotes.go | 248 | ||||
-rw-r--r-- | markup/goldmark/blockquotes/blockquotes_integration_test.go | 83 | ||||
-rw-r--r-- | markup/goldmark/blockquotes/blockquotes_test.go | 60 | ||||
-rw-r--r-- | markup/goldmark/codeblocks/render.go | 7 | ||||
-rw-r--r-- | markup/goldmark/convert.go | 2 | ||||
-rw-r--r-- | markup/goldmark/passthrough/passthrough.go | 7 |
7 files changed, 442 insertions, 0 deletions
diff --git a/markup/converter/hooks/hooks.go b/markup/converter/hooks/hooks.go index 1e335fa46..29e848d80 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 000000000..f4c908e0b --- /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: <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 index 000000000..45c7caa0f --- /dev/null +++ b/markup/goldmark/blockquotes/blockquotes_integration_test.go @@ -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 index 000000000..5b2680a0d --- /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("<p>"+test.input), qt.Equals, test.expected) + } +} diff --git a/markup/goldmark/codeblocks/render.go b/markup/goldmark/codeblocks/render.go index 51f3b20f8..fad3ac458 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 efb3100aa..357be7328 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 20e42211e..aafb1544b 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) |