From: Joe Mooring Date: Sat, 13 Sep 2025 03:19:18 +0000 (-0700) Subject: markup/goldmark: Enhance footnote extension with auto-prefixing option X-Git-Url: http://git.maquefel.me/?a=commitdiff_plain;h=47678d8cbd9a4151ee183bc5601f043e59cc3459;p=brevno-suite%2Fhugo markup/goldmark: Enhance footnote extension with auto-prefixing option 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 --- diff --git a/docs/data/docs.yaml b/docs/data/docs.yaml index d4ca1449e..e359ff6c9 100644 --- a/docs/data/docs.yaml +++ b/docs/data/docs.yaml @@ -1238,7 +1238,9 @@ config: enable: false superscript: enable: false - footnote: true + footnote: + enable: true + enableAutoIDPrefix: false linkify: true linkifyProtocol: https passthrough: diff --git a/hugolib/page__meta.go b/hugolib/page__meta.go index 5a27d1913..f04e0bd56 100644 --- a/hugolib/page__meta.go +++ b/hugolib/page__meta.go @@ -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, }, diff --git a/markup/goldmark/convert.go b/markup/goldmark/convert.go index fb374e6ae..7b23c8986 100644 --- a/markup/goldmark/convert.go +++ b/markup/goldmark/convert.go @@ -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, diff --git a/markup/goldmark/goldmark_config/config.go b/markup/goldmark/goldmark_config/config.go index 168a3b8fa..fe808f5d4 100644 --- a/markup/goldmark/goldmark_config/config.go +++ b/markup/goldmark/goldmark_config/config.go @@ -40,7 +40,10 @@ var Default = Config{ RightAngleQuote: "»", Apostrophe: "’", }, - 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. diff --git a/markup/goldmark/goldmark_integration_test.go b/markup/goldmark/goldmark_integration_test.go index a65600951..9a8616829 100644 --- a/markup/goldmark/goldmark_integration_test.go +++ b/markup/goldmark/goldmark_integration_test.go @@ -897,7 +897,7 @@ title: "p1" --- # HTML comments -## Simple +## Simple @@ -918,7 +918,7 @@ title: "p1" Trulli --> -## XSS +## XSS @@ -931,10 +931,10 @@ This is a word. This is a word. -This is a word. -This is a 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 := "

foo[^1] and bar[^2]

\n

[^1]: footnote one\n[^2]: footnote two

" + 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 = "

foo1 and bar2

\n
\n
\n
    \n
  1. \n

    footnote one ↩︎

    \n
  2. \n
  3. \n

    footnote two ↩︎

    \n
  4. \n
\n
" + 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", + "

foo1 and bar2

\n
\n
\n
    \n
  1. \n

    footnote one ↩︎

    \n
  2. \n
  3. \n

    footnote two ↩︎

    \n
  4. \n
\n
", + ) + b.AssertFileContent("public/p2/index.html", + "

foo1 and bar2

\n
\n
\n
    \n
  1. \n

    footnote one ↩︎

    \n
  2. \n
  3. \n

    footnote two ↩︎

    \n
  4. \n
\n
", + ) + b.AssertFileContent("public/p3/index.html", + "

foo1 and bar2

\n
\n
\n
    \n
  1. \n

    footnote one ↩︎

    \n
  2. \n
  3. \n

    footnote two ↩︎

    \n
  4. \n
\n
", + ) + b.AssertFileContent("public/p4/index.html", + "

foo1 and bar2

\n
\n
\n
    \n
  1. \n

    footnote one ↩︎

    \n
  2. \n
  3. \n

    footnote two ↩︎

    \n
  4. \n
\n
", + ) +} diff --git a/markup/markup_config/config.go b/markup/markup_config/config.go index e944caae6..50173cd6d 100644 --- a/markup/markup_config/config.go +++ b/markup/markup_config/config.go @@ -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) } } } diff --git a/markup/markup_config/config_test.go b/markup/markup_config/config_test.go index 5169bd79f..51ed8a49e 100644 --- a/markup/markup_config/config_test.go +++ b/markup/markup_config/config_test.go @@ -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, "…") })