]> git.maquefel.me Git - brevno-suite/hugo/commitdiff
markup/goldmark: Enhance footnote extension with auto-prefixing option
authorJoe Mooring <joe.mooring@veriphor.com>
Sat, 13 Sep 2025 03:19:18 +0000 (20:19 -0700)
committerBjørn Erik Pedersen <bjorn.erik.pedersen@gmail.com>
Sat, 27 Sep 2025 16:16:42 +0000 (18:16 +0200)
This commit introduces a new option, enableAutoIDPrefix, to the Goldmark
footnote extension. When enabled, it prepends a unique prefix to
footnote IDs, preventing clashes when multiple documents are rendered
together. This prefix is unique to each logical path, which means that
the prefix is not unique across content dimensions such as language.
This change also refactors the extension's configuration from a boolean
to a struct.

Closes #8045

docs/data/docs.yaml
hugolib/page__meta.go
markup/goldmark/convert.go
markup/goldmark/goldmark_config/config.go
markup/goldmark/goldmark_integration_test.go
markup/markup_config/config.go
markup/markup_config/config_test.go

index d4ca1449e1b2c6a0442d102bfcecb2ed5db3fc4b..e359ff6c98a7bd01b4d5033e92dcc626dd69bec3 100644 (file)
@@ -1238,7 +1238,9 @@ config:
             enable: false
           superscript:
             enable: false
-        footnote: true
+        footnote:
+          enable: true
+          enableAutoIDPrefix: false
         linkify: true
         linkifyProtocol: https
         passthrough:
index 5a27d1913cd58dcd8c994a427f90d1a756a20dfc..f04e0bd56eed92aab44b68fecddeb232445e624c 100644 (file)
@@ -794,11 +794,9 @@ func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.
                return converter.NopConverter, fmt.Errorf("no content renderer found for markup %q, page: %s", markup, ps.getPageInfoForError())
        }
 
