Repository: gohugoio/hugo (language: go)

Issue:
Create default link and image render hooks
With the implementation of https://github.com/gohugoio/hugo/issues/11453 and https://github.com/gohugoio/hugo/issues/11904 we no longer duplicate page resources per language[^1]. This is applicable to multilingual, single-host sites. 

When resources are not duplicated per language (the default), link and image render hooks are required to properly resolve markdown destinations (links and image).

[^1]: This can be disabled by setting `markup.goldmark.duplicateResourceFiles = true` in site configuration.

Per [this discussion](https://github.com/gohugoio/hugo/issues/11904#issuecomment-1902270945), the site config would be:

```text
[markup.goldmark.renderhooks.image]
enable = true
multilingualOnly = true

[markup.goldmark.renderhooks.link]
enable = true
multilingualOnly = true
```

Candidate render hooks:

<details>
<summary>layouts/_default/_markup/render-image.html</summary>

```text
{{- $u := urls.Parse .Destination -}}
{{- $src := $u.String -}}
{{- if not $u.IsAbs -}}
  {{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}}
    {{- $src = .RelPermalink -}}
  {{- end -}}
{{- end -}}
{{- $attributes := dict "alt" .Text "src" $src "title" .Title -}}
<img
  {{- range $k, $v := $attributes -}}
    {{- if $v -}}
      {{- printf " %s=%q" $k $v | safeHTMLAttr -}}
    {{- end -}}
  {{- end -}}
>
{{- /**/ -}}
```
</details>

<details>
<summary>layouts/_default/_markup/render-link.html</summary>

```text
{{- $u := urls.Parse .Destination -}}
{{- $href := $u.String -}}
{{- if not $u.IsAbs -}}
  {{- with or
    ($.Page.GetPage $u.Path)
    ($.Page.GetPage (strings.TrimRight "/" $u.Path))
    ($.Page.Resources.Get $u.Path)
    (resources.Get $u.Path)
  -}}
    {{- $href = .RelPermalink -}}
    {{- with $u.RawQuery -}}
      {{- $href = printf "%s?%s" $href . -}}
    {{- end -}}
    {{- with $u.Fragment -}}
      {{- $href = printf "%s#%s" $href . -}}
    {{- end -}}
  {{- end -}}
{{- end -}}
{{- $attributes := dict "href" $href "title" .Title -}}
<a
  {{- range $k, $v := $attributes -}}
    {{- if $v -}}
      {{- printf " %s=%q" $k $v | safeHTMLAttr -}}
    {{- end -}}
  {{- end -}}
>{{ .Text | safeHTML }}</a>
{{- /**/ -}}
```
</details>

Test site:

```text
git clone --single-branch -b hugo-github-issue-11933 https://github.com/jmooring/hugo-testing hugo-github-issue-11933
cd hugo-github-issue-11933
hugo server
```


Discussion / hints:
Just to be clear, when I do

```toml
[markup.goldmark.renderhooks.link]
enable = false
```

That toggle is just for the "default templates" not the "user provided" ... or both?


> That toggle is just for...

Yeah, I get it. There's a couple of cases I didn't consider. Let me think about this for a bit.




Here's my thought process on this...

1\) With a multilingual single-host site where `markup.goldmark.duplicateResourceFiles = false`, do you **always need** link and image render hooks? Yes. So let's hardcode that bit, always enabling the default link and image render hooks under the following conditions:

- Site is multilingual, and
- Site is single-host, and
- `markup.goldmark.duplicateResourceFiles = false`

Note the default hooks will always be overridden by user-provided hooks if present (in the same way that any of the built-in templates are overridden, including the built-in GoAT hook).

2\) Is there ever a time, with any type of site, that you would want the default render hooks to override user-provided hooks? No.

3\) Do we need or want a flag to disable both default and user-provided hooks? No. We don't have a flag like that for heading or code block hooks.

So, what about?

```text
[markup.goldmark.renderhooks.image]
enableDefault = false   <-- hardcoded to true per criteria above

[markup.goldmark.renderhooks.link]
enableDefault = false   <-- hardcoded to true per criteria above
```

We wouldn't need the `multilingualOnly` because it would be hardcoded (see item 1).

Since detecting a `false` that's explicitly set is harder than the other way around, I suggest we do:

```toml
[markup.goldmark.renderhooks.image]
disableDefault = true   <-- hardcoded to false per criteria above.
```
Sounds good to me.