-       var id string
        var filename string
        var path string
        if p.f != nil {
-               id = p.f.UniqueID()
                filename = p.f.Filename()
                path = p.f.Path()
        } else {
@@ -822,7 +820,7 @@ func (p *pageMeta) newContentConverter(ps *pageState, markup string) (converter.
                converter.DocumentContext{
                        Document:       doc,
                        DocumentLookup: documentLookup,
-                       DocumentID:     id,
+                       DocumentID:     hashing.XxHashFromStringHexEncoded(p.Path()),
                        DocumentName:   path,
                        Filename:       filename,
                },
index fb374e6aee3efde3207ed3f1b41d748d2638bc0e..7b23c89865b5a4045bf46d803f28d552e84794ff 100644 (file)
@@ -176,8 +176,15 @@ func newMarkdown(pcfg converter.ProviderConfig) goldmark.Markdown {
                extensions = append(extensions, extension.DefinitionList)
        }
 
-       if cfg.Extensions.Footnote {
-               extensions = append(extensions, extension.Footnote)
+       if cfg.Extensions.Footnote.Enable {
+               if cfg.Extensions.Footnote.EnableAutoIDPrefix {
+                       extensions = append(extensions, extension.NewFootnote(extension.WithFootnoteIDPrefixFunction(func(n ast.Node) []byte {
+                               documentID := n.OwnerDocument().Meta()["documentID"].(string)
+                               return []byte("h" + documentID)
+                       })))
+               } else {
+                       extensions = append(extensions, extension.Footnote)
+               }
        }
 
        if cfg.Extensions.CJK.Enable {
@@ -262,6 +269,7 @@ func (c *goldmarkConverter) Parse(ctx converter.RenderContext) (converter.Result
                reader,
                parser.WithContext(pctx),
        )
+       doc.OwnerDocument().AddMeta("documentID", c.ctx.DocumentID)
 
        return parserResult{
                doc: doc,
index 168a3b8fad1f186a208ec517e013682c5f677404..fe808f5d4cab2c79a88656a08a49352ed87d3b34 100644 (file)
@@ -40,7 +40,10 @@ var Default = Config{
                        RightAngleQuote:  "&raquo;",
                        Apostrophe:       "&rsquo;",
                },
-               Footnote:        true,
+               Footnote: Footnote{
+                       Enable:             true,
+                       EnableAutoIDPrefix: false,
+               },
                DefinitionList:  true,
                Table:           true,
                Strikethrough:   true,
@@ -152,7 +155,7 @@ type LinkRenderHook struct {
 
 type Extensions struct {
        Typographer    Typographer
-       Footnote       bool
+       Footnote       Footnote
        DefinitionList bool
        Extras         Extras
        Passthrough    Passthrough
@@ -166,6 +169,12 @@ type Extensions struct {
        CJK             CJK
 }
 
+// Footnote holds footnote configuration.
+type Footnote struct {
+       Enable             bool
+       EnableAutoIDPrefix bool
+}
+
 // Typographer holds typographer configuration.
 type Typographer struct {
        // Whether to disable typographer.
index a656009515eb36ae2fa526a82ab2722bd0217638..9a861682997f89858e7886111e2a2446ea7071dd 100644 (file)
@@ -897,7 +897,7 @@ title: "p1"
 ---
 # HTML comments
 
-## Simple 
+## Simple
 <!-- This is a comment -->
 
     <!-- This is a comment indented -->
@@ -918,7 +918,7 @@ title: "p1"
 <img border="0" src="pic_trulli.jpg" alt="Trulli">
 -->
 
-## XSS 
+## XSS
 
 <!-- --><script>alert("I just escaped the HTML comment")</script><!-- -->
 
@@ -931,10 +931,10 @@ This is a <!-- hidden--> word.
 
 This is a <!-- hidden --> word.
 
-This is a <!-- 
+This is a <!--
 hidden --> word.
 
-This is a <!-- 
+This is a <!--
 hidden
 --> word.
 
@@ -961,3 +961,72 @@ hidden
        )
        b.AssertLogContains("! WARN")
 }
+
+func TestFootnoteExtension(t *testing.T) {
+       t.Parallel()
+
+       files := `
+-- hugo.toml --
+disableKinds = ['home','rss','section','sitemap','taxonomy','term']
+[markup.goldmark.extensions.footnote]
+enable = false
+enableAutoIDPrefix = false
+-- layouts/all.html --
+{{ .Content }}
+-- content/p1.md --
+---
+title: p1
+---
+foo[^1] and bar[^2]
+
+[^1]: footnote one
+[^2]: footnote two
+-- content/p2.md --
+---
+title: p2
+---
+foo[^1] and bar[^2]
+
+[^1]: footnote one
+[^2]: footnote two
+-- content/_content.gotmpl --
+{{ range slice 3 4 }}
+  {{ $page := dict
+    "content" (dict "mediaType" "text/markdown" "value" "foo[^1] and bar[^2]\n\n[^1]: footnote one\n[^2]: footnote two")
+    "path" (printf "p%d" .)
+    "title" (printf "p%d" .)
+  }}
+  {{ $.AddPage $page }}
+{{ end }}
+`
+
+       want := "<p>foo[^1] and bar[^2]</p>\n<p>[^1]: footnote one\n[^2]: footnote two</p>"
+       b := hugolib.Test(t, files)
+       b.AssertFileContent("public/p1/index.html", want)
+       b.AssertFileContent("public/p2/index.html", want)
+       b.AssertFileContent("public/p3/index.html", want)
+       b.AssertFileContent("public/p4/index.html", want)
+
+       files = strings.ReplaceAll(files, "enable = false", "enable = true")
+       want = "<p>foo<sup id=\"fnref:1\"><a href=\"#fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup> and bar<sup id=\"fnref:2\"><a href=\"#fn:2\" class=\"footnote-ref\" role=\"doc-noteref\">2</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"fn:1\">\n<p>footnote one&#160;<a href=\"#fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n<li id=\"fn:2\">\n<p>footnote two&#160;<a href=\"#fnref:2\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n</ol>\n</div>"
+       b = hugolib.Test(t, files)
+       b.AssertFileContent("public/p1/index.html", want)
+       b.AssertFileContent("public/p2/index.html", want)
+       b.AssertFileContent("public/p3/index.html", want)
+       b.AssertFileContent("public/p4/index.html", want)
+
+       files = strings.ReplaceAll(files, "enableAutoIDPrefix = false", "enableAutoIDPrefix = true")
+       b = hugolib.Test(t, files)
+       b.AssertFileContent("public/p1/index.html",
+               "<p>foo<sup id=\"hb5cdcabc9e678612fnref:1\"><a href=\"#hb5cdcabc9e678612fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup> and bar<sup id=\"hb5cdcabc9e678612fnref:2\"><a href=\"#hb5cdcabc9e678612fn:2\" class=\"footnote-ref\" role=\"doc-noteref\">2</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"hb5cdcabc9e678612fn:1\">\n<p>footnote one&#160;<a href=\"#hb5cdcabc9e678612fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n<li id=\"hb5cdcabc9e678612fn:2\">\n<p>footnote two&#160;<a href=\"#hb5cdcabc9e678612fnref:2\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n</ol>\n</div>",
+       )
+       b.AssertFileContent("public/p2/index.html",
+               "<p>foo<sup id=\"h58e8265a0c07b195fnref:1\"><a href=\"#h58e8265a0c07b195fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup> and bar<sup id=\"h58e8265a0c07b195fnref:2\"><a href=\"#h58e8265a0c07b195fn:2\" class=\"footnote-ref\" role=\"doc-noteref\">2</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"h58e8265a0c07b195fn:1\">\n<p>footnote one&#160;<a href=\"#h58e8265a0c07b195fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n<li id=\"h58e8265a0c07b195fn:2\">\n<p>footnote two&#160;<a href=\"#h58e8265a0c07b195fnref:2\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n</ol>\n</div>",
+       )
+       b.AssertFileContent("public/p3/index.html",
+               "<p>foo<sup id=\"h0aab769290d7e233fnref:1\"><a href=\"#h0aab769290d7e233fn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup> and bar<sup id=\"h0aab769290d7e233fnref:2\"><a href=\"#h0aab769290d7e233fn:2\" class=\"footnote-ref\" role=\"doc-noteref\">2</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"h0aab769290d7e233fn:1\">\n<p>footnote one&#160;<a href=\"#h0aab769290d7e233fnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n<li id=\"h0aab769290d7e233fn:2\">\n<p>footnote two&#160;<a href=\"#h0aab769290d7e233fnref:2\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n</ol>\n</div>",
+       )
+       b.AssertFileContent("public/p4/index.html",
+               "<p>foo<sup id=\"ha35b794ad6e8626cfnref:1\"><a href=\"#ha35b794ad6e8626cfn:1\" class=\"footnote-ref\" role=\"doc-noteref\">1</a></sup> and bar<sup id=\"ha35b794ad6e8626cfnref:2\"><a href=\"#ha35b794ad6e8626cfn:2\" class=\"footnote-ref\" role=\"doc-noteref\">2</a></sup></p>\n<div class=\"footnotes\" role=\"doc-endnotes\">\n<hr>\n<ol>\n<li id=\"ha35b794ad6e8626cfn:1\">\n<p>footnote one&#160;<a href=\"#ha35b794ad6e8626cfnref:1\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n<li id=\"ha35b794ad6e8626cfn:2\">\n<p>footnote two&#160;<a href=\"#ha35b794ad6e8626cfnref:2\" class=\"footnote-backref\" role=\"doc-backlink\">&#x21a9;&#xfe0e;</a></p>\n</li>\n</ol>\n</div>",
+       )
+}
index e944caae6e435fe7a7efcc8d74edb9e5ca4281a7..50173cd6d539fa45df200f92e49f879fee014a6e 100644 (file)
@@ -86,20 +86,26 @@ func normalizeConfig(m map[string]any) {
                }
        }
 
-       // Changed from a bool in 0.112.0.
+       // Handle changes to the Goldmark configuration.
        v, err = maps.GetNestedParam("goldmark.extensions", ".", m)
        if err == nil {
                vm := maps.ToStringMap(v)
-               const typographerKey = "typographer"
-               if vv, found := vm[typographerKey]; found {
-                       if vvb, ok := vv.(bool); ok {
-                               if !vvb {
-                                       vm[typographerKey] = goldmark_config.Typographer{
-                                               Disable: true,
-                                       }
-                               } else {
-                                       delete(vm, typographerKey)
-                               }
+
+               // We changed the typographer extension config from a bool to a struct in 0.112.0.
+               migrateGoldmarkConfig(vm, "typographer", goldmark_config.Typographer{Disable: true})
+
+               // We changed the footnote extension config from a bool to a struct in 0.151.0.
+               migrateGoldmarkConfig(vm, "footnote", goldmark_config.Footnote{Enable: false})
+       }
+}
+
+func migrateGoldmarkConfig(vm map[string]any, key string, falseVal any) {
+       if vv, found := vm[key]; found {
+               if vvb, ok := vv.(bool); ok {
+                       if !vvb {
+                               vm[key] = falseVal
+                       } else {
+                               delete(vm, key)
                        }
                }
        }
index 5169bd79fc871e0da316d17f1e0b2288e06da27b..51ed8a49ec4f8910609afeab844c41be3c7ffc2a 100644 (file)
@@ -52,14 +52,16 @@ func TestConfig(t *testing.T) {
                c.Assert(conf.AsciidocExt.Extensions[0], qt.Equals, "asciidoctor-html5s")
        })
 
-       c.Run("Decode legacy typographer", func(c *qt.C) {
+       // We changed the typographer extension config from a bool to a struct in 0.112.0.
+       // We changed the footnote extension config from a bool to a struct in 0.151.0.
+       c.Run("Decode legacy Goldmark configs", func(c *qt.C) {
                c.Parallel()
                v := config.New()
 
-               // typographer was changed from a bool to a struct in 0.112.0.
                v.Set("markup", map[string]any{
                        "goldmark": map[string]any{
                                "extensions": map[string]any{
+                                       "footnote":    false,
                                        "typographer": false,
                                },
                        },
@@ -68,11 +70,13 @@ func TestConfig(t *testing.T) {
                conf, err := Decode(v)
 
                c.Assert(err, qt.IsNil)
+               c.Assert(conf.Goldmark.Extensions.Footnote.Enable, qt.Equals, false)
                c.Assert(conf.Goldmark.Extensions.Typographer.Disable, qt.Equals, true)
 
                v.Set("markup", map[string]any{
                        "goldmark": map[string]any{
                                "extensions": map[string]any{
+                                       "footnote":    true,
                                        "typographer": true,
                                },
                        },
@@ -81,6 +85,7 @@ func TestConfig(t *testing.T) {
                conf, err = Decode(v)
 
                c.Assert(err, qt.IsNil)
+               c.Assert(conf.Goldmark.Extensions.Footnote.Enable, qt.Equals, true)
                c.Assert(conf.Goldmark.Extensions.Typographer.Disable, qt.Equals, false)
                c.Assert(conf.Goldmark.Extensions.Typographer.Ellipsis, qt.Equals, "&hellip;")
        })