Fix patch:
diff --git a/check_gofmt.sh b/check_gofmt.sh
new file mode 100755
index 00000000000..c77517d3ff3
--- /dev/null
+++ b/check_gofmt.sh
@@ -0,0 +1,2 @@
+#!/usr/bin/env bash
+diff <(gofmt -d .) <(printf '')
\ No newline at end of file
diff --git a/common/paths/path.go b/common/paths/path.go
index da99b16ac3c..906270cae38 100644
--- a/common/paths/path.go
+++ b/common/paths/path.go
@@ -387,6 +387,11 @@ func ToSlashTrimLeading(s string) string {
 	return strings.TrimPrefix(filepath.ToSlash(s), "/")
 }
 
+// ToSlashTrimTrailing is just a filepath.ToSlash with an added / suffix trimmer.
+func ToSlashTrimTrailing(s string) string {
+	return strings.TrimSuffix(filepath.ToSlash(s), "/")
+}
+
 // ToSlashPreserveLeading converts the path given to a forward slash separated path
 // and preserves the leading slash if present trimming any trailing slash.
 func ToSlashPreserveLeading(s string) string {
diff --git a/common/types/types.go b/common/types/types.go
index 11683c196bc..801c511a0fd 100644
--- a/common/types/types.go
+++ b/common/types/types.go
@@ -107,3 +107,8 @@ type LowHigh struct {
 
 // This is only used for debugging purposes.
 var InvocationCounter atomic.Int64
+
+// NewTrue returns a pointer to b.
+func NewBool(b bool) *bool {
+	return &b
+}
diff --git a/config/allconfig/allconfig.go b/config/allconfig/allconfig.go
index 5788e792bdb..c12551f565a 100644
--- a/config/allconfig/allconfig.go
+++ b/config/allconfig/allconfig.go
@@ -31,6 +31,7 @@ import (
 	"github.com/gohugoio/hugo/common/loggers"
 	"github.com/gohugoio/hugo/common/maps"
 	"github.com/gohugoio/hugo/common/paths"
+	"github.com/gohugoio/hugo/common/types"
 	"github.com/gohugoio/hugo/common/urls"
 	"github.com/gohugoio/hugo/config"
 	"github.com/gohugoio/hugo/config/privacy"
@@ -899,6 +900,18 @@ func fromLoadConfigResult(fs afero.Fs, logger loggers.Logger, res config.LoadCon
 				return nil, err
 			}
 
+			// Adjust Goldmark config defaults for multilingual, single-host sites.
+			if len(languagesConfig) > 1 && !isMultiHost && !clone.Markup.Goldmark.DuplicateResourceFiles {
+				if !clone.Markup.Goldmark.DuplicateResourceFiles {
+					if clone.Markup.Goldmark.RenderHooks.Link.EnableDefault == nil {
+						clone.Markup.Goldmark.RenderHooks.Link.EnableDefault = types.NewBool(true)
+					}
+					if clone.Markup.Goldmark.RenderHooks.Image.EnableDefault == nil {
+						clone.Markup.Goldmark.RenderHooks.Image.EnableDefault = types.NewBool(true)
+					}
+				}
+			}
+
 			langConfigMap[k] = clone
 		case maps.ParamsMergeStrategy:
 		default:
diff --git a/hugolib/page__per_output.go b/hugolib/page__per_output.go
index 3d86cdece90..a387b294e17 100644
--- a/hugolib/page__per_output.go
+++ b/hugolib/page__per_output.go
@@ -469,6 +469,21 @@ func (pco *pageContentOutput) initRenderHooks() error {
 				if err != nil {
 					panic(err)
 				}
+				if found {
+					if isitp, ok := templ.(tpl.IsInternalTemplateProvider); ok && isitp.IsInternalTemplate() {
+						renderHookConfig := pco.po.p.s.conf.Markup.Goldmark.RenderHooks
+						switch templ.Name() {
+						case "_default/_markup/render-link.html":
+							if !renderHookConfig.Link.IsEnableDefault() {
+								return nil, false
+							}
+						case "_default/_markup/render-image.html":
+							if !renderHookConfig.Image.IsEnableDefault() {
+								return nil, false
+							}
+						}
+					}
+				}
 				return templ, found
 			}
 
diff --git a/hugolib/pagecollections.go b/hugolib/pagecollections.go
index 8e05ad7e60d..505b10bd772 100644
--- a/hugolib/pagecollections.go
+++ b/hugolib/pagecollections.go
@@ -56,7 +56,7 @@ func (c *pageFinder) getPageRef(context page.Page, ref string) (page.Page, error
 }
 
 func (c *pageFinder) getPage(context page.Page, ref string) (page.Page, error) {
-	n, err := c.getContentNode(context, false, filepath.ToSlash(ref))
+	n, err := c.getContentNode(context, false, paths.ToSlashTrimTrailing(ref))
 	if err != nil {
 		return nil, err
 	}
diff --git a/magefile.go b/magefile.go
index c8542dad7c4..d4c7b65de43 100644
--- a/magefile.go
+++ b/magefile.go
@@ -185,42 +185,15 @@ func TestRace() error {
 
 // Run gofmt linter
 func Fmt() error {
-	if !isGoLatest() {
+	if !isGoLatest() && !isUnix() {
 		return nil
 	}
-	pkgs, err := hugoPackages()
+	s, err := sh.Output("./check_gofmt.sh")
 	if err != nil {
-		return err
-	}
-	failed := false
-	first := true
-	for _, pkg := range pkgs {
-		files, err := filepath.Glob(filepath.Join(pkg, "*.go"))
-		if err != nil {
-			return nil
-		}
-		for _, f := range files {
-			// gofmt doesn't exit with non-zero when it finds unformatted code
-			// so we have to explicitly look for output, and if we find any, we
-			// should fail this target.
-			s, err := sh.Output("gofmt", "-l", f)
-			if err != nil {
-				fmt.Printf("ERROR: running gofmt on %q: %v\n", f, err)
-				failed = true
-			}
-			if s != "" {
-				if first {
-					fmt.Println("The following files are not gofmt'ed:")
-					first = false
-				}
-				failed = true
-				fmt.Println(s)
-			}
-		}
-	}
-	if failed {
-		return errors.New("improperly formatted go files")
+		fmt.Println(s)
+		return fmt.Errorf("gofmt needs to be run: %s", err)
 	}
+
 	return nil
 }
 
@@ -332,6 +305,10 @@ func isGoLatest() bool {
 	return strings.Contains(runtime.Version(), "1.21")
 }
 
+func isUnix() bool {
+	return runtime.GOOS != "windows"
+}
+
 func isCI() bool {
 	return os.Getenv("CI") != ""
 }
diff --git a/markup/goldmark/goldmark_config/config.go b/markup/goldmark/goldmark_config/config.go
index 1c393e3f44e..9a14bf9b884 100644
--- a/markup/goldmark/goldmark_config/config.go
+++ b/markup/goldmark/goldmark_config/config.go
@@ -73,10 +73,39 @@ var Default = Config{
 
 // Config configures Goldmark.
 type Config struct {
-	DuplicateResourceFiles bool
 	Renderer               Renderer
 	Parser                 Parser
 	Extensions             Extensions
+	DuplicateResourceFiles bool
+	RenderHooks            RenderHooks
+}
+
+// RenderHooks contains configuration for Goldmark render hooks.
+type RenderHooks struct {
+	Image ImageRenderHook
+	Link  LinkRenderHook
+}
+
+// ImageRenderHook contains configuration for the image render hook.
+type ImageRenderHook struct {
+	// Enable the default image render hook.
+	// We need to know if it is set or not, hence the pointer.
+	EnableDefault *bool
+}
+
+func (h ImageRenderHook) IsEnableDefault() bool {
+	return h.EnableDefault != nil && *h.EnableDefault
+}
+
+// LinkRenderHook contains configuration for the link render hook.
+type LinkRenderHook struct {
+	// Disable the default image render hook.
+	// We need to know if it is set or not, hence the pointer.
+	EnableDefault *bool
+}
+
+func (h LinkRenderHook) IsEnableDefault() bool {
+	return h.EnableDefault != nil && *h.EnableDefault
 }
 
 type Extensions struct {
diff --git a/tpl/template_info.go b/tpl/template_info.go
index b27debf1fcf..fd126d80f64 100644
--- a/tpl/template_info.go
+++ b/tpl/template_info.go
@@ -25,6 +25,10 @@ type FileInfo interface {
 	Filename() string
 }
 
+type IsInternalTemplateProvider interface {
+	IsInternalTemplate() bool
+}
+
 type ParseInfo struct {
 	// Set for shortcode templates with any {{ .Inner }}
 	IsInner bool
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html b/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
new file mode 100644
index 00000000000..c90ababd224
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-image.html
@@ -0,0 +1,15 @@
+{{- $u := urls.Parse .Destination -}}
+{{- $src := $u.String -}}
+{{- if not $u.IsAbs -}}
+  {{- with or (.Page.Resources.Get $u.Path) (resources.Get $u.Path) -}}
+    {{- $src = .RelPermalink -}}
+  {{- end -}}
+{{- end -}}
+{{- $attributes := dict "alt" .Text "src" $src "title" .Title -}}
+<img
+  {{- range $k, $v := $attributes -}}
+    {{- if $v -}}
+      {{- printf " %s=%q" $k $v | safeHTMLAttr -}}
+    {{- end -}}
+  {{- end -}}>
+{{- /**/ -}}
diff --git a/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html b/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
new file mode 100644
index 00000000000..bd64b204b35
--- /dev/null
+++ b/tpl/tplimpl/embedded/templates/_default/_markup/render-link.html
@@ -0,0 +1,26 @@
+{{- $u := urls.Parse .Destination -}}
+{{- $href := $u.String -}}
+{{- if not $u.IsAbs -}}
+  {{- with or
+    ($.Page.GetPage $u.Path)
+    ($.Page.Resources.Get $u.Path)
+    (resources.Get $u.Path)
+  -}}
+    {{- $href = .RelPermalink -}}
+    {{- with $u.RawQuery -}}
+      {{- $href = printf "%s?%s" $href . -}}
+    {{- end -}}
+    {{- with $u.Fragment -}}
+      {{- $href = printf "%s#%s" $href . -}}
+    {{- end -}}
+  {{- end -}}
+{{- end -}}
+{{- $attributes := dict "href" $href "title" .Title -}}
+<a
+  {{- range $k, $v := $attributes -}}
+    {{- if $v -}}
+      {{- printf " %s=%q" $k $v | safeHTMLAttr -}}
+    {{- end -}}
+  {{- end -}}
+  >{{ .Text | safeHTML }}</a>
+{{- /**/ -}}
diff --git a/tpl/tplimpl/template.go b/tpl/tplimpl/template.go
index a8ba6815d55..63dc29662e4 100644
--- a/tpl/tplimpl/template.go
+++ b/tpl/tplimpl/template.go
@@ -55,6 +55,7 @@ const (
 
 	shortcodesPathPrefix = "shortcodes/"
 	internalPathPrefix   = "_internal/"
+	embeddedPathPrefix   = "_embedded/"
 	baseFileBase         = "baseof"
 )
 
@@ -517,11 +518,19 @@ func (t *templateHandler) findLayout(d layouts.LayoutDescriptor, f output.Format
 
 func (t *templateHandler) newTemplateInfo(name, tpl string) templateInfo {
 	var isText bool
+	var isEmbedded bool
+
+	if strings.HasPrefix(name, embeddedPathPrefix) {
+		isEmbedded = true
+		name = strings.TrimPrefix(name, embeddedPathPrefix)
+	}
+
 	name, isText = t.nameIsText(name)
 	return templateInfo{
-		name:     name,
-		isText:   isText,
-		template: tpl,
+		name:       name,
+		isText:     isText,
+		isEmbedded: isEmbedded,
+		template:   tpl,
 	}
 }
 
@@ -772,7 +781,7 @@ func (t *templateHandler) loadEmbedded() error {
 		}
 
 		if _, found := t.Lookup(templateName); !found {
-			if err := t.AddTemplate(templateName, templ); err != nil {
+			if err := t.AddTemplate(embeddedPathPrefix+templateName, templ); err != nil {
 				return err
 			}
 		}
@@ -781,7 +790,7 @@ func (t *templateHandler) loadEmbedded() error {
 			// TODO(bep) avoid reparsing these aliases
 			for _, alias := range aliases {
 				alias = internalPathPrefix + alias
-				if err := t.AddTemplate(alias, templ); err != nil {
+				if err := t.AddTemplate(embeddedPathPrefix+alias, templ); err != nil {
 					return err
 				}
 			}
@@ -1026,6 +1035,8 @@ func (t *templateNamespace) parse(info templateInfo) (*templateState, error) {
 	return ts, nil
 }
 
+var _ tpl.IsInternalTemplateProvider = (*templateState)(nil)
+
 type templateState struct {
 	tpl.Template
 
@@ -1037,6 +1048,10 @@ type templateState struct {
 	baseInfo templateInfo // Set when a base template is used.
 }
 
+func (t *templateState) IsInternalTemplate() bool {
+	return t.info.isEmbedded
+}
+
 func (t *templateState) GetIdentity() identity.Identity {
 	return t.id
 }
diff --git a/tpl/tplimpl/template_errors.go b/tpl/tplimpl/template_errors.go
index 34e73a07a1f..a9d25922005 100644
--- a/tpl/tplimpl/template_errors.go
+++ b/tpl/tplimpl/template_errors.go
@@ -24,9 +24,10 @@ import (
 var _ identity.Identity = (*templateInfo)(nil)
 
 type templateInfo struct {
-	name     string
-	template string
-	isText   bool // HTML or plain text template.
+	name       string
+	template   string
+	isText     bool // HTML or plain text template.
+	isEmbedded bool
 
 	meta *hugofs.FileMeta
 }


Test patch:
diff --git a/hugolib/content_render_hooks_test.go b/hugolib/content_render_hooks_test.go
index 93b0e1621af..36d1e626f6c 100644
--- a/hugolib/content_render_hooks_test.go
+++ b/hugolib/content_render_hooks_test.go
@@ -14,6 +14,7 @@
 package hugolib
 
 import (
+	"strings"
 	"testing"
 )
 
@@ -169,3 +170,74 @@ Self Fragments: [d e f]
 P1 Fragments: [b c z]
 	`)
 }
+
+func TestDefaultRenderHooksMultilingual(t *testing.T) {
+	files := `
+-- hugo.toml --
+baseURL = "https://example.org"
+disableKinds = ["taxonomy", "term", "RSS", "sitemap", "robotsTXT"]
+defaultContentLanguage = "nn"
+defaultContentLanguageInSubdir = true
+[markup]
+[markup.goldmark]
+duplicateResourceFiles = false
+[markup.goldmark.renderhooks]
+[markup.goldmark.renderhooks.link]
+#enableDefault = false
+[markup.goldmark.renderhooks.image]
+#enableDefault = false
+[languages]
+[languages.en]
+weight = 1
+[languages.nn]
+weight = 2
+-- content/p1/index.md --
+---
+title: "p1"
+---
+[P2](p2)
+![Pixel](pixel.png)
+-- content/p2/index.md --
+---
+title: "p2"
+---
+[P1](p1)
+![Pixel](pixel.jpg)
+-- content/p1/index.en.md --
+---
+title: "p1 en"
+---
+[P2](p2)
+![Pixel](pixel.png)
+-- content/p2/index.en.md --
+---
+title: "p2 en"
+---
+[P1](p1)
+![Pixel](pixel.png)
+
+-- content/p1/pixel.nn.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- content/p2/pixel.png --
+iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==
+-- layouts/_default/single.html --
+{{ .Title }}|{{ .Content }}|$
+	
+`
+
+	t.Run("Default multilingual", func(t *testing.T) {
+		b := Test(t, files)
+
+		b.AssertFileContent("public/nn/p1/index.html",
+			"p1|<p><a href=\"/nn/p2/\">P2</a\n></p>", "<img alt=\"Pixel\" src=\"/nn/p1/pixel.nn.png\">")
+		b.AssertFileContent("public/en/p1/index.html",
+			"p1 en|<p><a href=\"/en/p2/\">P2</a\n></p>", "<img alt=\"Pixel\" src=\"/nn/p1/pixel.nn.png\">")
+	})
+
+	t.Run("Disabled", func(t *testing.T) {
+		b := Test(t, strings.ReplaceAll(files, "#enableDefault = false", "enableDefault = false"))
+
+		b.AssertFileContent("public/nn/p1/index.html",
+			"p1|<p><a href=\"p2\">P2</a>", "<img src=\"pixel.png\" alt=\"Pixel\">")
+	})
+}
diff --git a/hugolib/pagecollections_test.go b/hugolib/pagecollections_test.go
index 8fd4f07395c..eaa80a10995 100644
--- a/hugolib/pagecollections_test.go
+++ b/hugolib/pagecollections_test.go
@@ -413,6 +413,10 @@ title: p2
 func TestPageGetPageVariations(t *testing.T) {
 	files := `
 -- hugo.toml --
+-- content/s1/_index.md --
+---
+title: s1 section
+---
 -- content/s1/p1/index.md --
 ---
 title: p1
@@ -430,6 +434,8 @@ title: p3
 title: p2_root
 ---
 -- layouts/index.html --
+/s1: {{ with .GetPage "/s1" }}{{ .Title }}{{ end }}|
+/s1/: {{ with .GetPage "/s1/" }}{{ .Title }}{{ end }}|
 /s1/p2.md: {{ with .GetPage "/s1/p2.md" }}{{ .Title }}{{ end }}|
 /s1/p2: {{ with .GetPage "/s1/p2" }}{{ .Title }}{{ end }}|
 /s1/p1/index.md: {{ with .GetPage "/s1/p1/index.md" }}{{ .Title }}{{ end }}|
@@ -444,6 +450,8 @@ p1/index.md: {{ with .GetPage "p1/index.md" }}{{ .Title }}{{ end }}|
 	b := Test(t, files)
 
 	b.AssertFileContent("public/index.html", `
+/s1: s1 section|
+/s1/: s1 section|
 /s1/p2.md: p2|
 /s1/p2: p2|
 /s1/p1/index.md: p1|