Compare commits

...

262 Commits

Author SHA1 Message Date
Shiwang Bisht a25af7facf metrics: Improve template metrics duration formatting
Fixes #15027
2026-08-24 11:36:58 +02:00
Bjørn Erik Pedersen 723579ff54 Remove comments from rebuild test functions
Removed commented-out explanations for test functions related to mounted assets and their behavior during rebuilds.
2026-08-24 11:32:24 +02:00
Bjørn Erik Pedersen 85ad5e48eb hugolib: Add some fast render mode integration tests 2026-08-24 11:32:24 +02:00
Bjørn Erik Pedersen 7b5199fdef all: Run modernize -fix ./... 2026-08-20 21:56:40 +02:00
Bjørn Erik Pedersen e31ff547d2 Upgrade to Go 1.27
Closes #15228
2026-08-20 20:23:18 +02:00
Joe Mooring 87260e4a60 commands: Fix lang flag description in config command
Closes #15223
2026-08-19 20:00:06 +02:00
Bjørn Erik Pedersen 8405b802cf tpl: Improve the return keyword in templates
Replace the partial return template rewriting with a sentinel error
trapped in the template executor:

* return now works in any template, not just partials.
* return can be used anywhere in the template, e.g. inside if/range;
  it stops execution of the current template, so a bare return in a
  block or template include ends just that template.
* {{ return <value> }} sets the return value of the enclosing partial;
  using it outside a partial is now an error (it was silently ignored).

The fork changes are limited to hugo_template.go plus one mechanical
rename (walkTemplate -> walkTemplateOld) mirrored in the fork script.

Closes #15212

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 16:21:11 +02:00
Shiwang0-0 bf05832d14 page: Add IndexOf method to Pages
See #13589
2026-08-19 15:47:58 +02:00
Bjørn Erik Pedersen a05736cb9f tpl/resources: Add resources.Publish
Closes #15208
2026-08-19 13:13:55 +02:00
Bjørn Erik Pedersen 49dceb19f5 hugolib: Fix slice bounds panic when deleting multiple nodes at same path
The contentNodes cases in Delete/DeleteFunc spliced the slice inside a
forward range loop, panicking when a second deletion hit the last index,
and the shrunken slice was never written back to the tree. Let the
Shifter return the updated node and re-insert it on partial deletes.

Fixes #15207

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 20:42:57 +02:00
Joe Mooring 5e7099256e hugolib: Fix ReadingTime and FuzzyWordCount calculations
Closes #15206
2026-08-15 20:41:03 +02:00
hugoreleaser 0805c734a4 releaser: Prepare repository for 0.166.0-DEV
[ci skip]
2026-08-12 14:47:36 +00:00
hugoreleaser 76a5e1880a releaser: Bump versions for release of 0.165.0
[ci skip]
2026-08-12 14:26:28 +00:00
dependabot[bot] 0bb337b22a build(deps): bump github.com/bep/imagemeta from 0.17.3 to 1.0.0
Bumps [github.com/bep/imagemeta](https://github.com/bep/imagemeta) from 0.17.3 to 1.0.0.
- [Release notes](https://github.com/bep/imagemeta/releases)
- [Commits](https://github.com/bep/imagemeta/compare/v0.17.3...v1.0.0)

---
updated-dependencies:
- dependency-name: github.com/bep/imagemeta
  dependency-version: 1.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 13:49:11 +02:00
dependabot[bot] 03dc9170b3 build(deps): bump github.com/evanw/esbuild from 0.28.1 to 0.28.2
Bumps [github.com/evanw/esbuild](https://github.com/evanw/esbuild) from 0.28.1 to 0.28.2.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.28.1...v0.28.2)

---
updated-dependencies:
- dependency-name: github.com/evanw/esbuild
  dependency-version: 0.28.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 13:48:55 +02:00
dependabot[bot] c829b736cc build(deps): bump github.com/tdewolff/minify/v2 from 2.24.14 to 2.24.16
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.14 to 2.24.16.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.14...v2.24.16)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-version: 2.24.16
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 11:38:40 +02:00
Bjørn Erik Pedersen 995a2159e5 resources: Resume chained resource transformations
This is a follow up the bug fix in #15189. With the example given in that issue, the `css.Build` would be performed twice, which was unfortunate.

This commit fixes that by resuming the transformation from the last executed transformation.

See #15189
2026-08-11 19:12:24 +02:00
Bjørn Erik Pedersen f88f0a9f59 resources/jsconfig: Drop source root mapping for the current source root
* Intellisense doesn't need it to do its work.
* This also avoids creating a jsconfig.js file in the common cases.

Fixes #15169
2026-08-11 15:57:03 +02:00
Bjørn Erik Pedersen dd3f2731e0 Remove Star History from README
Removed Star History section from README.

Closes #15190
2026-08-10 23:08:30 +02:00
Bjørn Erik Pedersen f772998fa3 Fix resource transformation chaining after content access
When a transformed resource had been initialized (e.g. via .Data, .Content
or .RelPermalink) before chaining another transformation, the new chain
would run on the transformed output instead of the original source,
re-running all transformations on their own output.

Fixes #15189

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 18:10:06 +02:00
Bjørn Erik Pedersen 8a55df7af2 Remove tailwindcss from the default security.exec.allow list (note)
Fixes #15178
Closes #15171
2026-08-10 15:59:57 +02:00
Bjørn Erik Pedersen 52c9bd7908 circleci: Upgrade to Go 1.26.5 2026-08-09 21:38:57 +02:00
Bjørn Erik Pedersen 44da086082 Add Data.Artifacts to css.Build and js.Build
Artifacts are the additional output files published as part of the build:
source maps and files emitted by ESBuild's file loader (e.g. fonts). Each
artifact provides Permalink, RelPermalink and MediaType, so e.g. font
preload links can be constructed in templates.

Also add media type definitions for source maps (application/source-map),
font/woff and font/woff2.

Fixes #15173

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-08 19:51:42 +02:00
Bjørn Erik Pedersen 2ffaf1fc1e Fix server static file detection for deleted files/directories in the static syncer
Related to #15174
2026-08-08 18:08:03 +02:00
Bjørn Erik Pedersen a808f6e40d Fix server errors when deleting static files or directories
Fixes #15174
2026-08-08 18:08:03 +02:00
dependabot[bot] 94f3908ec6 build(deps): bump github.com/getkin/kin-openapi from 0.145.0 to 0.146.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.145.0 to 0.146.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.145.0...v0.146.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.146.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 11:31:51 +02:00
dependabot[bot] 75fcc7524a build(deps): bump github.com/rogpeppe/go-internal from 1.15.0 to 1.16.0
Bumps [github.com/rogpeppe/go-internal](https://github.com/rogpeppe/go-internal) from 1.15.0 to 1.16.0.
- [Release notes](https://github.com/rogpeppe/go-internal/releases)
- [Commits](https://github.com/rogpeppe/go-internal/compare/v1.15.0...v1.16.0)

---
updated-dependencies:
- dependency-name: github.com/rogpeppe/go-internal
  dependency-version: 1.16.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-07 11:31:37 +02:00
Bjørn Erik Pedersen 33d1f2c853 css: Add classDark and classLight options to css.ChromaStyles and gen chromastyles
Closes #15167
2026-08-05 16:04:48 +02:00
Bjørn Erik Pedersen 64da6d7c62 markup/highlight: Re-emit token colors dropped by Chroma's minifier
Chroma drops token rules whose color equals the style's default foreground
(e.g. .nx in github-dark). In a paired light/dark setup the other sheet's
explicit rule then leaks in, since an explicit declaration beats inheritance
from .chroma. Apply the chromaCSSOverrides custom CSS unconditionally so
these rules survive in modeSelector sheets too.

Updates #15161
2026-08-04 16:10:27 +02:00
Bjørn Erik Pedersen 70db201ed4 Add importContext option to css.Build, js.Build, css.Sass and css.TailwindCSS
This allows @import statements to be resolved in a set of user provided resources (e.g. from resources.FromString or css.ChromaStyles) before the assets filesystem.

The option also applies to css.PostCSS via the shared import inlining, and css.Sass requires the dartsass transpiler. The import context is part of the transformation cache key.

Fixes #15103

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-03 21:20:06 +02:00
Bjørn Erik Pedersen 8a468df065 Remove some old deprecations 2026-07-29 20:48:47 +02:00
dependabot[bot] b5fa03d3aa build(deps): bump github.com/mattn/go-isatty from 0.0.22 to 0.0.24
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.22 to 0.0.24.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.22...v0.0.24)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:42:01 +02:00
dependabot[bot] 9da472dd3b build(deps): bump github.com/yuin/goldmark from 1.8.4 to 1.8.5
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.8.4 to 1.8.5.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.8.4...v1.8.5)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.8.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 18:41:48 +02:00
Bjørn Erik Pedersen 615e45d6e8 Add css.ChromaStyles
Move the stylesheet generation from the gen chromastyles command into
markup/highlight and share it with the new template function.

Fixes #15112

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-29 18:41:31 +02:00
dependabot[bot] 635532a20f build(deps): bump golang.org/x/tools from 0.47.0 to 0.48.0
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.47.0 to 0.48.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.47.0...v0.48.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.48.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-29 11:52:08 +02:00
Shantanav Mukherjee a243a6154c check.sh: Handle staticcheck not installed/in PATH 2026-07-28 12:31:02 +02:00
dependabot[bot] 9c71f600bf build(deps): bump github.com/kyokomi/emoji/v2 from 2.2.13 to 2.2.14
Bumps [github.com/kyokomi/emoji/v2](https://github.com/kyokomi/emoji) from 2.2.13 to 2.2.14.
- [Release notes](https://github.com/kyokomi/emoji/releases)
- [Commits](https://github.com/kyokomi/emoji/compare/v2.2.13...v2.2.14)

---
updated-dependencies:
- dependency-name: github.com/kyokomi/emoji/v2
  dependency-version: 2.2.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 12:29:19 +02:00
dependabot[bot] 420527fc6b build(deps): bump github.com/getkin/kin-openapi from 0.144.0 to 0.145.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.144.0 to 0.145.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.144.0...v0.145.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.145.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 12:28:40 +02:00
dependabot[bot] 7fe786e3b0 build(deps): bump golang.org/x/image from 0.43.0 to 0.44.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.43.0 to 0.44.0.
- [Commits](https://github.com/golang/image/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:44:56 +02:00
dependabot[bot] 03b244fc0b build(deps): bump github.com/bep/imagemeta from 0.17.2 to 0.17.3
Bumps [github.com/bep/imagemeta](https://github.com/bep/imagemeta) from 0.17.2 to 0.17.3.
- [Release notes](https://github.com/bep/imagemeta/releases)
- [Commits](https://github.com/bep/imagemeta/compare/v0.17.2...v0.17.3)

---
updated-dependencies:
- dependency-name: github.com/bep/imagemeta
  dependency-version: 0.17.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:44:44 +02:00
dependabot[bot] 961181334f build(deps): bump github.com/tdewolff/minify/v2 from 2.24.13 to 2.24.14
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.13 to 2.24.14.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.13...v2.24.14)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-version: 2.24.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:44:32 +02:00
dependabot[bot] e35b7f049d build(deps): bump github.com/yuin/goldmark from 1.8.2 to 1.8.4
Bumps [github.com/yuin/goldmark](https://github.com/yuin/goldmark) from 1.8.2 to 1.8.4.
- [Release notes](https://github.com/yuin/goldmark/releases)
- [Commits](https://github.com/yuin/goldmark/compare/v1.8.2...v1.8.4)

---
updated-dependencies:
- dependency-name: github.com/yuin/goldmark
  dependency-version: 1.8.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-27 17:44:14 +02:00
Bjørn Erik Pedersen 7d90277a28 warpc: Improve AVIF error message on memory allocation failure
See https://discourse.gohugo.io/t/avif-conversion-fails-for-jpg-files-with-embedded-colour-profiles-when-not-resizing/57392/4
2026-07-27 17:44:01 +02:00
dependabot[bot] 0796fa7ace build(deps): bump golang.org/x/net from 0.56.0 to 0.57.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.56.0 to 0.57.0.
- [Commits](https://github.com/golang/net/compare/v0.56.0...v0.57.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.57.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 17:03:18 +02:00
Bjørn Erik Pedersen 6bf15241a1 Fix panic on server atomic save edits on MacOS
An atomic save (write temp file, rename into place) unlinks the inode the
watcher holds, so kqueue reports Remove for a file that's still on disk.
That took the delete branch and wiped the entire taxonomy subtree; the
following assemble then panicked in createMissingTaxonomies, where the
shifting tree.Get hit a not yet assembled *pageMetaSource.

Treat Remove of a path that still exists as an update, and use the
non-shifting GetRaw when checking for the auto created taxonomy node.

Fixes #15130

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-26 14:00:32 +02:00
Bjørn Erik Pedersen 861ede6d10 cache/filecache: Don't prune used cache entries with mixed-case dir names
On case-insensitive filesystems, entries created before we started lowercasing
the content paths in v0.123 are the same file as today's lowercased cache key,
and were removed on every hugo --gc.

Fixes #15101

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-07-26 11:40:27 +02:00
Bjørn Erik Pedersen f228c87d41 Drop symlinks in parent directories
Lstat only refrains from following the last element of a path, so a symlink
in an intermediate directory was still resolved, and reads could escape the
mount via e.g. resources.Get "symlinkdir/secret.txt".

Walk the directories up to the mount root and reject any that is a symlink.

Follow-up to cf9c8f93c and f8b5fa09a.
2026-07-25 18:17:06 +02:00
dependabot[bot] 1b701b72ca build(deps): bump golang.org/x/text from 0.38.0 to 0.40.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.38.0 to 0.40.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.38.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 18:14:11 +02:00
dependabot[bot] a32d70b712 build(deps): bump github.com/getkin/kin-openapi from 0.140.0 to 0.144.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.140.0 to 0.144.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.140.0...v0.144.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.144.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 18:13:45 +02:00
dependabot[bot] 948cfb98f9 build(deps): bump google.golang.org/grpc from 1.80.0 to 1.82.1
Bumps [google.golang.org/grpc](https://github.com/grpc/grpc-go) from 1.80.0 to 1.82.1.
- [Release notes](https://github.com/grpc/grpc-go/releases)
- [Commits](https://github.com/grpc/grpc-go/compare/v1.80.0...v1.82.1)

---
updated-dependencies:
- dependency-name: google.golang.org/grpc
  dependency-version: 1.82.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 18:12:39 +02:00
dependabot[bot] 8930802ef2 build(deps): bump golang.org/x/mod from 0.37.0 to 0.38.0
Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.37.0 to 0.38.0.
- [Commits](https://github.com/golang/mod/compare/v0.37.0...v0.38.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.38.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-25 16:45:18 +02:00
Bjørn Erik Pedersen 7df45f615a Delete .gemini 2026-07-25 11:30:55 +02:00
Joe Mooring f961093ea9 markup/asciidocext: Fix TOC parsing for asciidoctor-html5s
Closes #15121

Co-authored-by: mike.szewil <szewil_michael@bah.com>
2026-07-22 19:32:19 +02:00
Joe Mooring 89b8c32200 common/hugo: Include non-go dependencies in go env output
Closes #15116

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-13 10:58:38 +02:00
Joe Mooring 984358f047 snap: Fix snap home environment
Closes #15114
2026-07-13 10:57:03 +02:00
Joe Mooring d1f191c58d Update README.md
- Remove Go Report Card badge (services has been sunset)
- Updated dependencies section
2026-07-13 10:56:09 +02:00
hugoreleaser a198116669 releaser: Prepare repository for 0.165.0-DEV
[ci skip]
2026-07-06 17:47:51 +00:00
hugoreleaser ce2470e701 releaser: Bump versions for release of 0.164.0
[ci skip]
2026-07-06 16:39:30 +00:00
dependabot[bot] 921db7b52c build(deps): bump github.com/JohannesKaufmann/html-to-markdown/v2
Bumps [github.com/JohannesKaufmann/html-to-markdown/v2](https://github.com/JohannesKaufmann/html-to-markdown) from 2.5.1 to 2.5.2.
- [Release notes](https://github.com/JohannesKaufmann/html-to-markdown/releases)
- [Commits](https://github.com/JohannesKaufmann/html-to-markdown/compare/v2.5.1...v2.5.2)

---
updated-dependencies:
- dependency-name: github.com/JohannesKaufmann/html-to-markdown/v2
  dependency-version: 2.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 15:21:13 +02:00
dependabot[bot] 786ce71e59 build(deps): bump golang.org/x/tools from 0.45.0 to 0.47.0
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.45.0 to 0.47.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.45.0...v0.47.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.47.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 13:34:38 +02:00
dependabot[bot] 5ad2846161 build(deps): bump golang.org/x/image from 0.42.0 to 0.43.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.42.0 to 0.43.0.
- [Commits](https://github.com/golang/image/compare/v0.42.0...v0.43.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.43.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 13:33:50 +02:00
dependabot[bot] 36ad9f583a build(deps): bump golang.org/x/net from 0.55.0 to 0.56.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.55.0 to 0.56.0.
- [Commits](https://github.com/golang/net/compare/v0.55.0...v0.56.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.56.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 11:50:17 +02:00
dependabot[bot] 7c0a0bc979 build(deps): bump github.com/pelletier/go-toml/v2 from 2.4.2 to 2.4.3
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.4.2 to 2.4.3.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.4.2...v2.4.3)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-version: 2.4.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-06 11:49:16 +02:00
Bjørn Erik Pedersen d83ce27ae0 tpl/tplimpl: Support sub paths in layouts passed to .Render
E.g. {{ .Render "foo/mylayout" }} matches mylayout templates in
<dir>/foo for every dir from the page's layout path up to the layouts root.

Closes #15056

Co-authored-by: Joe Mooring <joe.mooring@veriphor.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-05 20:14:32 +02:00
Bjørn Erik Pedersen 5a5f4a5495 all: Rewrite deprecated constructs in tests 2026-07-04 23:10:26 +02:00
Bjørn Erik Pedersen c6acc246ab Add markup.rst.syntaxHighlight option
Fixes #5349
2026-07-04 22:27:29 +02:00
Bjørn Erik Pedersen 29ed932513 tpl/resources: Deprecate resources.PostProcess in favour of templates.Defer
Fixes #15086
2026-07-04 21:28:31 +02:00
Bjørn Erik Pedersen 7b4ddd1863 contributing: Add a note about co-authoring attributions when borrowing test cases 2026-07-04 18:10:03 +02:00
bejaratommy 671897ae91 tpl/collections: Include key in IsSet unsupported-type warning
The warning logged when calling IsSet with an unsupported type did not
identify which key triggered it, making it hard to locate the offending
template. Include the key in the message.

Fixes #11794
2026-07-04 15:18:42 +02:00
dependabot[bot] a879ebfaaa build(deps): bump github.com/getkin/kin-openapi from 0.139.0 to 0.140.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.139.0 to 0.140.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.139.0...v0.140.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.140.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 14:43:32 +02:00
sjh9714 499794d19e create: Keep new content placeholders buildable
Fixes #15078
2026-07-04 14:31:54 +02:00
hexbinoct feb3d494b9 hugolib: Use the output format's suffix for alias paths
Alias generation hardcoded "index.html" and only recognized a ".html"
suffix, so an alias with an explicit ".htm" extension (or any other
suffix configured for the html media type) was written to the wrong
path. For example "d.htm" produced the directory "d.htm/index.html"
instead of the file "d.htm".

Build the alias file name from the output format's BaseName and the
media type's first suffix, and detect an explicit file by matching the
alias extension against the configured suffixes. The same check now
drives the uglyURLs case in Aliases.

Fixes #15066
2026-07-04 14:31:34 +02:00
Bjørn Erik Pedersen 65c82178b7 hugio: Speedup hasBytesWriter
The old implementation copied the incoming bytes into a fixed-size buffer
one byte at a time, and for every byte ran bytes.Contains for every
pattern over the whole buffer. That's O(n·patterns) scans of the buffer,
which showed up badly on large output (a full rendered page).

Scan each not-yet-matched pattern once per Write with bytes.Contains over
the chunk itself, in place, rather than per byte. The only carried state
is a small boundary window (the last maxPatternLen-1 bytes) joined with
the head of the next chunk, so a pattern straddling a Write boundary is
still detected. The chunk is never copied, so the extra allocation is
bounded by the longest pattern and independent of the output size. Once
all patterns have matched we mark done and drop the buffer.

patternLen summed the pattern lengths (to size the old buffer); the
boundary window only needs the longest pattern, so it's renamed
maxPatternLen and returns the max.

```bash
                  │ benchcmp.bench  │       fix-hasbytewriter.bench       │
                  │     sec/op      │    sec/op     vs base               │
HasBytesWriter-10   2473.095µ ± ∞ ¹   6.114µ ± ∞ ¹  -99.75% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95

                  │ benchcmp.bench │       fix-hasbytewriter.bench        │
                  │      B/op      │     B/op      vs base                │
HasBytesWriter-10      48.00 ± ∞ ¹   128.00 ± ∞ ¹  +166.67% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95

                  │ benchcmp.bench │       fix-hasbytewriter.bench       │
                  │   allocs/op    │  allocs/op   vs base                │
```
2026-07-04 13:25:35 +02:00
dependabot[bot] 332d5ec823 build(deps): bump golang.org/x/mod from 0.36.0 to 0.37.0
Bumps [golang.org/x/mod](https://github.com/golang/mod) from 0.36.0 to 0.37.0.
- [Commits](https://github.com/golang/mod/compare/v0.36.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/mod
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 12:54:41 +02:00
dependabot[bot] 212cc11ada build(deps): bump github.com/pelletier/go-toml/v2 from 2.3.1 to 2.4.2
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.3.1 to 2.4.2.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.3.1...v2.4.2)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-version: 2.4.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-04 12:54:04 +02:00
Bjørn Erik Pedersen dfb35dcd7a tpl/crypto: Add crypto.Hash
Add a generic crypto.Hash template function returning the hex-encoded
checksum of a string using one of md5, sha1, sha256 (default), sha384 or
sha512. The supported algorithms match those used for the SRI hash in
.Data.Integrity on fingerprinted resources, so an SRI hash can be built
by composing with encoding.HexDecode and encoding.Base64Encode.

Fixes #15072

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-03 18:03:05 +02:00
Bjørn Erik Pedersen ef053faa6a Merge commit 'af91eb1997ace34ef5eed0be31646e8483b78096' 2026-07-03 10:43:53 +02:00
Bjørn Erik Pedersen af91eb1997 Squashed 'docs/' changes from e17426e2b6..c85af679bf
c85af679bf content: Miscellaneous edits
43149dae59 content: Cleanup trailing spaces
2b161d600c content: Improve hosting guides
edc02f6402 content: Improve description of scheduled Cloudflare builds
83dff65d50 content: Fix typos
a93f8b956b content: Fix formatting
3dde5702c7 content: Add instructions for scheduled Cloudflare builds
b1075d9c77 content: Change wrangler config format in Cloudflare hosting guide
565d3292c3 content: Add .mjs and .cjs variants to mounted config files
4fc418d223 content: Update version references
bf2efc9114 content: Update hosting guides
0d8ff2dfe1 Update HUGO_VERSION to 0.163.3

git-subtree-dir: docs
git-subtree-split: c85af679bfe21dd7c01924c47933444f002bdc6b
2026-07-03 10:43:53 +02:00
Bjørn Erik Pedersen a5ec542393 Add encoding.HexDecode/Encode
Fixes #15068
See #15060
2026-06-28 21:39:51 +02:00
Bjørn Erik Pedersen 884439b9a2 deps: Upgrade github.com/evanw/esbuild v0.28.0 => v0.28.1
Test / test (1.26.x, ubuntu-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Closes #15033
2026-06-28 19:07:48 +02:00
Joe Mooring 128fb17c2b markup/pandoc: Add citation support
Closes #15062

Co-authored-by: Sebastian Höffner <info@sebastian-hoeffner.de>
2026-06-28 15:37:21 +02:00
Joe Mooring e46d37a984 tpl/tplimpl: Make template name lookup case-insensitive
Closes #15057

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 15:36:29 +02:00
Bjørn Erik Pedersen 790a8aa474 deps: Add Chroma dark/light mode support
Test / test (1.26.x, ubuntu-latest) (push) Has been cancelled
Test / test (1.26.x, windows-latest) (push) Has been cancelled
Closes #15017
2026-06-27 18:21:20 +02:00
Joe Mooring fe06735214 hugolib: Return error from .Render when template not found
Closes #15052
2026-06-25 12:09:52 +02:00
hugoreleaser d15baf53a9 releaser: Prepare repository for 0.164.0-DEV
[ci skip]
2026-06-18 16:39:26 +00:00
hugoreleaser 4d22555aeb releaser: Bump versions for release of 0.163.3
[ci skip]
2026-06-18 16:18:24 +00:00
Bjørn Erik Pedersen ce1a7e0bce markup/highlight: Escape lang in default code block rendering 2026-06-18 18:14:40 +02:00
Bjørn Erik Pedersen c86d9f4aa8 Squashed 'docs/' changes from 1f8ddb8a52..e17426e2b6
e17426e2b6 content: Adjust Cloudflare hosting guide
02edafd770 content: Add build caching instructions for Cloudflare Workers
3430b3c154 content: Update version references
ed8f6930e6 content: Fix examples
3e1a3030ab content: Miscellaneous edits
77f12f75d7 Update HUGO_VERSION to 0.163.2
943417e8f2 content: Remove Hugo SFTP Upload from community tools
62d04f0440 content: Add --global to git config command in CI/CD examples
b9fd5b6cad content: Document GitInfo limitation
9d28aa9e0e content: Fix link references
66bba81f82 content: Fix link references
b08128c0aa content: Fix function reference
2da3083578 content: Miscellaneous edits
40e048f605 content: Use consistent terminology for configuration settings
1b8d67c3fe content: Add HUGO_ENVIRONMENT to the configuration introduction
a931241abe content: Remove erroneous config setting
d621e2c38b Update HUGO_VERSION to 0.163.1
a23a5a0465 content: Fix fenced code block
1122f0e8b5 content: Fix typo
076184e983 content: Miscellaneous standardization edits
b53b801061 theme: Adjust code language substitutions
6fe0cc5f77 content: Fix formatting
f6fc643110 content: Fix link
8e9cdfb9e9 content: Fix link
ae2e15a51e content: Fix links
c73fefee7a content: Fix formatting
9d0390e927 content: Fix link destinations
938a76e00f content: Update comments.md
01d66b670c content: Fix typos
d1e70b81cb content: Improve segmentation documentation
a2a2f89f7f content: Clarify applicability of format-specific imaging config keys
e6c487eb80 content: Remove outdated feature badge
39aebec026 content: Updates for v0.163.0
e9bd8eca6b Update HUGO_VERSION to 0.163.0
f088cf8d19 content: Update hosting guides
ce481a8798 content: Fix broken links
e1f09a8783 content: Remove hidden showcases
54f3a7651e content: Standardize link references
344f8d660e theme: Add nofollow to external links
a83ccec106 theme: Remove the qr transition
5e73f514b4 theme: Hide the search modal on pageswap
57f89ce22d theme: Trim https://gohugo.io from search results when running the server
f12aa1d1d0 content: Fix formatting in Defer
5ab496dd2a content: Add FastComments to the list of commenting systems
1819171668 theme: Remove some unused code
218754408b theme: Replace Turbo with Speculation Rules
bddc24beff content: Fix typos
1a3b527595 content: Fix typo
b72ccefbd9 content: Fix typo
4bfd6c937f content: Miscellaneous link edits
4b57ca8555 content: Clarify :filename slug inference in front matter config
5b1a03ff68 content: Remove outdated new-in badges
bcc6647be8 content: Fix link
263efb81ec content: Add front matter to core-methods.md
41e27e2e54 theme: Render function/method link titles as inline code
8875ef7fe6 content: Replace "scratch pad" with clearer data structure terminology
6e186e591d Update HUGO_VERSION to 0.162.1
30a90c961d content: Add missing configuration key data types
c8e5b9fd2d content: Replace h3 method sequences with description lists
8b44afbe31 content: Fix typo
04109b0921 content: Change collections.Dictionary note regarding nil map
9de560ca01 content: Remove Commento from comments integration list
d2d1506598 content: Update PostCSS examples
6c4bd3a096 content: Updates for v0.162.0
46e6e2c66d Update HUGO_VERSION to 0.162.0
6900d27eea content: Remove date from content file
2c1e04da4a theme: Darken inline code elements in content for better readability
1c5bd6a4b4 content: Clarify resources.Copy publication path
911c1c7549 content: Wrap relevant description list terms in backticks
740e887e05 misc: Remove VS Code extension suggestions

git-subtree-dir: docs
git-subtree-split: e17426e2b63dafe06354e7a204ce61506dc79dc3
2026-06-18 16:28:21 +02:00
Bjørn Erik Pedersen e8988c3141 Merge commit 'c86d9f4aa8a58931f52df6516f10b67c807505fb' 2026-06-18 16:28:21 +02:00
Bjørn Erik Pedersen 70a9068aa6 parser/pageparser: Preserve non-ASCII whitespace after e.g. summary divider
Make it insted consume just ASCII whitespace, which preserves e.g. ideographic space (U+3000) after the summary divider, which is important for e.g. Chinese and Japanese content, and possibly other Unicode whitespace characters with meaning.

Doing this is possibly breaking, but not likely, and obviously the correct thing to do.
2026-06-18 16:23:06 +02:00
Joe Mooring 9d66d513ce resources: Support babel/postcss config variants
Allow modules to use .mjs and .cjs file extensions for Babel and PostCSS
configuration files instead of just .js.

Closes #15039
Closes #15040
Closes #15043
2026-06-18 16:22:10 +02:00
Joe Mooring f013346667 hugolib: Fix page/section name collision regression
Fixes #15046

When a regular page (e.g. content/s1.md) and a section (content/s1/)
share the same tree key, the assembler must not overwrite the real page
with a synthetic section. Restore the existence guard that was present
in v0.152.2.

The fix checks if a node already exists at the section key before
inserting a synthetic section during root section creation.

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 16:20:34 +02:00
hugoreleaser 96e06e1ab8 releaser: Prepare repository for 0.164.0-DEV
[ci skip]
2026-06-15 15:17:37 +00:00
hugoreleaser 19a5cec0b9 releaser: Bump versions for release of 0.163.2
[ci skip]
2026-06-15 14:55:00 +00:00
Bjørn Erik Pedersen 134674f00d Continue resolving on ERR_ACCESS_DENIED in Node's resolver
And then rethrow the ERR_ACCESS_DENIED if we cannot recover.

There's more details in #15041, but this error has been seen on Netlify with the CJS because of how Netlify has their node_modules cache folder set up.

Fixes #15041
2026-06-15 15:53:19 +02:00
Joe Mooring 147f605f7d markup: Standardize behavior when external converters are missing
Closes #14222
2026-06-13 20:19:47 +02:00
hugoreleaser 1f35beb918 releaser: Prepare repository for 0.164.0-DEV
[ci skip]
2026-06-11 15:55:44 +00:00
hugoreleaser 2a4fd58818 releaser: Bump versions for release of 0.163.1
[ci skip]
2026-06-11 15:34:40 +00:00
dependabot[bot] 93c8c7d345 build(deps): bump golang.org/x/image from 0.41.0 to 0.42.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.41.0 to 0.42.0.
- [Commits](https://github.com/golang/image/compare/v0.41.0...v0.42.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.42.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-11 16:15:56 +02:00
Bjørn Erik Pedersen 95e5e9f4ab Fix multi --renderSegments merge behavior
Fixes #15024
2026-06-11 15:25:43 +02:00
Bjørn Erik Pedersen a00b5c72ac security: Normalize integer IPv4 host encodings in http.urls check
Canonicalize integer/hex/octal IPv4 hosts to dotted-decimal before
applying the security.http.urls policy so all encodings of an address
are treated alike.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-10 18:53:36 +02:00
Bjørn Erik Pedersen cf9c8f93ca Drop symlinks in os.ReadDir, os.ReadFile, os.Stat and os.FileExists
Fixes #15019
2026-06-10 18:53:36 +02:00
Bjørn Erik Pedersen 11f09f59fc Update CI workflow to exclude macOS
Removed macOS from the CI test matrix.
2026-06-09 14:34:07 +02:00
Joe Mooring 2602796cf1 commands: Fix convert command
Closes #15012
2026-06-09 13:02:26 +02:00
hugoreleaser 72495f9fba releaser: Prepare repository for 0.164.0-DEV
[ci skip]
2026-06-08 14:36:12 +00:00
hugoreleaser 4a9485336a releaser: Bump versions for release of 0.163.0
[ci skip]
2026-06-08 14:13:03 +00:00
anupamojha-eng 1d018ef857 pagesfromdata: Use relative path for content adapter template metrics
Fixes #14999
2026-06-08 15:56:47 +02:00
Bjørn Erik Pedersen 121bc6ceb2 ci: Re-add macos-latest to the test matrix
I suspect there will be some disk space issue, but let's try.
2026-06-08 15:55:43 +02:00
dependabot[bot] 0d29fc81bb build(deps): bump github.com/bits-and-blooms/bitset
Bumps [github.com/bits-and-blooms/bitset](https://github.com/bits-and-blooms/bitset) from 1.24.4 to 1.24.5.
- [Release notes](https://github.com/bits-and-blooms/bitset/releases)
- [Commits](https://github.com/bits-and-blooms/bitset/compare/v1.24.4...v1.24.5)

---
updated-dependencies:
- dependency-name: github.com/bits-and-blooms/bitset
  dependency-version: 1.24.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 11:32:09 +02:00
dependabot[bot] bb57404f3d build(deps): bump github.com/tetratelabs/wazero
Bumps [github.com/tetratelabs/wazero](https://github.com/tetratelabs/wazero) from 1.11.1-0.20260521072212-475a1f8f0dc3 to 1.12.0.
- [Release notes](https://github.com/tetratelabs/wazero/releases)
- [Commits](https://github.com/tetratelabs/wazero/commits/v1.12.0)

---
updated-dependencies:
- dependency-name: github.com/tetratelabs/wazero
  dependency-version: 1.12.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-08 11:31:32 +02:00
Bjørn Erik Pedersen 781fabf4e4 all: Run go fix ./... 2026-06-07 21:20:40 +02:00
Bjørn Erik Pedersen cf18b827e2 images: Deprecate Imaging.Compression and move it down to webp and avif configs
Also clean up and simplify the image config handling.

Closes #14998
2026-06-07 21:20:16 +02:00
Bjørn Erik Pedersen 98ad9b3c03 Only support the latest Go version
Which is currently Go 1.26.

Closes #14997
2026-06-07 17:01:04 +02:00
Bjørn Erik Pedersen ff2903a931 resources/jsconfig: Remove deprecated baseUrl setting
baseUrl is deprecated in TypeScript and is no longer required when
paths is set (TypeScript 4.1+).

Fixes #14991
Closes #14996
2026-06-07 13:35:25 +02:00
dependabot[bot] 7d1b1fb33d build(deps): bump github.com/rogpeppe/go-internal from 1.14.1 to 1.15.0
Bumps [github.com/rogpeppe/go-internal](https://github.com/rogpeppe/go-internal) from 1.14.1 to 1.15.0.
- [Release notes](https://github.com/rogpeppe/go-internal/releases)
- [Commits](https://github.com/rogpeppe/go-internal/compare/v1.14.1...v1.15.0)

---
updated-dependencies:
- dependency-name: github.com/rogpeppe/go-internal
  dependency-version: 1.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 19:05:57 +02:00
Bjørn Erik Pedersen b89e7fe675 page: Add IsBranch and deprecate IsNode
IsNode's meaning was murky. Add IsBranch, defined as the set of branch
node kinds (home, section, taxonomy, term), and make IsNode a deprecated
alias for it.

Fixes #11574

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:18:57 +02:00
Bjørn Erik Pedersen e8fefc8388 images: Force cache invalidation for AVIF target
Fixes #14990
2026-06-05 16:18:23 +02:00
Bjørn Erik Pedersen a043d3ec63 images: Add a per-format AVIF hint setting
The hint setting controls WebP encoding (preset) and AVIF encoding
(chroma subsampling), but only lived on imaging.webp. Add imaging.avif.hint
so it shows up under the AVIF section in the docs, with the same root-level
backwards compatibility as imaging.webp.hint. Per-image hint now resolves
from the target format.

Fixes #14992

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 16:16:38 +02:00
dependabot[bot] 77a1147056 build(deps): bump github.com/getkin/kin-openapi from 0.138.0 to 0.139.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.138.0 to 0.139.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.138.0...v0.139.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.139.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-05 16:16:00 +02:00
Bjørn Erik Pedersen 341f575d2d images: Make AVIF chroma subsampling content-aware via the hint
Encode photo/picture hints (and the default) as YUV420 instead of YUV444,
keeping 444 for text/icon/drawing. Lossless stays 444.

This roughly halves the encoder's peak memory (42 -> 27 MiB/MP) and the
output size, while 444 remains available for sharp-edged content. A
3000x3000 image now needs ~239 MiB to encode, down from ~381 MiB (which
sat right at the 384 MiB WASM cap).

Closes #14987

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 18:54:56 +02:00
Bjørn Erik Pedersen 248241b6e1 Cap AVIF lossy quality at 99
Before this commit, lossy compression with 100 quality meant lossless compression, leading to a big gap between 99 and 100.

This commit caps the lossy quality at 99, ensuring that lossy compression is always lossy.

Fixes #14981
2026-06-04 14:42:08 +02:00
Bjørn Erik Pedersen 4e47d95db9 config: Deprecate the glogal imaging quality setting
In favour of the new per-image quality setting.

See #14979
2026-06-04 11:07:34 +02:00
Bjørn Erik Pedersen 03b4b54220 images: Make 60 the default quality for AVIF
AVIF's quality scale is not perceptually comparable to JPEG/WebP:
libavif anchors its "good" default at 60, where Hugo's universal 75
produced visibly higher quality and larger files. AVIF now defaults to
60 while JPEG and WebP keep 75. An explicit imaging.quality,
imaging.avif.quality or a per-image qNN still wins.

Fixes #14979

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:07:34 +02:00
Bjørn Erik Pedersen 79be0532f1 livereload: Disconnect from websocket server on pageswap
Fixes #14983
2026-06-04 11:04:39 +02:00
Bjørn Erik Pedersen 0f440460c8 tpl/tplimpl/embedded: Prevent leading newline in sitemap template
Fixes #14977
2026-06-04 11:03:03 +02:00
Bjørn Erik Pedersen 4e17421ec2 images: Recover from memory alloc errors in WASM image processors
Fixes #14985
2026-06-04 11:01:49 +02:00
Bjørn Erik Pedersen b01ecd4cd4 images: Add quality setting per image format
Allow setting quality per output format via imaging.jpeg.quality,
imaging.webp.quality and imaging.avif.quality. Each falls back to the
global imaging.quality when unset, and a per-image qNN still wins.

Fixes #14957

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 20:43:55 +02:00
Joe Mooring ca68936d61 all: Adjust tests for deprecated link and image render hook settings 2026-06-01 15:52:34 +02:00
Joe Mooring 45c00b7c16 misc: Remove duplicate words in comments
Closes #14936
Closes #14950
Closes #14965
2026-05-29 17:54:14 +02:00
Bjørn Erik Pedersen 28d882ab70 Add some PNG to AVIF golden test cases
Both 8 and 24 bit PNG to AVIF with vivid colors.

The output looks good to me ...

https://discourse.gohugo.io/t/v0-162-0-avif-image-processing-strips-washes-out-colors-on-vector-graphics/57210
2026-05-29 11:28:27 +02:00
hugoreleaser b0fd7ee2e1 releaser: Prepare repository for 0.163.0-DEV
[ci skip]
2026-05-28 18:03:30 +00:00
hugoreleaser bba860e3ed releaser: Bump versions for release of 0.162.1
[ci skip]
2026-05-28 17:40:44 +00:00
Joe Mooring 59f35cd985 modules/npm: Fix false stale warning after npm pack
Closes #14959

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 18:57:41 +02:00
Joe Mooring 21e9868fe2 tpl/tplimpl: Fix X shortcode test 2026-05-28 09:26:45 +02:00
Bjørn Erik Pedersen 2760288c6d tpl: Skip broken x shortcode test 2026-05-27 21:21:35 +02:00
Bjørn Erik Pedersen c270975049 Revert "tpl/collections: Make dict return nil when no values are provided"
This reverts commit 17a86ef5ed.

Closes #14958
2026-05-27 20:02:55 +02:00
Joe Mooring ea8b48af64 tpl/time: Fix locale-specific month abbreviations
Closes #14948
2026-05-27 12:38:21 +02:00
hugoreleaser 076dfe13d0 releaser: Bump versions for release of 0.162.0
[ci skip]
2026-05-26 13:53:44 +00:00
Bjørn Erik Pedersen e41a06447d Disallow HTML content by default
For security reasons. Enable in security config, e.g.:

```toml
[security]
allowContent = ['.*']
```
2026-05-26 13:57:12 +02:00
Bjørn Erik Pedersen 90d9f812b2 Add image processing support for AVIF
The encode/decode is implemented in a WebAssembly module built from a
small C wrapper around libavif. Bundled libraries (statically linked,
compiled with the WASI SDK):

* libavif v1.4.1 (container + codec glue)
* libaom v3.14.1 (AV1 encoder + decoder)
* dav1d 1.5.3 (AV1 decoder)
* libyuv (Chromium pin) for color conversion
* parson for JSON message passing across the wasm boundary

HDR handling on the encoder:

* SDR images are written as BT.709 / sRGB / BT.601 (8-bit).
* 10-bit and up are written as BT.2020 primaries with PQ (SMPTE
  ST 2084) transfer and BT.2020-NCL matrix coefficients, signalled
  via CICP.
* Adobe-style SDR+gainmap inputs (e.g. Lightroom HDR exports) are
  baked into a single true-HDR image in BT.2020/PQ at 10-bit, with
  the CLLI (Content Light Level Information) box carried through so
  HDR-capable clients can tone-map correctly.

Limitations:

* Animated input (animated WebP/GIF) is collapsed to its first frame
  when re-encoded as AVIF; animated AVIF output is not yet supported.

Fixes #7837
2026-05-26 12:13:53 +02:00
Joe Mooring 80e60847fb config: Preserve intentionally empty maps
Closes #14944
2026-05-26 11:50:04 +02:00
Bjørn Erik Pedersen df5421918a hugolib: Fix Page.GitInfo for modules with go.mod in a repo subdirectory
Map content files using their path relative to the git repo root by
prepending the module's Origin.Subdir to the lookup key.

Fixes #14942
2026-05-26 00:28:22 +02:00
Bjørn Erik Pedersen aeb9a5cc02 hugolib: Merge existing hugo_stats.json when renderSegments is set
With renderSegments only a subset of pages is rendered, so the resulting
hugo_stats.json would no longer contain elements from the excluded pages,
causing tools like Tailwind to strip classes that are actually in use.

Fixes #14939
2026-05-25 17:00:52 +02:00
Bjørn Erik Pedersen c4bbc2805c all: Replace RWMutex struct caches with ConcurrentMap 2026-05-24 15:29:47 +02:00
Joe Mooring d8c70218b7 tpl/tplimpl: Consolidate and improve embedded template integration tests
Closes #14932
2026-05-24 15:29:33 +02:00
Bjørn Erik Pedersen ee4f1acd93 parser: Drop empty sub maps from hugo config output
Fixes #14855
2026-05-24 11:52:43 +02:00
Bjørn Erik Pedersen b6133657e0 markup/highlight: Allow overriding type and code via options
Treat type and code as highlighting options in both transform.Highlight
and transform.HighlightCodeBlock. The type option overrides the language
and code overrides the code, so the two functions now share the same
options handling.

transform.Highlight's LANG argument is now optional:

	transform.Highlight CODE [LANG] [OPTIONS]

Fixes #11872
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 13:12:07 +02:00
Bjørn Erik Pedersen 4bc7caea14 Fix typo in CONTRIBUTING.md 2026-05-23 12:47:00 +02:00
Bjørn Erik Pedersen 7711ba4d0c Remove note on refactoring contributions
Removed note about holding off on big refactoring.
2026-05-23 11:55:27 +02:00
Bjørn Erik Pedersen d2c821b5c7 Update AI assistance disclosure requirements
Clarify expectations for AI contributions and manual verification in pull requests.
2026-05-23 11:54:57 +02:00
dependabot[bot] 4f444c810c build(deps): bump golang.org/x/net from 0.54.0 to 0.55.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.54.0 to 0.55.0.
- [Commits](https://github.com/golang/net/compare/v0.54.0...v0.55.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.55.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 11:43:45 +02:00
dependabot[bot] fe6c72652f build(deps): bump golang.org/x/image from 0.40.0 to 0.41.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/image/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-23 11:43:06 +02:00
Bjørn Erik Pedersen 4ed7600fd6 hugolib: Use AllTranslated in IsTranslated
Which makes it faster:

```
                           │ benchcmp.bench │   feat-speedupistranslated.bench    │
                           │     sec/op     │    sec/op     vs base               │
IsTranslatedOneLanguage-10     996.2n ± ∞ ¹   685.6n ± ∞ ¹  -31.18% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95

                           │ benchcmp.bench │   feat-speedupistranslated.bench   │
                           │      B/op      │    B/op      vs base               │
IsTranslatedOneLanguage-10      832.0 ± ∞ ¹   536.0 ± ∞ ¹  -35.58% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95

                           │ benchcmp.bench │   feat-speedupistranslated.bench   │
                           │   allocs/op    │  allocs/op   vs base               │
IsTranslatedOneLanguage-10      18.00 ± ∞ ¹   13.00 ± ∞ ¹  -27.78% (p=0.029 n=4)
¹ need >= 6 samples for confidence interval at level 0.95
````
2026-05-22 14:11:50 +02:00
Bjørn Erik Pedersen cbe4339a5f tpl: Simplify sitemap template
The benefit of my performance motivated construct in 6475d308ec was most likely minimal and not worth the loss of clarity.

See #14912
2026-05-21 21:36:43 +02:00
Bjørn Erik Pedersen 6475d308ec tpl: Use AllTranslations in sitemap template
Closes #14912
Closes #14917
2026-05-21 19:44:23 +02:00
Bjørn Erik Pedersen 67aede4364 tpl/collections: Make dict return nil when no values are provided 2026-05-21 16:03:02 +02:00
Bjørn Erik Pedersen 87f194b249 Sync Go template package to 1.26.3
See #14897
2026-05-21 14:28:31 +02:00
Bjørn Erik Pedersen 5f01b0603d Merge commit 'c23d97904fd31ef745bece57133aa1b9275ec20c' 2026-05-21 12:22:49 +02:00
Bjørn Erik Pedersen c23d97904f Squashed 'docs/' changes from 0755fb534d..1f8ddb8a52
1f8ddb8a52 content: clarify resources front matter key descriptions
e064ab8528 content: Add deprecation badges to module config page
727ca5563a github: Add push trigger to lint workflow
64dd5c9886 content: Fix typo
c5bc6b6515 github: Fix lint workflow
faec0c3a0a github: Combine linting actions into a single workflow
06112aeaf2 theme: Miscellaneous template edits
75d4902270 theme: Format templates with gotmplfmt
fec2e2a67e content: Document that the language code in a file name must be lowercase
9dbd841ba6 content: Document the src attribute in the Page Resources metadata reference
fd3ffef985 content: Fix "build from source" instructions for Windows
af4c9cd4d7 content: Miscellaneous edits
408d8b2f0a content: Miscellaneous edits
e8804afe6e content: Fix typo
d98276be30 content: Updates for v0.161.0
01b1f8fa12 content: Note merge limitation for slice configuration values
d2b18f0c8d content: Document page matcher usage for cascading values
45e5bd9ab3 content: Update Cloudflare Worker host/deploy guide
b83726b89a content: Document fallback rendering for fenced code blocks
8f1eeb42bc content: Update reference for source code shortcode
e8da56303b content: Add gotmplfmt to list of VS Code extensions
950fabbfd6 content: Update FAQ on feature availability error
6411146d24 content: Update quick start guide
38cc39fd51 content: Add Hugo Shortcodes to list of VS Code extensions
72d98b107b content: Misc updates to get validators to pass
9fb0e1ca35 Add a paragraph about sec boundaries
e6abf5644f content: Improve syntax highlighting documentation
c06193bd1a content: Update go-i18n package reference
ce58fef945 Hugo 0.161.1
c7e0f63385 content: Fix package references
7f15fb3bf9 data: Regen docshelper
7483d53b55 Update HUGO_VERSION to 0.161.0
c4abcdb45f security: Add a bullet point about "pragmatic defaults"
3cd7492862 content: Improve explanation of mount removal in module configurations
4099f07bb9 content: Update GitHub Pages workflow example
a6c9853a58 content: Fix typo
e6f79a938b Update netlify.toml
abda3d6659 content: Update Action versions in GitHub Pages workflow example
55dd288fa9 content: Add GitCMS to front-ends tools list
21081f6d49 content: Remove outdated new-in badges
b2ec263884 content: Update version references
825e0b8ea9 One more CSS var adjustment
85f95a899b Adjust css.Build var docs a little
df48288002 content: Updates for v0.160.0
a82a9b9797 Update HUGO_VERSION to 0.160.0
1155747dc4 content: Improve CSS processing feature description
f6ce893974 content: Add css.Build to features
67b8ed1198 content: Fix typos
0f62a67863 content: Fix typo
dbb42aed4a content: Document the deploy edition
549f30f933 content: De-emphasize references to the extended edition
8f5c9782d4 content: Add Pages CMS to front-ends documentation
b2bfc3af48 Update HUGO_VERSION to 0.159.2
3793156fc5 content: Fix typos
bacd4824ef content: Specify function namespace in example
7f2dc0d40a Regen docs.yml
65a851f731 Update HUGO_VERSION to 0.159.1
ce05fe3fc0 content: Adjust variable references in build script examples
8a04f9fe64 content: Improve hosting build script examples
67962ce05c content: Link to Codeberg Pages 404 handling
fd248f57ed content: Identify esbuild as the foundation for build functions
62f02879fd content: Remove outdated content
553c407f9e content: Miscellaneous corrections
77e2cad088 content: Add new-in badge for usePackageJSON
0746e1e621 Add a page on using npm dependencies in Hugo Modules
8824850f5c Update HUGO_VERSION to 0.159.0

git-subtree-dir: docs
git-subtree-split: 1f8ddb8a5230518f07c50b4b03cba3cae21081c4
2026-05-21 12:22:48 +02:00
Joe Mooring 5d51b82a7e resources: Fix the :counter placeholder
Closes #14921

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-21 09:45:11 +02:00
Bjørn Erik Pedersen d81e3c29c0 Upgrade to Go 1.26.3
Fixes #14897
2026-05-20 11:00:30 +02:00
Bjørn Erik Pedersen 7c65a4dbc0 ci: Check embedded template formatting with gotmplfmt 2026-05-19 16:50:25 +02:00
Bjørn Erik Pedersen d31a9275c6 tpl: Run gotmplfmt -w . 2026-05-19 16:15:45 +02:00
dependabot[bot] 6a2a03806d build(deps): bump github.com/getkin/kin-openapi from 0.137.0 to 0.138.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.137.0 to 0.138.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.137.0...v0.138.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.138.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 12:34:07 +02:00
dependabot[bot] cf1de598d9 build(deps): bump github.com/JohannesKaufmann/html-to-markdown/v2
Bumps [github.com/JohannesKaufmann/html-to-markdown/v2](https://github.com/JohannesKaufmann/html-to-markdown) from 2.5.0 to 2.5.1.
- [Release notes](https://github.com/JohannesKaufmann/html-to-markdown/releases)
- [Commits](https://github.com/JohannesKaufmann/html-to-markdown/compare/v2.5.0...v2.5.1)

---
updated-dependencies:
- dependency-name: github.com/JohannesKaufmann/html-to-markdown/v2
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-19 12:25:06 +02:00
Joe Mooring c36608c584 markup/goldmark/codeblocks: Always split Chroma options into .Options
Closes #14909
2026-05-19 12:23:42 +02:00
Joe Mooring 1e4037cade docs: Update docs.yaml 2026-05-18 21:24:35 -07:00
Alexandre Vaz 2f361a8e25 hugolib: Allow empty params front matter
Treat an empty front matter params key as an empty params map instead of failing while decoding page metadata.

Fixes #14886
2026-05-17 19:59:44 +02:00
Joe Mooring 81d77620c6 commands: Fix import from Jekyll
- Look for _config.yml, _config.yaml, or _config.toml
- Fix theme submodule URL
- Fix config filename in the instructions
- Add tests

Closes #14795
Closes #14906
2026-05-17 10:46:40 +02:00
Joe Mooring 5559263326 common/hmaps: Merge slice-valued module config into site config
When a module provides a config key whose value is a slice (e.g.
cascade or permalinks), and the site config declares the same key as a
map with only a merge strategy marker (_merge = 'deep'), the types do
not match and Params.merge silently dropped the module's value, leaving
the site with no effective cascade or permalink config from the module.

Fix Params.merge so that when the destination value is an empty Params
(IsZero — only the _merge key is present) and the source value is a
non-Params type, the source value is used provided the user-declared
merge strategy is not 'none'. This honours the explicit _merge
directive regardless of the surrounding shallow-merge context.

Closes #13869

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-15 19:50:42 +02:00
dependabot[bot] 97f990cc4f build(deps): bump golang.org/x/image from 0.39.0 to 0.40.0
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/image/compare/v0.39.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-15 16:00:01 +02:00
Bjørn Erik Pedersen 656fc04035 tpl: Use GetMatch for both local and global image resources
See #14062
2026-05-14 20:49:33 +02:00
Bjørn Erik Pedersen a20cb5b1c0 Revert "markup/tableofcontents: Skip empty TOC levels"
This reverts commit 7d4af7a179.

Closes #14898
2026-05-14 19:46:41 +02:00
dependabot[bot] b99634e253 build(deps): bump golang.org/x/tools from 0.44.0 to 0.45.0
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.44.0 to 0.45.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.44.0...v0.45.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.45.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-14 15:39:54 +02:00
Bjørn Erik Pedersen 4d775cbe95 tpl/templates: Reject Defer inside partialCached
A partial's rendered output (placeholder included) is cached by
partialCached across rebuilds, but BuildState.DeferredExecutions
is reset every stage. On a fast-render rebuild the cached string
replays the placeholder while doDefer is not called this build,
leaving executeDeferredTemplates to panic with "deferred execution
with id ... not found".

Mark the ctx inside IncludeCached's body execution and have Defer
return a clear error if it sees the flag. Catches transitive cases
(partialCached -> partial -> Defer) via ctx propagation. Defer in
baseof.html and in a plain partial is unaffected.

Fixes #13492

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 14:17:42 +02:00
dependabot[bot] fdd977e95d build(deps): bump github.com/aws/aws-sdk-go-v2/service/s3
Bumps [github.com/aws/aws-sdk-go-v2/service/s3](https://github.com/aws/aws-sdk-go-v2) from 1.92.1 to 1.97.3.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.92.1...service/s3/v1.97.3)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/s3
  dependency-version: 1.97.3
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 22:32:44 +02:00
Bjørn Erik Pedersen ae7bf74b3e common/hexec: Make NODE_PATH a fallback for ESM bare imports
Node's ESM resolver does not consult NODE_PATH (unlike CJS require), so
an ESM postcss.config.js shipped by a Hugo theme fails when loaded from
the module cache: bare imports like `import x from "postcss-import"`
have no node_modules to walk up to.

Install a synchronous resolver hook (module.registerHooks) via
--import=data:... on every Node invocation. On ERR_MODULE_NOT_FOUND for
a bare specifier it resolves the package from each NODE_PATH entry via
createRequire().resolve(). No-op for relative, absolute, URL-scheme and
non-MODULE_NOT_FOUND failures. Synchronous hooks run on the main thread,
so no --allow-worker is needed under the Node permission model.

Fixes #13987

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 22:00:04 +02:00
dependabot[bot] 123018de21 build(deps): bump github.com/pelletier/go-toml/v2 from 2.3.0 to 2.3.1
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.3.0 to 2.3.1.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.3.0...v2.3.1)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-13 18:23:27 +02:00
Bjørn Erik Pedersen ba5d812673 config: Allow repeating the root key in /config files
If a non-default-name file in the config folder parses to a map with a
single top-level key matching the file's basename, unwrap it. This lets
TOML/YAML express slice-typed roots (cascade, permalinks), which can't
have a headless top-level array, and also lets users copy-paste docs
examples that include the root container (e.g. params.yaml with a
top-level params: block).

Fixes #12899
Fixes #14882

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-13 18:22:01 +02:00
Bjørn Erik Pedersen be4a0df3a2 Revise test naming guidelines in AGENTS.md
Updated test naming conventions for clarity and documentation.
2026-05-13 17:42:20 +02:00
Bjørn Erik Pedersen e4cf565c0d Update AGENTS.md 2026-05-13 17:34:08 +02:00
Alexandre Vaz 9e64953338 js: Return error for missing batch imports
A stale or removed resource used by js.Batch could panic while the esbuild import loader read its content during a rebuild. Return the read error through the loader so esbuild reports a normal build error and a later rebuild can recover when the file returns.

Closes #13737
2026-05-13 17:15:14 +02:00
Alexandre Vaz f0cfc28c00 resources/images: Keep smart crop target size
Smartcrop can return a crop rectangle that is smaller than the requested dimensions after prescaling and rounding. Expand that rectangle within the source image bounds before applying Hugo's crop/fill pipeline, so smart crops keep the requested size without stretching the image.

Bump the smart crop cache version for crop/fill only.

Fixes #13688

Co-Authored-By: Joe Mooring <joe.mooring@veriphor.com>
2026-05-13 12:10:44 +02:00
Bjørn Erik Pedersen 16e854a437 testing: Use synctest where relevant 2026-05-13 10:07:26 +02:00
Bjørn Erik Pedersen f8b5fa09a6 Fix prevention of direct symlink reads in resources.Get
* Note for themes, this is only an issue for themes stored locally, e.g. below `themes/...`. Themes mounted as modules from GitHub gets symlinks stripped away.
* Thas was also not an issue for file reading walking one or more directories.
* This is an regression introduced in `v0.123.0`.
2026-05-13 10:06:43 +02:00
Bjørn Erik Pedersen 86fbb0f7a8 security: Validate redirects against security.http.urls
A server allowed by security.http.urls could redirect resources.GetRemote
to a host that is not. Re-run the check on each hop via CheckRedirect.

Fixes #14871
2026-05-13 10:06:43 +02:00
Alexandre Vaz 7d4af7a179 markup/tableofcontents: Skip empty TOC levels
Fixes #7128
2026-05-12 19:02:23 +02:00
Bjørn Erik Pedersen 28147cb040 Fall back to hugo.buildDate in hugo.BuildDate() in non-vcs builds
Fixes #14862
2026-05-11 12:26:13 +02:00
Bjørn Erik Pedersen db40fada48 agents: Add a note to Ai security researchers 2026-05-11 11:31:17 +02:00
Bjørn Erik Pedersen b88fa8cc66 deps: Upgrade to Chroma v2.24.1
Closes #14839
2026-05-10 20:19:11 +02:00
Alexandre Vaz 88d838a971 commands: Fix github-dark chromastyles
Fixes #14831
2026-05-10 20:19:11 +02:00
Bjørn Erik Pedersen e51e761d9c css: Make css.Build's file-loader URLs absolute to web context root
When CSS imports assets via the file loader (fonts, images), the emitted
URLs were relative to the CSS output directory. That broke when the CSS
was inlined into HTML, since browsers then resolved the URLs against the
page rather than the CSS file.

Set esbuild's PublicPath to the CSS output directory joined with the
site base path so URLs work whether the CSS is published as a file or
inlined.

Fixes #14849
2026-05-10 19:08:08 +02:00
Bjørn Erik Pedersen 7011239205 hugolib: Don't warn about lang/kind/path coming from cascade.params
These keys are reserved at the top level of front matter, but are
legitimate user params under cascade.params. Only fire the deprecation
when the key was actually set at the top level of the original front
matter.

Fixes #14848
2026-05-09 11:54:20 +02:00
Rayan Salhab 694906f6f1 markup/goldmark: Unwrap inner HTML for plain code blocks
Fixes #14820
2026-05-09 11:41:18 +02:00
Ogulcan Aydogan d27b9c06bd tpl/tplimpl: Extend page image lookup to include global resources
Fall back to global resources via resources.Get when page resources
don't match for named images in the images front matter parameter.
This aligns get-page-images.html with the existing behavior in
render-image.html, render-link.html, and figure.html.

Fixes #14062
2026-05-08 11:34:44 +02:00
Bjørn Erik Pedersen 62cef3678b security: Allow hostnames starting with digits in default http.urls
Domains like 1password.com and 37signals.com were blocked by the default
allow rule '^https?://[a-z]'. Allow [a-z0-9] for the first hostname char
and add an explicit deny for hosts whose first label is all-digit (IP
literals like 127.0.0.1) to retain the prior SSRF protections.

Fixes #14837
2026-05-01 15:34:20 +02:00
Joe Mooring ff22c62a32 commands: Improve description of command flags
Closes #14817
2026-04-30 21:38:44 +02:00
hugoreleaser 7fd65e16e4 releaser: Prepare repository for 0.162.0-DEV
[ci skip]
2026-04-29 14:17:46 +00:00
hugoreleaser ea8f66a7ce releaser: Bump versions for release of 0.161.1
[ci skip]
2026-04-29 13:56:01 +00:00
Bjørn Erik Pedersen c4eba92863 resources: Honor Retry-After header in resources.GetRemote retries
When the server returns a temporary HTTP error (e.g. 429 or 503)
together with a Retry-After header, use that value as the next sleep
duration instead of the default exponential backoff. The Retry-After
value is also surfaced in the retry-timeout error message.

Fixes #14828
2026-04-29 15:44:16 +02:00
Bjørn Erik Pedersen 8b40a96b6e warpc: Move to parson.c in https://github.com/kgabis/parson
And be specific about which commit we use.

Hugo treat this as an upstream dependency, so we would appreciate that any bugs will be reported and fixed upstream.

See #14823
2026-04-29 13:51:42 +02:00
Bjørn Erik Pedersen d65af84d15 config/security: Add AllowChildProcess to security.node.permissions
Some Linux setups trigger detect-libc's spawnSync('getconf') fallback
when process.report does not expose glibcVersionRuntime, breaking
tailwindcss under the Node permission model. Add AllowChildProcess
mirroring AllowAddons/AllowWorker, default to ["tailwindcss"], and
emit --allow-child-process accordingly.

Fixes #14824
2026-04-29 13:50:37 +02:00
Bjørn Erik Pedersen 454450a647 config/security: Restrict default http.urls "@" deny to userinfo
The previous "! @" deny rule rejected any URL containing "@",
including legitimate version-pinned imports such as
https://cdn.jsdelivr.net/npm/mermaid@latest/dist/mermaid.esm.min.mjs.
Tighten it to "! (?i)^https?://[^/?#]*@" so only "@" inside the
authority section (i.e. real userinfo) is blocked.

Fixes #14825

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 13:50:37 +02:00
hugoreleaser 2bfcc6b994 releaser: Prepare repository for 0.162.0-DEV
[ci skip]
2026-04-28 12:08:01 +00:00
hugoreleaser 98d396c16a releaser: Bump versions for release of 0.161.0
[ci skip]
2026-04-28 11:46:32 +00:00
dependabot[bot] d4ae662d59 build(deps): bump github.com/getkin/kin-openapi from 0.135.0 to 0.137.0
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.135.0 to 0.137.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.135.0...v0.137.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.137.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-28 13:40:06 +02:00
dependabot[bot] 9ede5fb9e0 build(deps): bump github.com/mattn/go-isatty from 0.0.21 to 0.0.22
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.21 to 0.0.22.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.21...v0.0.22)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 20:10:08 +02:00
dependabot[bot] 833a878eef build(deps): bump github.com/tdewolff/minify/v2 from 2.24.12 to 2.24.13
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.12 to 2.24.13.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.12...v2.24.13)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-version: 2.24.13
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 20:08:49 +02:00
Bjørn Erik Pedersen 7622dd86ce css: Support nested hugo:vars/<name> imports
Allow CSS variables to be grouped under sub-paths and imported via
@import "hugo:vars/mobile" (or @use for Dart Sass), so callers can pass
nested dicts like:

    {{ dict "primary-color" "blue" "mobile" (dict "primary-color" "red") }}

Top-level "hugo:vars" now skips nested map entries instead of emitting
garbage for them.

Fixes #14705

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 19:42:50 +02:00
Bjørn Erik Pedersen 0814059bb6 github: Update GitHub actions versions
By running:

```
ghat swot --stable 7 -d .github
```

Closes #14810
2026-04-27 19:15:56 +02:00
Joe Mooring 8920d56e95 hugolib: Do not render aliases if the page is not rendered
Closes #14807
2026-04-25 18:41:27 +02:00
Joe Mooring 633cc772e0 langs/i18n: Improve default content language fallback
The fallback order for translations is now:

1. Current language's locale (e.g., pt-BR → pt-br.toml)
2. Current language's key (e.g., pt → pt.toml)
3. Default language's locale (e.g., es-AR → es-ar.toml) ← new
4. Default language's key (e.g., es → es.toml)

Closes #14243

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-25 10:43:37 +02:00
Bjørn Erik Pedersen 90d8bf34ae Replace deprecated .Site.Sites/.Page.Sites with hugo.Sites intests 2026-04-24 19:02:46 +02:00
Bjørn Erik Pedersen 4c40c6d5ca helpers: Remove unused code 2026-04-23 20:05:09 +02:00
Bjørn Erik Pedersen d2594db670 common/constants: Remove unused consts 2026-04-23 20:05:09 +02:00
Bjørn Erik Pedersen ab2de51e07 common/paths: Remove unused code
Identified with:

```
punused "common/paths/**.go"
 ````
2026-04-23 20:05:09 +02:00
Joe Mooring 72b85d5f9c langs/i18n: Fix translation lookup when using language variants
Closes #7982

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-23 18:47:12 +02:00
Joe Mooring 75f61832c2 tests: Update Ruby setup action to v1.305.0 2026-04-23 18:46:49 +02:00
dependabot[bot] 4c03129fcf build(deps): bump github.com/magefile/mage from 1.17.1 to 1.17.2
Bumps [github.com/magefile/mage](https://github.com/magefile/mage) from 1.17.1 to 1.17.2.
- [Release notes](https://github.com/magefile/mage/releases)
- [Commits](https://github.com/magefile/mage/compare/v1.17.1...v1.17.2)

---
updated-dependencies:
- dependency-name: github.com/magefile/mage
  dependency-version: 1.17.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-23 15:02:42 +02:00
Bjørn Erik Pedersen 080970bc6b deps: Upgrade github.com/bep/imagemeta v0.17.1 => v0.17.2 2026-04-23 15:02:28 +02:00
Joe Mooring 1b7495bc49 langs: Use Language.Locale as primary localization key
Localization now uses Language.Locale as the golocales lookup key,
falling back to Language.Lang, then defaultContentLanguage, then "en".

Closes #9109
2026-04-22 22:41:51 +02:00
Bjørn Erik Pedersen 79f030be5b config/security: Add "! " negation to Whitelist, harden default http.urls
Whitelist now treats any pattern prefixed with "! " (the same negation
prefix used by hglob/predicate) as a deny rule. Deny matches take
precedence over allow, and a whitelist made up exclusively of deny
rules implicitly allows everything it does not deny.

The default security.http.urls now reads:

    urls = ['(?i)^https?://[a-z]', '! (?i)localhost', '! @']

i.e. allow URLs whose host starts with a letter (the common
"https://example.com" shape), deny anything that looks like localhost,
and deny URLs with userinfo to foil "http://user@127.0.0.1/" bypasses.
Public IP literals are collateral blocks; users who need them (or their
own private hosts) override security.http.urls as before, mixing allow
and deny rules with the same "! " prefix, e.g.

    [security.http]
    urls = ['.*', '! ^https?://evil\.example\.com']

Fixes #14792
2026-04-22 20:15:19 +02:00
dependabot[bot] 896bc89ab8 build(deps): bump github.com/aws/aws-sdk-go-v2/service/cloudfront (#14789)
Bumps [github.com/aws/aws-sdk-go-v2/service/cloudfront](https://github.com/aws/aws-sdk-go-v2) from 1.59.0 to 1.61.1.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/service/s3/v1.59.0...service/s3/v1.61.1)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2/service/cloudfront
  dependency-version: 1.61.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 15:51:30 +02:00
dependabot[bot] 100dde53ad build(deps): bump github.com/mattn/go-isatty from 0.0.20 to 0.0.21 (#14788)
Bumps [github.com/mattn/go-isatty](https://github.com/mattn/go-isatty) from 0.0.20 to 0.0.21.
- [Commits](https://github.com/mattn/go-isatty/compare/v0.0.20...v0.0.21)

---
updated-dependencies:
- dependency-name: github.com/mattn/go-isatty
  dependency-version: 0.0.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 15:51:14 +02:00
dependabot[bot] bdebb79705 build(deps): bump github.com/bep/mclib (#14787)
Bumps [github.com/bep/mclib](https://github.com/bep/mclib) from 1.20400.20402 to 1.20401.20400.
- [Release notes](https://github.com/bep/mclib/releases)
- [Commits](https://github.com/bep/mclib/compare/v1.20400.20402...v1.20401.20400)

---
updated-dependencies:
- dependency-name: github.com/bep/mclib
  dependency-version: 1.20401.20400
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-22 15:51:00 +02:00
Bjørn Erik Pedersen a54c398b93 Harden Node tool execution with --permission flag
Add security.node.permissions config to run Node tools (PostCSS, Babel,
TailwindCSS) under Node's permission model, restricting file system access
to the working directory by default.

The binary resolution is simplified to node_modules/.bin → PATH (npx removed).
For both locations, the actual JS entry point is resolved via symlinks (macOS/Linux)
or by parsing npm wrapper scripts (Windows .cmd), then executed as
"node --permission --allow-fs-read=<path> --allow-fs-write=<path> <script>".

Users can opt out by removing "node" from security.exec.allow.

Closes #7287
2026-04-22 15:47:34 +02:00
Bjørn Erik Pedersen f5fce935e7 tpl/collections: Honor the Eqer interface in where comparisons
The where function previously fell through to a no-op when comparing
two values whose kinds were not handled by the primitive type switches
(e.g. two Page interface values). This made `where pages "Parent" $page`
return an empty list, while the equivalent `range pages` + `if eq` worked.

Use compare.Eqer for equality operators when either side implements it,
matching the behavior of the eq/ne template funcs.

Fixes #14777

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 19:45:06 +02:00
dependabot[bot] 52123ae23f build(deps): bump google.golang.org/api from 0.267.0 to 0.276.0
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.267.0 to 0.276.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.267.0...v0.276.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.276.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 19:12:24 +02:00
dependabot[bot] 38b8afdc90 build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.41.5 to 1.41.6
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.41.5 to 1.41.6.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.41.5...v1.41.6)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.41.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-20 19:12:13 +02:00
Bjørn Erik Pedersen 4169c1f70f modules: Ignore non-require blocks in go.mod rewrite
The Go 1.24 tool directive uses single-token entries inside a
tool ( ... ) block. The previous splitter treated any tab-indented
line as a require entry, causing an index out of range panic when
running hugo mod tidy on a module with a tool block.

Track the require block state explicitly so other blocks (tool,
replace, exclude, retract) are left untouched.

Fixes #14783
2026-04-20 19:11:41 +02:00
Bjørn Erik Pedersen 7574e35b40 Replace the concurrent map with an identical upstream version 2026-04-20 18:33:59 +02:00
dependabot[bot] 927666005c build(deps): bump github.com/getkin/kin-openapi from 0.134.0 to 0.135.0 (#14781)
Bumps [github.com/getkin/kin-openapi](https://github.com/getkin/kin-openapi) from 0.134.0 to 0.135.0.
- [Release notes](https://github.com/getkin/kin-openapi/releases)
- [Commits](https://github.com/getkin/kin-openapi/compare/v0.134.0...v0.135.0)

---
updated-dependencies:
- dependency-name: github.com/getkin/kin-openapi
  dependency-version: 0.135.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 17:45:53 +02:00
dependabot[bot] 790f4084a1 build(deps): bump github.com/bep/goportabletext from 0.1.0 to 0.2.0 (#14779)
Bumps [github.com/bep/goportabletext](https://github.com/bep/goportabletext) from 0.1.0 to 0.2.0.
- [Release notes](https://github.com/bep/goportabletext/releases)
- [Commits](https://github.com/bep/goportabletext/compare/v0.1.0...v0.2.0)

---
updated-dependencies:
- dependency-name: github.com/bep/goportabletext
  dependency-version: 0.2.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 17:45:36 +02:00
dependabot[bot] de6955bab3 build(deps): bump golang.org/x/image from 0.38.0 to 0.39.0 (#14780)
Bumps [golang.org/x/image](https://github.com/golang/image) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/image/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/image
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-04-20 15:19:28 +02:00
Bjørn Erik Pedersen a77bd527fd deps: Upgrade github.com/bep/imagemeta v0.17.0 => v0.17.1 (#14775)
Close #14758
2026-04-20 10:35:22 +02:00
Bjørn Erik Pedersen 017a7cd63a Add slice-based permalinks config with PageMatcher target
Closes #14744
Clses #4641

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-19 19:45:45 +02:00
Bjørn Erik Pedersen e3413d927f commands: Add missing import 2026-04-19 15:35:46 +02:00
Bjørn Erik Pedersen b01cc14703 Revert "common/hugo: Deprecate extended and extended_withdeploy editions"
This reverts commit a17bdbc5fa.

Close #14771
2026-04-19 11:15:49 +02:00
Bjørn Erik Pedersen 8ee19ff9a3 Adjust the SECURITY.md slightly 2026-04-18 23:05:27 +02:00
Joe Mooring 6436deb3e1 create: Fix non-deterministic conflict detection in hugo new content
The contentInclusionFilter used strings.Contains to match filenames
against the target path. Because strings.Contains is a substring check,
a directory entry like "content/about" matches "content/about.md",
causing unrelated files to be pulled into the mini-build. Whether the
conflict was then detected depended on whether the filesystem walker
delivered a directory entry or a full file path.

Also adds an upfront check for the directory-conflict case, since
a corrected filter alone would allow about.md to be created alongside
an existing about/ directory.

Closes #12602
Closes #12786
Closes #14112
Closes #14769

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 15:06:28 +02:00
dependabot[bot] 547ab29ced build(deps): bump golang.org/x/tools from 0.43.0 to 0.44.0
Bumps [golang.org/x/tools](https://github.com/golang/tools) from 0.43.0 to 0.44.0.
- [Release notes](https://github.com/golang/tools/releases)
- [Commits](https://github.com/golang/tools/compare/v0.43.0...v0.44.0)

---
updated-dependencies:
- dependency-name: golang.org/x/tools
  dependency-version: 0.44.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-18 15:03:47 +02:00
Joe Mooring 0d58e4286f resources/page: Add passing test for Issue #14325 2026-04-18 15:02:47 +02:00
Bjørn Erik Pedersen bbb42b5a6a agents: Add a note about having the issue ID in test names 2026-04-17 21:59:11 +02:00
Joe Mooring 1eea9fba0b commands: Fix environment isolation for configuration settings
Closes #14763

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-17 09:40:23 +02:00
dependabot[bot] 9a5c7e0d24 build(deps): bump github.com/evanw/esbuild from 0.27.4 to 0.28.0
Bumps [github.com/evanw/esbuild](https://github.com/evanw/esbuild) from 0.27.4 to 0.28.0.
- [Release notes](https://github.com/evanw/esbuild/releases)
- [Changelog](https://github.com/evanw/esbuild/blob/main/CHANGELOG.md)
- [Commits](https://github.com/evanw/esbuild/compare/v0.27.4...v0.28.0)

---
updated-dependencies:
- dependency-name: github.com/evanw/esbuild
  dependency-version: 0.28.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 19:56:49 +02:00
dependabot[bot] 6613b08eb6 build(deps): bump github.com/aws/aws-sdk-go-v2 from 1.41.1 to 1.41.5
Bumps [github.com/aws/aws-sdk-go-v2](https://github.com/aws/aws-sdk-go-v2) from 1.41.1 to 1.41.5.
- [Release notes](https://github.com/aws/aws-sdk-go-v2/releases)
- [Commits](https://github.com/aws/aws-sdk-go-v2/compare/v1.41.1...v1.41.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go-v2
  dependency-version: 1.41.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 19:55:55 +02:00
dependabot[bot] 582c26ef42 build(deps): bump github.com/pelletier/go-toml/v2 from 2.2.4 to 2.3.0
Bumps [github.com/pelletier/go-toml/v2](https://github.com/pelletier/go-toml) from 2.2.4 to 2.3.0.
- [Release notes](https://github.com/pelletier/go-toml/releases)
- [Commits](https://github.com/pelletier/go-toml/compare/v2.2.4...v2.3.0)

---
updated-dependencies:
- dependency-name: github.com/pelletier/go-toml/v2
  dependency-version: 2.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 19:55:18 +02:00
dependabot[bot] a4f2a8a579 build(deps): bump github.com/tdewolff/minify/v2 from 2.24.11 to 2.24.12
Bumps [github.com/tdewolff/minify/v2](https://github.com/tdewolff/minify) from 2.24.11 to 2.24.12.
- [Release notes](https://github.com/tdewolff/minify/releases)
- [Commits](https://github.com/tdewolff/minify/compare/v2.24.11...v2.24.12)

---
updated-dependencies:
- dependency-name: github.com/tdewolff/minify/v2
  dependency-version: 2.24.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-16 13:57:04 +02:00
Bjørn Erik Pedersen 8d6145f3c3 Fix filename dimension identifiers (_role_X_, _version_X_) to replace mount config
Filename identifiers for roles and versions were parsed but never applied
to the SitesMatrix. Now they replace the mount's configuration for that
dimension, matching how language identifiers already worked.

Fixes #14756

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 20:34:34 +02:00
Bjørn Erik Pedersen ce2a156a4e Add a more flexible filename identifier scheme that also allows setting roles and versions (#14754)
Fixes #14750
2026-04-15 16:44:19 +02:00
Bjørn Erik Pedersen 9747724222 Fix it so we never auto-fallback to page resources in other roles/versions
This is some logic that's left behind from when we had only one dimension (language) where the common case would be to have one resource set (e.g. an image) and many content translation.

After this commit:

* For sites matrix defined in the content filename (e.g. data.en.js) or in its mount definition, we may use that as a fallback for e.g. German languages if we don't find a better match.
* For content adapters, this is not relevant: Here you must be explicit about this.
* We never auto-fallback on resources from a role/version to another.
* When a page bundle spans multiple roles (e.g. via roles = "*"), we clone its resources to all roles so each gets role-specific paths.

Fixes #14749
Fixes #14752
2026-04-14 16:33:38 +02:00
Joe Mooring a17bdbc5fa common/hugo: Deprecate extended and extended_withdeploy editions
Closes #14696
2026-04-13 23:18:23 +02:00
Bjørn Erik Pedersen 8f94d65cac parser/pageparser: Add a parser fuzz test
Ran it for 40 minutes on my MacBook Pro, and it found no issues.
2026-04-09 12:25:19 +02:00
hugoreleaser d6bc8165e6 releaser: Bump versions for release of 0.160.1
[ci skip]
2026-04-08 14:02:42 +00:00
Bjørn Erik Pedersen 8b00030b34 Fix panic when passthrough elements are used in headings
Fixes #14677

Co-Authored-By: xingzihai <1315258019@qq.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 15:03:05 +02:00
Bjørn Erik Pedersen c48551677c Fix panic on edit of legacy mapped template names that's also a valid path in the new setup
This mapping was added in Hugo `v0.146.0`.

Fixes #14740
2026-04-08 13:19:45 +02:00
Bjørn Erik Pedersen 161d0d4757 Fix RenderShortcodes leaking context markers when indented
Strip leading whitespace from Hugo context marker lines before
Goldmark parsing to prevent them from being treated as indented
code blocks.

Fixes #12457

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 22:05:42 +02:00
Bjørn Erik Pedersen 45e4596630 Strip nested page context markers from standalone RenderShortcodes
Fixes #14732

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 19:27:40 +02:00
Bjørn Erik Pedersen 58927aa14a Rename deprecated cascade._target to cascade.target in tests 2026-04-07 13:58:06 +02:00
Bjørn Erik Pedersen ce009e3aa9 Fix auto-creation of root sections in multilingual sites
Fixes #14681

Co-authored-by: Joe Mooring <joe@mooring.com>
2026-04-07 13:58:06 +02:00
Christopher Hicks 0755872424 readme: Fix links
* docs: fix broken links in top README.md

* point at the docs site instead of github

* fix typo

Co-authored-by: Joe Mooring <joe@mooring.com>

---------

Co-authored-by: Joe Mooring <joe@mooring.com>
2026-04-06 08:48:45 -07:00
hugoreleaser 6b5554bac9 releaser: Prepare repository for 0.161.0-DEV
[ci skip]
2026-04-04 13:53:16 +00:00
1138 changed files with 29810 additions and 16648 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ parameters:
defaults: &defaults
resource_class: large
docker:
- image: bepsays/ci-hugoreleaser:1.22600.20100
- image: bepsays/ci-hugoreleaser:1.22700.20000
environment: &buildenv
GOMODCACHE: /root/project/gomodcache
version: 2
@@ -58,7 +58,7 @@ jobs:
environment:
<<: [*buildenv]
docker:
- image: bepsays/ci-hugoreleaser-linux-arm64:1.22600.20100
- image: bepsays/ci-hugoreleaser-linux-arm64:1.22700.20000
steps:
- *restore-cache
- &attach-workspace
-13
View File
@@ -1,13 +0,0 @@
have_fun: false
memory_config:
disabled: false
code_review:
disable: false
comment_severity_threshold: HIGH
max_review_comments: -1
pull_request_opened:
help: true
summary: false
code_review: false
include_drafts: false
ignore_patterns: []
+5 -5
View File
@@ -16,20 +16,20 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Docker meta
id: meta
uses: docker/metadata-action@318604b99e75e41977312d83839a89be02ca4893 # v5.9.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ env.REGISTRY_IMAGE }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435 # v3.11.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GHCR
# Login is only needed when the image is pushed
uses: docker/login-action@5e57cd118135c172c3672efd75eb46360885c0ef # v3.6.0
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -37,7 +37,7 @@ jobs:
- name: Build and push
id: build
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
provenance: mode=max
+2 -2
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
runs-on: ubuntu-latest
steps:
- uses: dessant/lock-threads@7de207be1d3ce97a9abe6ff1306222982d1ca9f9 # v5.0.1
- uses: dessant/lock-threads@f5f995c727ac99a91dec92781a8e34e7c839a65e # v6.0.0
with:
issue-inactive-days: 21
add-issue-labels: 'Outdated'
@@ -24,7 +24,7 @@ jobs:
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with:
operations-per-run: 999
days-before-issue-stale: 365
+17 -10
View File
@@ -16,8 +16,8 @@ jobs:
test:
strategy:
matrix:
go-version: [1.25.x, 1.26.x]
os: [ubuntu-latest, windows-latest] # macos disabled for now because of disk space issues.
go-version: [1.27.x]
os: [ubuntu-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- if: matrix.os == 'ubuntu-latest'
@@ -32,35 +32,39 @@ jobs:
docker-images: true
swap-storage: true
- name: Checkout code
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@44694675825211faa026b3c33043df3e48a5fa00 # v6.0.0
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: ${{ matrix.go-version }}
check-latest: true
cache: true
cache-dependency-path: |
**/go.sum
**/go.mod
- name: Install Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: "22"
- name: Install Ruby
uses: ruby/setup-ruby@8aeb6ff8030dd539317f8e1769a044873b56ea71 # v1.268.0
uses: ruby/setup-ruby@7372622e62b60b3cb750dcd2b9e32c247ffec26a # v1.302.0
with:
ruby-version: "3.4.5"
- name: Install Ruby gems
run: |
gem install asciidoctor -v "2.0.26"
gem install asciidoctor-diagram -v "3.1.0"
gem install asciidoctor-html5s -v "0.5.1"
- name: Install GoAT
run: go install github.com/blampe/goat/cmd/goat@177de93b192b8ffae608e5d9ec421cc99bf68402
- name: Install Python
uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c # v6.0.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.x"
- name: Install Mage
run: go install github.com/magefile/mage@v1.15.0
- name: Install gotmplfmt
run: go install github.com/gohugoio/gotmplfmt@latest
- name: Install docutils
run: |
pip install docutils
pip install docutils Pygments
rst2html --version
- if: matrix.os == 'ubuntu-latest'
name: Install pandoc on Linux
@@ -104,6 +108,9 @@ jobs:
- if: matrix.os == 'ubuntu-latest'
name: Install staticcheck
run: go install honnef.co/go/tools/cmd/staticcheck@latest
- if: matrix.os == 'ubuntu-latest'
name: Check embedded go template formatting
run: "diff <(gotmplfmt -d tpl/tplimpl/embedded/templates) <(printf '')"
- if: matrix.os == 'ubuntu-latest'
name: Run staticcheck
run: |
+3 -1
View File
@@ -4,4 +4,6 @@ imports.*
dist/
public/
.DS_Store
cache/filecache/_gen/
cache/filecache/_gen/
.claude/
dump.txt
+5
View File
@@ -1,3 +1,4 @@
* Brevity is good.
* Assume that the maintainers and readers of the code you write are Go experts:
* Don't use comments to explain the obvious.
@@ -7,8 +8,12 @@
* Never export symbols that's not needed outside of the package.
* Avoid global state at (almost) all cost.
* This is a project with a long history; assume that a similiar problem has been solved before, look hard for helper functions before creating new ones.
* In tests, almost always write end-to-end integration tests using `hugolib.Test` or one of its siblings. Write unit tests only for isolated utilities.
* In tests, use `qt` matchers (e.g. `b.Assert(err, qt.ErrorMatches, ...)`) instead of raw `if`/`t.Fatal` checks.
* In tests, always use the latest Hugo specification, e.g. for layouts, it's `layouts/page.html` and not `layouts/_default/single.html`, `layouts/list.html` and not `layouts/_default/list.html`
* Never name tests `TestIssue1234`; always give the test function a descriptive name, e.g. `TestDisablePathToLower`, and add any issue reference as a Go doc function comment, e.g. `// See issue 1234.`.
* If you borrow a test case (e.g. from the issue), that test's author must be added as co-author in the commit.
* If you're a security researcher, read @SECURITY.md carefully.
* Brevity is good. This applies to code, comments and commit messages. Don't write a novel.
* Use `./check.sh ./somepackage/...` when iterating.
* Use `./check.sh` when you're done.
+6 -4
View File
@@ -1,5 +1,3 @@
>**Note:** We would appreciate if you hold on with any big refactoring (like renaming deprecated Go packages), mainly because of potential for extra merge work for future coming in in the near future.
# Contributing to Hugo
We welcome contributions to Hugo of any kind including documentation, themes,
@@ -74,6 +72,7 @@ To make the contribution process as seamless as possible, we ask for the followi
* When youre ready to create a pull request, be sure to:
* Sign the [CLA](https://cla-assistant.io/gohugoio/hugo).
* Have test cases for the new code. If you have questions about how to do this, please ask in your pull request.
* If you borrow a test case (e.g. from the issue), that test's author must be added as [co-author](https://docs.github.com/en/pull-requests/committing-changes-to-your-project/creating-and-editing-commits/creating-a-commit-with-multiple-authors) in the commit.
* Run `go fmt`.
* Add documentation if you are adding new features or changing functionality. The docs site lives in `/docs`.
* Squash your commits into a single commit. `git rebase -i`. Its okay to force update your pull request with `git push -f`.
@@ -82,13 +81,16 @@ To make the contribution process as seamless as possible, we ask for the followi
## AI Assistance Notice
If a substantial part of your contribution is autogenerated with AI, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used.
If a substantial part of your contribution is autogenerated with AI, **this must be disclosed in the pull request**, along with the extent to which AI assistance was used. AI contributions from non-maintainers needs to have a fairly narrow scope (e.g. a bug fix), as we have limited review capacity.
An example disclosure:
> This PR was written primarily by Claude Code.
When using AI assistance, we expect contributors to understand the code that is produced and be able to answer critical questions about it. Also, AI contributions from non-maintainers needs to have a fairly narrow scope (e.g. a bug fix), as we have limited review capacity.
Also, When using AI assistance:
* We expect contributors to manually verify that the state of the pull request is OK (e.g. that the CLI is signed).
* We expect contributors to understand the code that is produced and be able to answer critical questions about it
### Git Commit Message Guidelines
+2 -2
View File
@@ -2,8 +2,8 @@
# Twitter: https://twitter.com/gohugoio
# Website: https://gohugo.io/
ARG GO_VERSION="1.26"
ARG ALPINE_VERSION="3.22"
ARG GO_VERSION="1.27"
ARG ALPINE_VERSION="3.24"
ARG DART_SASS_VERSION="1.79.3"
FROM --platform=$BUILDPLATFORM tonistiigi/xx:1.5.0 AS xx
+126 -69
View File
@@ -6,8 +6,8 @@
[bugs]: https://github.com/gohugoio/hugo/issues?q=is%3Aopen+is%3Aissue+label%3ABug
[contributing]: CONTRIBUTING.md
[create a proposal]: https://github.com/gohugoio/hugo/issues/new?labels=Proposal%2C+NeedsTriage&template=feature_request.md
[dart sass]: /functions/css/sass/#dart-sass
[details]: /host-and-deploy/deploy-with-hugo-deploy/
[dart sass]: https://gohugo.io/functions/css/sass/#dart-sass
[details]: https://gohugo.io/host-and-deploy/deploy-with-hugo-deploy/
[documentation repository]: https://github.com/gohugoio/hugoDocs
[documentation]: https://gohugo.io/documentation
[dragonfly bsd, freebsd, netbsd, and openbsd]: https://gohugo.io/installation/bsd
@@ -25,7 +25,7 @@
[static site generator]: https://en.wikipedia.org/wiki/Static_site_generator
[support]: https://discourse.gohugo.io
[themes]: https://themes.gohugo.io/
[transpile sass to css]: /functions/css/sass/
[transpile sass to css]: https://gohugo.io/functions/css/sass/
[website]: https://gohugo.io
[windows]: https://gohugo.io/installation/windows
@@ -37,7 +37,6 @@ A fast and flexible static site generator built with love by [bep][], [spf13][],
[![GoDoc](https://godoc.org/github.com/gohugoio/hugo?status.svg)](https://godoc.org/github.com/gohugoio/hugo)
[![Tests on Linux, MacOS and Windows](https://github.com/gohugoio/hugo/workflows/Test/badge.svg)](https://github.com/gohugoio/hugo/actions?query=workflow%3ATest)
[![Go Report Card](https://goreportcard.com/badge/github.com/gohugoio/hugo)](https://goreportcard.com/report/github.com/gohugoio/hugo)
[Website][] | [Installation][] | [Documentation][] | [Support][] | [Contributing][] | <a rel="me" href="https://fosstodon.org/@gohugoio">Mastodon</a>
@@ -105,7 +104,7 @@ Install Hugo from a [prebuilt binary][], package manager, or package repository.
To build Hugo from source you must install:
1. [Git][]
1. [Go][] version 1.25.0 or later
1. [Go][] version 1.26.0 or later
### Standard edition
@@ -139,10 +138,6 @@ To build and install the extended/deploy edition, first install a C compiler suc
CGO_ENABLED=1 go install -tags extended,withdeploy github.com/gohugoio/hugo@latest
```
## Star History
[![Star History Chart](https://api.star-history.com/svg?repos=gohugoio/hugo&type=Timeline)](https://star-history.com/#gohugoio/hugo&Timeline)
## Documentation
Hugo's [documentation][] includes installation instructions, a quick start guide, conceptual explanations, reference information, and examples.
@@ -195,10 +190,48 @@ Hugo stands on the shoulders of great open source libraries. Run `hugo env --log
<summary>See current dependencies</summary>
```text
cel.dev/expr="v0.25.1"
cloud.google.com/go/auth/oauth2adapt="v0.2.8"
cloud.google.com/go/auth="v0.20.0"
cloud.google.com/go/compute/metadata="v0.9.0"
cloud.google.com/go/iam="v1.5.3"
cloud.google.com/go/monitoring="v1.24.3"
cloud.google.com/go/storage="v1.57.2"
cloud.google.com/go="v0.123.0"
github.com/Azure/azure-sdk-for-go/sdk/azcore="v1.20.0"
github.com/Azure/azure-sdk-for-go/sdk/azidentity="v1.13.1"
github.com/Azure/azure-sdk-for-go/sdk/internal="v1.11.2"
github.com/Azure/azure-sdk-for-go/sdk/storage/azblob="v1.6.3"
github.com/Azure/go-autorest/autorest/to="v0.4.1"
github.com/AzureAD/microsoft-authentication-library-for-go="v1.6.0"
github.com/BurntSushi/locker="v0.0.0-20171006230638-a6e239ea1c69"
github.com/JohannesKaufmann/dom="v0.2.0"
github.com/JohannesKaufmann/html-to-markdown/v2="v2.5.0"
github.com/alecthomas/chroma/v2="v2.21.1"
github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp="v1.31.0"
github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric="v0.54.0"
github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping="v0.54.0"
github.com/JohannesKaufmann/dom="v0.3.1"
github.com/JohannesKaufmann/html-to-markdown/v2="v2.5.2"
github.com/alecthomas/chroma/v2="v2.27.0"
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream="v1.7.8"
github.com/aws/aws-sdk-go-v2/config="v1.32.2"
github.com/aws/aws-sdk-go-v2/credentials="v1.19.2"
github.com/aws/aws-sdk-go-v2/feature/ec2/imds="v1.18.14"
github.com/aws/aws-sdk-go-v2/feature/s3/manager="v1.20.12"
github.com/aws/aws-sdk-go-v2/internal/configsources="v1.4.22"
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2="v2.7.22"
github.com/aws/aws-sdk-go-v2/internal/ini="v1.8.4"
github.com/aws/aws-sdk-go-v2/internal/v4a="v1.4.22"
github.com/aws/aws-sdk-go-v2/service/cloudfront="v1.61.1"
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding="v1.13.7"
github.com/aws/aws-sdk-go-v2/service/internal/checksum="v1.9.13"
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url="v1.13.21"
github.com/aws/aws-sdk-go-v2/service/internal/s3shared="v1.19.21"
github.com/aws/aws-sdk-go-v2/service/s3="v1.97.3"
github.com/aws/aws-sdk-go-v2/service/signin="v1.0.2"
github.com/aws/aws-sdk-go-v2/service/sso="v1.30.5"
github.com/aws/aws-sdk-go-v2/service/ssooidc="v1.35.10"
github.com/aws/aws-sdk-go-v2/service/sts="v1.41.2"
github.com/aws/aws-sdk-go-v2="v1.41.6"
github.com/aws/smithy-go="v1.25.0"
github.com/aymerick/douceur="v0.2.0"
github.com/bep/clocks="v0.5.0"
github.com/bep/debounce="v1.2.0"
@@ -206,105 +239,129 @@ github.com/bep/gitmap="v1.9.0"
github.com/bep/goat="v0.5.0"
github.com/bep/godartsass/v2="v2.5.0"
github.com/bep/golibsass="v1.2.0"
github.com/bep/goportabletext="v0.1.0"
github.com/bep/helpers="v0.6.0"
github.com/bep/imagemeta="v0.12.0"
github.com/bep/lazycache="v0.8.0"
github.com/bep/golocales="v0.2.0"
github.com/bep/goportabletext="v0.2.0"
github.com/bep/helpers="v0.12.0"
github.com/bep/imagemeta="v0.17.2"
github.com/bep/lazycache="v0.8.1"
github.com/bep/logg="v0.4.0"
github.com/bep/mclib="v1.20400.20402"
github.com/bep/overlayfs="v0.10.0"
github.com/bep/simplecobra="v0.6.1"
github.com/bep/textandbinarywriter="v0.0.0-20251212174530-cd9f0732f60f"
github.com/bep/tmc="v0.5.1"
github.com/bits-and-blooms/bitset="v1.24.4"
github.com/bep/mclib="v1.20401.20400"
github.com/bep/overlayfs="v0.11.0"
github.com/bep/simplecobra="v0.7.0"
github.com/bep/textandbinarywriter="v0.1.0"
github.com/bep/tmc="v0.6.0"
github.com/bits-and-blooms/bitset="v1.24.5"
github.com/cespare/xxhash/v2="v2.3.0"
github.com/clbanning/mxj/v2="v2.7.0"
github.com/clipperhouse/displaywidth="v0.6.0"
github.com/clipperhouse/stringish="v0.1.1"
github.com/clipperhouse/uax29/v2="v2.3.0"
github.com/clipperhouse/displaywidth="v0.10.0"
github.com/clipperhouse/uax29/v2="v2.6.0"
github.com/cncf/xds/go="v0.0.0-20251210132809-ee656c7534f5"
github.com/cpuguy83/go-md2man/v2="v2.0.6"
github.com/disintegration/gift="v1.2.1"
github.com/dlclark/regexp2="v1.11.5"
github.com/evanw/esbuild="v0.27.2"
github.com/dlclark/regexp2/v2="v2.2.1"
github.com/dustin/go-humanize="v1.0.1"
github.com/envoyproxy/go-control-plane/envoy="v1.36.0"
github.com/envoyproxy/protoc-gen-validate="v1.3.0"
github.com/evanw/esbuild="v0.28.1"
github.com/fatih/color="v1.18.0"
github.com/felixge/httpsnoop="v1.0.4"
github.com/frankban/quicktest="v1.14.6"
github.com/fsnotify/fsnotify="v1.9.0"
github.com/getkin/kin-openapi="v0.133.0"
github.com/go-openapi/jsonpointer="v0.21.0"
github.com/go-openapi/swag="v0.23.0"
github.com/getkin/kin-openapi="v0.140.0"
github.com/go-jose/go-jose/v4="v4.1.4"
github.com/go-logr/logr="v1.4.3"
github.com/go-logr/stdr="v1.2.2"
github.com/go-openapi/jsonpointer="v0.22.5"
github.com/go-openapi/swag/jsonname="v0.25.5"
github.com/gobuffalo/flect="v1.0.3"
github.com/gobwas/glob="v0.2.3"
github.com/goccy/go-yaml="v1.19.1"
github.com/goccy/go-yaml="v1.19.2"
github.com/gohugoio/gift="v0.2.0"
github.com/gohugoio/go-i18n/v2="v2.1.3-0.20251018145728-cfcc22d823c6"
github.com/gohugoio/go-radix="v1.2.0"
github.com/gohugoio/hashstructure="v0.6.0"
github.com/gohugoio/httpcache="v0.8.0"
github.com/gohugoio/hugo-goldmark-extensions/extras="v0.5.0"
github.com/gohugoio/hugo-goldmark-extensions/passthrough="v0.3.1"
github.com/gohugoio/locales="v0.14.0"
github.com/gohugoio/localescompressed="v1.0.1"
github.com/gohugoio/hugo-goldmark-extensions/extras="v0.7.0"
github.com/gohugoio/hugo-goldmark-extensions/passthrough="v0.5.0"
github.com/golang-jwt/jwt/v5="v5.3.0"
github.com/google/go-cmp="v0.7.0"
github.com/google/s2a-go="v0.1.9"
github.com/google/uuid="v1.6.0"
github.com/google/wire="v0.7.0"
github.com/googleapis/enterprise-certificate-proxy="v0.3.14"
github.com/googleapis/gax-go/v2="v2.21.0"
github.com/gorilla/css="v1.0.1"
github.com/gorilla/websocket="v1.5.3"
github.com/hairyhenderson/go-codeowners="v0.7.0"
github.com/hashicorp/golang-lru/v2="v2.0.7"
github.com/jdkato/prose="v1.2.1"
github.com/josharian/intern="v1.0.0"
github.com/kr/pretty="v0.3.1"
github.com/kr/text="v0.2.0"
github.com/kylelemons/godebug="v1.1.0"
github.com/kyokomi/emoji/v2="v2.2.13"
github.com/mailru/easyjson="v0.7.7"
github.com/makeworld-the-better-one/dither/v2="v2.4.0"
github.com/marekm4/color-extractor="v1.2.1"
github.com/mattn/go-colorable="v0.1.13"
github.com/mattn/go-isatty="v0.0.20"
github.com/mattn/go-colorable="v0.1.14"
github.com/mattn/go-isatty="v0.0.22"
github.com/mattn/go-runewidth="v0.0.19"
github.com/microcosm-cc/bluemonday="v1.0.27"
github.com/mitchellh/mapstructure="v1.5.1-0.20231216201459-8508981c8b6c"
github.com/mohae/deepcopy="v0.0.0-20170929034955-c48cc78d4826"
github.com/muesli/smartcrop="v0.3.0"
github.com/niklasfasching/go-org="v1.9.1"
github.com/oasdiff/yaml3="v0.0.0-20250309153720-d2182401db90"
github.com/oasdiff/yaml="v0.0.0-20250309154309-f31be36b4037"
github.com/oasdiff/yaml3="v0.0.13"
github.com/oasdiff/yaml="v0.1.0"
github.com/olekukonko/cat="v0.0.0-20250911104152-50322a0618f6"
github.com/olekukonko/errors="v1.1.0"
github.com/olekukonko/ll="v0.1.3"
github.com/olekukonko/tablewriter="v1.1.2"
github.com/olekukonko/errors="v1.2.0"
github.com/olekukonko/ll="v0.1.6"
github.com/olekukonko/tablewriter="v1.1.4"
github.com/pbnjay/memory="v0.0.0-20210728143218-7b4eea64cf58"
github.com/pelletier/go-toml/v2="v2.2.4"
github.com/perimeterx/marshmallow="v1.1.5"
github.com/pelletier/go-toml/v2="v2.4.3"
github.com/pkg/browser="v0.0.0-20240102092130-5ac0b6a4141c"
github.com/pkg/errors="v0.9.1"
github.com/rogpeppe/go-internal="v1.14.1"
github.com/rogpeppe/go-internal="v1.15.0"
github.com/russross/blackfriday/v2="v2.1.0"
github.com/sass/dart-sass/compiler="1.97.1"
github.com/sass/dart-sass/implementation="1.97.1"
github.com/sass/dart-sass/protocol="3.2.0"
github.com/santhosh-tekuri/jsonschema/v6="v6.0.2"
github.com/spf13/afero="v1.15.0"
github.com/spf13/cast="v1.10.0"
github.com/spf13/cobra="v1.10.2"
github.com/spf13/fsync="v0.10.1"
github.com/spf13/pflag="v1.0.9"
github.com/tdewolff/minify/v2="v2.24.8"
github.com/tdewolff/parse/v2="v2.8.5"
github.com/tetratelabs/wazero="v1.10.1"
github.com/spf13/pflag="v1.0.10"
github.com/spiffe/go-spiffe/v2="v2.6.0"
github.com/tdewolff/minify/v2="v2.24.13"
github.com/tdewolff/parse/v2="v2.8.12"
github.com/tetratelabs/wazero="v1.12.0"
github.com/webmproject/libwebp="v1.6.0"
github.com/woodsbury/decimal128="v1.3.0"
github.com/yuin/goldmark-emoji="v1.0.6"
github.com/yuin/goldmark="v1.7.13"
github.com/yuin/goldmark="v1.8.2"
go.opentelemetry.io/auto/sdk="v1.2.1"
go.opentelemetry.io/contrib/detectors/gcp="v1.39.0"
go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc="v0.67.0"
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp="v0.67.0"
go.opentelemetry.io/otel/metric="v1.43.0"
go.opentelemetry.io/otel/sdk/metric="v1.43.0"
go.opentelemetry.io/otel/sdk="v1.43.0"
go.opentelemetry.io/otel/trace="v1.43.0"
go.opentelemetry.io/otel="v1.43.0"
go.uber.org/automaxprocs="v1.5.3"
go.yaml.in/yaml/v3="v3.0.4"
golang.org/x/crypto="v0.46.0"
golang.org/x/image="v0.34.0"
golang.org/x/mod="v0.31.0"
golang.org/x/net="v0.48.0"
golang.org/x/sync="v0.19.0"
golang.org/x/sys="v0.39.0"
golang.org/x/text="v0.32.0"
golang.org/x/tools="v0.40.0"
google.golang.org/protobuf="v1.36.10"
gopkg.in/yaml.v3="v3.0.1"
gocloud.dev="v0.45.0"
golang.org/x/crypto="v0.53.0"
golang.org/x/image="v0.43.0"
golang.org/x/mod="v0.37.0"
golang.org/x/net="v0.56.0"
golang.org/x/oauth2="v0.36.0"
golang.org/x/sync="v0.21.0"
golang.org/x/sys="v0.46.0"
golang.org/x/text="v0.38.0"
golang.org/x/time="v0.15.0"
golang.org/x/tools="v0.47.0"
golang.org/x/xerrors="v0.0.0-20240903120638-7835f813f4da"
google.golang.org/api="v0.276.0"
google.golang.org/genproto/googleapis/api="v0.0.0-20260319201613-d00831a3d3e7"
google.golang.org/genproto/googleapis/rpc="v0.0.0-20260401024825-9d38bb4040a9"
google.golang.org/genproto="v0.0.0-20260319201613-d00831a3d3e7"
google.golang.org/grpc="v1.80.0"
google.golang.org/protobuf="v1.36.11"
rsc.io/qr="v0.2.0"
software.sslmate.com/src/go-pkcs12="v0.2.0"
software.sslmate.com/src/go-pkcs12="v0.7.0"
```
</details>
+6 -2
View File
@@ -1,7 +1,11 @@
## Security Policy
### Before You Report
Please read [Hugo's Security Model](https://gohugo.io/about/security/) first. If the issue reproduces in an upstream project, please report it there — we cannot triage or patch on their behalf.
### Reporting a Vulnerability
Please report (suspected) security vulnerabilities to **[bjorn.erik.pedersen@gmail.com](mailto:bjorn.erik.pedersen@gmail.com)**. You will receive a response from us within 48 hours. If we can confirm the issue, we will release a patch as soon as possible depending on the complexity of the issue but historically within days.
If, after the above, you believe you have found a vulnerability in Hugo itself with a concrete, reproducible impact, report it privately to **[bjorn.erik.pedersen@gmail.com](mailto:bjorn.erik.pedersen@gmail.com)**. Include a minimal reproducer, the Hugo version, and the observed vs. expected behavior.
Also see [Hugo's Security Model](https://gohugo.io/about/security/).
You should receive an initial response within a few days. Confirmed issues are typically patched within days, depending on complexity.
+4 -13
View File
@@ -31,6 +31,7 @@ import (
"github.com/gohugoio/hugo/helpers"
"github.com/BurntSushi/locker"
"github.com/bep/helpers/maphelpers"
"github.com/spf13/afero"
)
@@ -56,8 +57,7 @@ type Cache struct {
}
type lockTracker struct {
seenMu sync.RWMutex
seen map[string]struct{}
seen *maphelpers.ConcurrentSet[string]
*locker.Locker
}
@@ -65,16 +65,7 @@ type lockTracker struct {
// Lock tracks the ids in use. We use this information to do garbage collection
// after a Hugo build.
func (l *lockTracker) Lock(id string) {
l.seenMu.RLock()
if _, seen := l.seen[id]; !seen {
l.seenMu.RUnlock()
l.seenMu.Lock()
l.seen[id] = struct{}{}
l.seenMu.Unlock()
} else {
l.seenMu.RUnlock()
}
l.seen.AddIfAbsent(id)
l.Locker.Lock(id)
}
@@ -92,7 +83,7 @@ func NewCache(fs afero.Fs, cfg FileCacheConfig) *Cache {
return &Cache{
Fs: fs,
entryLocker: &lockTracker{Locker: locker.NewLocker(), seen: make(map[string]struct{})},
entryLocker: &lockTracker{Locker: locker.NewLocker(), seen: maphelpers.NewConcurrentSet[string]()},
cfg: cfg,
}
}
+54 -14
View File
@@ -17,6 +17,7 @@ import (
"fmt"
"io"
"os"
"strings"
"github.com/gohugoio/hugo/common/herrors"
"github.com/gohugoio/hugo/hugofs"
@@ -59,6 +60,29 @@ func (c *Cache) Prune(force bool) (int, error) {
counter := 0
seen := c.entryLocker.seen
seenByLower := make(map[string]string, seen.Len())
for id := range seen.All() {
seenByLower[strings.ToLower(id)] = id
}
// Names on disk matching a used cache key except for the case, and the used keys
// actually walked. See the note about case-insensitive filesystems below.
var candidates map[string]string
visited := make(map[string]bool, seen.Len())
remove := func(name string) error {
err := c.Fs.Remove(name)
if err == nil {
counter++
return nil
}
if !herrors.IsNotExist(err) {
return err
}
return nil
}
err := afero.Walk(c.Fs, "", func(name string, info os.FileInfo, err error) error {
if info == nil {
return nil
@@ -93,28 +117,44 @@ func (c *Cache) Prune(force bool) (int, error) {
shouldRemove := force || c.isExpired(info.ModTime())
if !shouldRemove && len(c.entryLocker.seen) > 0 {
// Remove it if it's not been touched/used in the last build.
_, seen := c.entryLocker.seen[name]
shouldRemove = !seen
if seen.Has(name) {
visited[name] = true
} else if !shouldRemove && seen.Len() > 0 {
if id, found := seenByLower[strings.ToLower(name)]; found {
// On case-insensitive filesystems this is the same file as id; e.g. an
// entry created before Hugo started lowercasing the content paths in
// v0.123 (content/MyBundle => _gen/images/MyBundle). Decided once the
// walk is done: if id is walked too, they are distinct files and this
// one is stale. See issue 15101.
if candidates == nil {
candidates = make(map[string]string)
}
candidates[name] = id
} else {
// Remove it if it's not been touched/used in the last build.
shouldRemove = true
}
}
if shouldRemove {
err := c.Fs.Remove(name)
if err == nil {
counter++
}
if err != nil && !herrors.IsNotExist(err) {
return err
}
return remove(name)
}
return nil
})
if err != nil {
return counter, err
}
return counter, err
for name, id := range candidates {
if visited[id] {
if err := remove(name); err != nil {
return counter, err
}
}
}
return counter, nil
}
func (c *Cache) pruneRootDirs(force bool) (int, error) {
+121 -57
View File
@@ -16,20 +16,83 @@ package filecache_test
import (
"fmt"
"testing"
"testing/synctest"
"time"
"github.com/gohugoio/hugo/cache/filecache"
"github.com/gohugoio/hugo/htesting"
"github.com/spf13/afero"
qt "github.com/frankban/quicktest"
)
// A cache entry created before Hugo started lowercasing content paths in v0.123
// (e.g. _gen/images/MyBundle) is on a case-insensitive filesystem the same file as
// the lowercased cache key used today, and must not be pruned.
// See issue 15101.
func TestPruneCacheEntryWithOtherCase(t *testing.T) {
t.Parallel()
c := qt.New(t)
dir := t.TempDir()
if isCaseInsensitive, err := htesting.IsCaseInsensitiveFs(dir); err != nil {
t.Fatal(err)
} else if !isCaseInsensitive {
t.Skip("skip test on case-sensitive filesystem")
}
fs := afero.NewBasePathFs(afero.NewOsFs(), dir)
newCache := func() *filecache.Cache {
return filecache.NewCache(fs, filecache.FileCacheConfig{Dir: "cache", MaxAge: -1})
}
c.Assert(newCache().SetBytes("MyBundle/i1", []byte("abc")), qt.IsNil)
cache := newCache()
_, b, err := cache.GetOrCreateBytes("mybundle/i1", func() ([]byte, error) {
return []byte("def"), nil
})
c.Assert(err, qt.IsNil)
c.Assert(string(b), qt.Equals, "abc")
count, err := cache.Prune(false)
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 0)
c.Assert(cache.GetString("MyBundle/i1"), qt.Equals, "abc")
}
// On a case-sensitive filesystem the entries above are distinct files,
// and the one not used in this build should be pruned.
func TestPruneCacheEntryWithOtherCaseCaseSensitiveFs(t *testing.T) {
t.Parallel()
c := qt.New(t)
fs := afero.NewMemMapFs()
cache := filecache.NewCache(fs, filecache.FileCacheConfig{Dir: "cache", MaxAge: -1})
c.Assert(cache.SetBytes("MyBundle/i1", []byte("abc")), qt.IsNil)
cache = filecache.NewCache(fs, filecache.FileCacheConfig{Dir: "cache", MaxAge: -1})
_, b, err := cache.GetOrCreateBytes("mybundle/i1", func() ([]byte, error) {
return []byte("def"), nil
})
c.Assert(err, qt.IsNil)
c.Assert(string(b), qt.Equals, "def")
count, err := cache.Prune(false)
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 1)
c.Assert(cache.GetString("MyBundle/i1"), qt.Equals, "")
c.Assert(cache.GetString("mybundle/i1"), qt.Equals, "def")
}
func TestPrune(t *testing.T) {
t.Parallel()
c := qt.New(t)
synctest.Test(t, func(t *testing.T) {
c := qt.New(t)
configStr := `
configStr := `
resourceDir = "myresources"
contentDir = "content"
dataDir = "data"
@@ -50,63 +113,64 @@ maxAge = "200ms"
dir = ":resourceDir/_gen"
`
for _, name := range []string{filecache.CacheKeyAssets, filecache.CacheKeyImages} {
msg := qt.Commentf("cache: %s", name)
fs := afero.NewMemMapFs()
p := newPathsSpec(t, fs, configStr)
fileCachConfig := p.Cfg.GetConfigSection("caches").(filecache.Configs)
caches, err := filecache.NewCaches(fileCachConfig, fs)
c.Assert(err, qt.IsNil)
caches.SetResourceFs(fs)
cache := caches[name]
for i := range 10 {
id := fmt.Sprintf("i%d", i)
cache.GetOrCreateBytes(id, func() ([]byte, error) {
for _, name := range []string{filecache.CacheKeyAssets, filecache.CacheKeyImages} {
msg := qt.Commentf("cache: %s", name)
fs := afero.NewMemMapFs()
p := newPathsSpec(t, fs, configStr)
fileCachConfig := p.Cfg.GetConfigSection("caches").(filecache.Configs)
caches, err := filecache.NewCaches(fileCachConfig, fs)
c.Assert(err, qt.IsNil)
caches.SetResourceFs(fs)
cache := caches[name]
for i := range 10 {
id := fmt.Sprintf("i%d", i)
cache.GetOrCreateBytes(id, func() ([]byte, error) {
return []byte("abc"), nil
})
if i == 4 {
// This will expire the first 5
time.Sleep(201 * time.Millisecond)
}
}
count, err := caches.Prune()
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 5, msg)
for i := range 10 {
id := fmt.Sprintf("i%d", i)
v := cache.GetString(id)
if i < 5 {
c.Assert(v, qt.Equals, "")
} else {
c.Assert(v, qt.Equals, "abc")
}
}
caches, err = filecache.NewCaches(fileCachConfig, fs)
c.Assert(err, qt.IsNil)
caches.SetResourceFs(fs)
cache = caches[name]
// Touch one and then prune.
cache.GetOrCreateBytes("i5", func() ([]byte, error) {
return []byte("abc"), nil
})
if i == 4 {
// This will expire the first 5
time.Sleep(201 * time.Millisecond)
count, err = caches.Prune()
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 4)
// Now only the i5 should be left.
for i := range 10 {
id := fmt.Sprintf("i%d", i)
v := cache.GetString(id)
if i != 5 {
c.Assert(v, qt.Equals, "")
} else {
c.Assert(v, qt.Equals, "abc")
}
}
}
count, err := caches.Prune()
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 5, msg)
for i := range 10 {
id := fmt.Sprintf("i%d", i)
v := cache.GetString(id)
if i < 5 {
c.Assert(v, qt.Equals, "")
} else {
c.Assert(v, qt.Equals, "abc")
}
}
caches, err = filecache.NewCaches(fileCachConfig, fs)
c.Assert(err, qt.IsNil)
caches.SetResourceFs(fs)
cache = caches[name]
// Touch one and then prune.
cache.GetOrCreateBytes("i5", func() ([]byte, error) {
return []byte("abc"), nil
})
count, err = caches.Prune()
c.Assert(err, qt.IsNil)
c.Assert(count, qt.Equals, 4)
// Now only the i5 should be left.
for i := range 10 {
id := fmt.Sprintf("i%d", i)
v := cache.GetString(id)
if i != 5 {
c.Assert(v, qt.Equals, "")
} else {
c.Assert(v, qt.Equals, "abc")
}
}
}
})
}
+10 -3
View File
@@ -38,13 +38,20 @@ run_gofmt() {
# Run staticcheck
run_staticcheck() {
# Check if staticcheck is installed, install if not
if ! command -v staticcheck &> /dev/null; then
local staticcheck_bin
if command -v staticcheck &> /dev/null; then
staticcheck_bin=$(command -v staticcheck)
else
echo "==> Installing staticcheck..."
go install honnef.co/go/tools/cmd/staticcheck@latest
staticcheck_bin="$(go env GOBIN)"
if [ -z "$staticcheck_bin" ]; then
staticcheck_bin="$(go env GOPATH)/bin"
fi
staticcheck_bin="$staticcheck_bin/staticcheck"
fi
echo "==> Running staticcheck..."
staticcheck $PACKAGES
"$staticcheck_bin" $PACKAGES
echo " OK"
}
+1 -3
View File
@@ -103,9 +103,7 @@ func (c *Inspector) MethodsFromTypes(include []reflect.Type, exclude []reflect.T
}
for _, t := range include {
for i := range t.NumMethod() {
m := t.Method(i)
for m := range t.Methods() {
if excludes[m.Name] || seen[m.Name] {
continue
}
+20 -1
View File
@@ -39,6 +39,7 @@ import (
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/htime"
"github.com/gohugoio/hugo/common/hugo"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/paths"
"github.com/gohugoio/hugo/common/types"
@@ -152,6 +153,23 @@ type rootCommand struct {
cfgDir string
}
// resolveEnvironment sets r.environment if not already set.
// server indicates whether the server command is running (defaults to development).
func (r *rootCommand) resolveEnvironment(server bool) {
if r.environment != "" {
return
}
if env := os.Getenv("HUGO_ENVIRONMENT"); env != "" {
r.environment = env
} else if env := os.Getenv("HUGO_ENV"); env != "" {
r.environment = env
} else if server {
r.environment = hugo.EnvironmentDevelopment
} else {
r.environment = hugo.EnvironmentProduction
}
}
func (r *rootCommand) isVerbose() bool {
return r.logger.Level() <= logg.LevelInfo
}
@@ -223,6 +241,7 @@ func (r *rootCommand) ConfigFromProvider(key configKey, cfg config.Provider) (*c
if cfg == nil {
panic("cfg must be set")
}
r.resolveEnvironment(false)
cc, _, err := r.commonConfigs.GetOrCreate(key, func(key configKey) (*commonConfig, error) {
var dir string
if r.source != "" {
@@ -594,7 +613,7 @@ func applyLocalFlagsBuild(cmd *cobra.Command, r *rootCommand) {
cmd.Flags().BoolP("buildDrafts", "D", false, "include content marked as draft")
cmd.Flags().BoolP("buildFuture", "F", false, "include content with publishdate in the future")
cmd.Flags().BoolP("buildExpired", "E", false, "include expired content")
cmd.Flags().BoolP("ignoreCache", "", false, "ignores the cache directory")
cmd.Flags().BoolP("ignoreCache", "", false, "ignore the configured file caches")
cmd.Flags().Bool("enableGitInfo", false, "add Git revision, date, author, and CODEOWNERS info to the pages")
cmd.Flags().StringP("layoutDir", "l", "", "filesystem path to layout directory")
_ = cmd.MarkFlagDirname("layoutDir")
+1 -1
View File
@@ -116,7 +116,7 @@ func (c *configCommand) Init(cd *simplecobra.Commandeer) error {
cmd.Long = `Display project configuration, both default and custom settings.`
cmd.Flags().StringVar(&c.format, "format", "toml", "preferred file format (toml, yaml or json)")
_ = cmd.RegisterFlagCompletionFunc("format", cobra.FixedCompletions([]string{"toml", "yaml", "json"}, cobra.ShellCompDirectiveNoFileComp))
cmd.Flags().StringVar(&c.lang, "lang", "", "the language to display config for. Defaults to the first language defined.")
cmd.Flags().StringVar(&c.lang, "lang", "", "the language to display config for (default is the default content language)")
cmd.Flags().BoolVar(&c.printZero, "printZero", false, `include config options with zero values (e.g. false, 0, "") in the output`)
_ = cmd.RegisterFlagCompletionFunc("lang", cobra.NoFileCompletions)
applyLocalFlagsBuildConfig(cmd, c.r)
+31 -3
View File
@@ -263,11 +263,39 @@ func (c *convertCommand) convertContents(format metadecoders.Format) error {
site := c.h.Sites[0]
var pagesBackedByFile page.Pages
for _, p := range site.AllPages() {
workingDir := c.h.Sites[0].Deps.Conf.WorkingDir() + string(filepath.Separator)
isConvertible := func(p page.Page) bool {
// Skip pages not backed by a content file.
if p.File() == nil {
return false
}
// Skip content adapters.
if p.File().IsContentAdapter() {
return false
}
// Skip content files provided by modules, including vendored modules.
if !p.File().FileInfo().Meta().IsProject {
return false
}
// Skip content files in project mounts outside the working directory.
if !strings.HasPrefix(p.File().Filename(), workingDir) {
return false
}
return true
}
seen := make(map[string]bool)
var pagesBackedByFile page.Pages
for _, p := range c.h.Pages() {
if !isConvertible(p) {
continue
}
filename := p.File().Filename()
if seen[filename] {
continue
}
seen[filename] = true
pagesBackedByFile = append(pagesBackedByFile, p)
}
@@ -278,7 +306,7 @@ func (c *convertCommand) convertContents(format metadecoders.Format) error {
}
site.Log.Println("processing", len(pagesBackedByFile), "content files")
for _, p := range site.AllPages() {
for _, p := range pagesBackedByFile {
if err := c.convertAndSavePage(p, site, format); err != nil {
return err
}
+28 -29
View File
@@ -21,12 +21,8 @@ import (
"os"
"path"
"path/filepath"
"slices"
"strings"
"github.com/alecthomas/chroma/v2"
"github.com/alecthomas/chroma/v2/formatters/html"
"github.com/alecthomas/chroma/v2/styles"
"github.com/bep/simplecobra"
"github.com/goccy/go-yaml"
"github.com/gohugoio/hugo/common/hugo"
@@ -34,6 +30,7 @@ import (
"github.com/gohugoio/hugo/helpers"
"github.com/gohugoio/hugo/hugofs"
"github.com/gohugoio/hugo/hugolib"
"github.com/gohugoio/hugo/markup/highlight"
"github.com/gohugoio/hugo/parser"
"github.com/spf13/cobra"
"github.com/spf13/cobra/doc"
@@ -47,6 +44,10 @@ func newGenCommand() *genCommand {
// Chroma flags.
style string
mode string
modeSelector bool
classDark string
classLight string
highlightStyle string
lineNumbersInlineStyle string
lineNumbersTableStyle string
@@ -63,43 +64,41 @@ func newGenCommand() *genCommand {
See https://gohugo.io/quick-reference/syntax-highlighting-styles/ for a preview of the available styles.`,
run: func(ctx context.Context, cd *simplecobra.Commandeer, r *rootCommand, args []string) error {
style = strings.ToLower(style)
if !slices.Contains(styles.Names(), style) {
return fmt.Errorf("invalid style: %s", style)
}
builder := styles.Get(style).Builder()
if highlightStyle != "" {
builder.Add(chroma.LineHighlight, highlightStyle)
}
if lineNumbersInlineStyle != "" {
builder.Add(chroma.LineNumbers, lineNumbersInlineStyle)
}
if lineNumbersTableStyle != "" {
builder.Add(chroma.LineNumbersTable, lineNumbersTableStyle)
}
style, err := builder.Build()
if err != nil {
return err
}
if omitEmpty {
// See https://github.com/alecthomas/chroma/commit/5b2a4c5a26c503c79bc86ba3c4ae5b330028bd3d
hugo.Deprecate("--omitEmpty", "Flag is no longer needed, empty classes are now always omitted.", "v0.149.0")
}
options := []html.Option{
html.WithCSSComments(!omitClassComments),
css, err := highlight.ChromaStylesCSS(highlight.ChromaStylesOptions{
Style: style,
Mode: mode,
ModeSelector: modeSelector,
ClassDark: classDark,
ClassLight: classLight,
HighlightStyle: highlightStyle,
LineNumbersInlineStyle: lineNumbersInlineStyle,
LineNumbersTableStyle: lineNumbersTableStyle,
OmitClassComments: omitClassComments,
})
if err != nil {
return err
}
formatter := html.New(options...)
w := os.Stdout
fmt.Fprintf(w, "/* Generated using: hugo %s */\n\n", strings.Join(os.Args[1:], " "))
formatter.WriteCSS(w, style)
fmt.Printf("/* Generated using: hugo %s */\n\n", strings.Join(os.Args[1:], " "))
fmt.Print(css)
return nil
},
withc: func(cmd *cobra.Command, r *rootCommand) {
cmd.ValidArgsFunction = cobra.NoFileCompletions
cmd.PersistentFlags().StringVar(&style, "style", "friendly", "highlighter style")
_ = cmd.RegisterFlagCompletionFunc("style", cobra.NoFileCompletions)
cmd.PersistentFlags().StringVar(&mode, "mode", "", `style mode ("light", "dark")`)
_ = cmd.RegisterFlagCompletionFunc("mode", cobra.FixedCompletions([]string{"light", "dark"}, cobra.ShellCompDirectiveNoFileComp))
cmd.PersistentFlags().BoolVar(&modeSelector, "modeSelector", false, `scope selectors under a top level mode class, e.g. ".dark .chroma"`)
_ = cmd.RegisterFlagCompletionFunc("modeSelector", cobra.NoFileCompletions)
cmd.PersistentFlags().StringVar(&classDark, "classDark", "dark", `class name used by --modeSelector for dark styles`)
_ = cmd.RegisterFlagCompletionFunc("classDark", cobra.NoFileCompletions)
cmd.PersistentFlags().StringVar(&classLight, "classLight", "light", `class name used by --modeSelector for light styles`)
_ = cmd.RegisterFlagCompletionFunc("classLight", cobra.NoFileCompletions)
cmd.PersistentFlags().StringVar(&highlightStyle, "highlightStyle", "", `foreground and background colors for highlighted lines, e.g. --highlightStyle "#fff000 bg:#000fff"`)
_ = cmd.RegisterFlagCompletionFunc("highlightStyle", cobra.NoFileCompletions)
cmd.PersistentFlags().StringVar(&lineNumbersInlineStyle, "lineNumbersInlineStyle", "", `foreground and background colors for inline line numbers, e.g. --lineNumbersInlineStyle "#fff000 bg:#000fff"`)
+4 -18
View File
@@ -367,7 +367,7 @@ func (c *hugoBuilder) newWatcher(pollIntervalStr string, dirList ...string) (*wa
case changes := <-c.r.changesFromBuild:
unlock, err := h.LockBuild()
if err != nil {
c.r.logger.Errorln("Failed to acquire a build lock: %s", err)
c.r.logger.Errorf("Failed to acquire a build lock: %s", err)
return
}
c.changeDetector.PrepareNew()
@@ -387,7 +387,7 @@ func (c *hugoBuilder) newWatcher(pollIntervalStr string, dirList ...string) (*wa
case evs := <-watcher.Events:
unlock, err := h.LockBuild()
if err != nil {
c.r.logger.Errorln("Failed to acquire a build lock: %s", err)
c.r.logger.Errorf("Failed to acquire a build lock: %s", err)
return
}
c.handleEvents(watcher, staticSyncer, evs, configSet)
@@ -1082,22 +1082,8 @@ func (c *hugoBuilder) loadConfig(cd *simplecobra.Commandeer, running bool) error
cfg := config.New()
cfg.Set("renderToMemory", c.r.renderToMemory)
watch := c.r.buildWatch || (c.s != nil && c.s.serverWatch)
if c.r.environment == "" {
// We need to set the environment as early as possible because we need it to load the correct config.
// Check if the user has set it in env.
if env := os.Getenv("HUGO_ENVIRONMENT"); env != "" {
c.r.environment = env
} else if env := os.Getenv("HUGO_ENV"); env != "" {
c.r.environment = env
} else {
if c.s != nil {
// The server defaults to development.
c.r.environment = hugo.EnvironmentDevelopment
} else {
c.r.environment = hugo.EnvironmentProduction
}
}
}
// We need to set the environment as early as possible because we need it to load the correct config.
c.r.resolveEnvironment(c.s != nil)
cfg.Set("environment", c.r.environment)
cfg.Set("internal", hmaps.Params{
+32 -25
View File
@@ -466,41 +466,48 @@ func (c *importCommand) importFromJekyll(args []string) error {
c.r.Println("Now, start Hugo by yourself:")
c.r.Println("cd " + args[1])
c.r.Println("git init")
c.r.Println("git submodule add https://github.com/theNewDynamic/gohugo-theme-ananke themes/ananke")
c.r.Println("echo \"theme = 'ananke'\" > hugo.toml")
c.r.Println("git submodule add https://github.com/gohugo-ananke/ananke themes/ananke")
c.r.Println("echo \"theme: ananke\" >> hugo.yaml")
c.r.Println("hugo server")
return nil
}
func (c *importCommand) loadJekyllConfig(fs afero.Fs, jekyllRoot string) map[string]any {
path := filepath.Join(jekyllRoot, "_config.yml")
for _, candidate := range []struct {
filename string
format metadecoders.Format
}{
{"_config.yml", metadecoders.YAML},
{"_config.yaml", metadecoders.YAML},
{"_config.toml", metadecoders.TOML},
} {
path := filepath.Join(jekyllRoot, candidate.filename)
exists, err := helpers.Exists(path, fs)
if err != nil || !exists {
continue
}
exists, err := helpers.Exists(path, fs)
f, err := fs.Open(path)
if err != nil {
continue
}
b, err := io.ReadAll(f)
f.Close()
if err != nil {
continue
}
if err != nil || !exists {
c.r.Println("_config.yaml not found: Is the specified Jekyll root correct?")
return nil
m, err := metadecoders.Default.UnmarshalToMap(b, candidate.format)
if err != nil {
continue
}
return m
}
f, err := fs.Open(path)
if err != nil {
return nil
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
return nil
}
m, err := metadecoders.Default.UnmarshalToMap(b, metadecoders.YAML)
if err != nil {
return nil
}
return m
c.r.Println("no config file (_config.yml, _config.yaml, or _config.toml) found: is the specified Jekyll root correct?")
return nil
}
func (c *importCommand) parseJekyllFilename(filename string) (time.Time, string, error) {
+2 -2
View File
@@ -549,7 +549,7 @@ of a second, you will be able to save and see your changes nearly instantly.`
cmd.Flags().BoolVar(&c.tlsAuto, "tlsAuto", false, "generate and use locally-trusted certificates.")
cmd.Flags().BoolVar(&c.pprof, "pprof", false, "enable the pprof server (port 8080)")
cmd.Flags().BoolVarP(&c.serverWatch, "watch", "w", true, "watch filesystem for changes and recreate as needed")
cmd.Flags().BoolVar(&c.noHTTPCache, "noHTTPCache", false, "prevent HTTP caching")
cmd.Flags().BoolVar(&c.noHTTPCache, "noHTTPCache", false, "disable browser caching of pages served by the embedded web server")
cmd.Flags().BoolVarP(&c.serverAppend, "appendPort", "", true, "append port to baseURL")
cmd.Flags().BoolVar(&c.disableLiveReload, "disableLiveReload", false, "watch without enabling live browser reload on rebuild")
cmd.Flags().BoolVarP(&c.navigateToChanged, "navigateToChanged", "N", false, "navigate to changed content file on live browser reload")
@@ -1108,7 +1108,7 @@ func (s *staticSyncer) syncsStaticEvents(staticEvents []fsnotify.Event) error {
fromPath := ev.Name
relPath, found := sourceFs.MakePathRelative(fromPath, true)
relPath, found := sourceFs.MakePathRelative(fromPath, false)
if !found {
// Not member of this virtual host.
+3 -3
View File
@@ -81,9 +81,9 @@ func (s *StackThreadSafe[T]) DrainMatching(predicate func(T) bool) []T {
s.mu.Lock()
defer s.mu.Unlock()
var items []T
for i := len(s.items) - 1; i >= 0; i-- {
if predicate(s.items[i]) {
items = append(items, s.items[i])
for i, v := range slices.Backward(s.items) {
if predicate(v) {
items = append(items, v)
s.items = slices.Delete(s.items, i, i+1)
}
}
+4 -9
View File
@@ -16,15 +16,10 @@ package constants
// Error/Warning IDs.
// Do not change these values.
const (
// IDs for remote errors in tpl/data.
ErrRemoteGetJSON = "error-remote-getjson"
ErrRemoteGetCSV = "error-remote-getcsv"
WarnFrontMatterParamsOverrides = "warning-frontmatter-params-overrides"
WarnRenderShortcodesInHTML = "warning-rendershortcodes-in-html"
WarnGoldmarkRawHTML = "warning-goldmark-raw-html"
WarnPartialSuperfluousPrefix = "warning-partial-superfluous-prefix"
WarnHomePageIsLeafBundle = "warning-home-page-is-leaf-bundle"
WarnRenderShortcodesInHTML = "warning-rendershortcodes-in-html"
WarnGoldmarkRawHTML = "warning-goldmark-raw-html"
WarnPartialSuperfluousPrefix = "warning-partial-superfluous-prefix"
WarnHomePageIsLeafBundle = "warning-home-page-is-leaf-bundle"
)
// Field/method names with special meaning.
+41 -21
View File
@@ -18,6 +18,7 @@ import (
"crypto/md5"
"encoding/hex"
"io"
"reflect"
"strconv"
"sync"
@@ -105,6 +106,16 @@ func MD5FromStringHexEncoded(f string) string {
return hex.EncodeToString(h.Sum(nil))
}
// MD5FromReaderHexEncoded returns the MD5 hash of the given reader.
func MD5FromReaderHexEncoded(r io.Reader) string {
h := md5.New()
_, err := io.Copy(h, r)
if err != nil {
return ""
}
return hex.EncodeToString(h.Sum(nil))
}
// HashString returns a hash from the given elements.
// It will panic if the hash cannot be calculated.
// Note that this hash should be used primarily for identity, not for change detection as
@@ -124,11 +135,38 @@ func HashStringHex(vs ...any) string {
var hashOptsPool = sync.Pool{
New: func() any {
return &hashstructure.HashOptions{
Hasher: xxhash.New(),
Hasher: xxhash.New(),
UnwrapFunc: unwrapForHashing,
}
},
}
// hashstructure only sees exported struct fields, so rewrite known identity types before hashing,
// e.g. a Resource or Page nested in an options map hashes by its Key.
func unwrapForHashing(v reflect.Value) (reflect.Value, error) {
if v.Kind() != reflect.Struct {
return v, nil
}
var in any
if v.CanAddr() {
// The common case; pointer receiver methods on a struct
// reached through a pointer.
in = v.Addr().Interface()
} else {
in = v.Interface()
}
switch t := in.(type) {
case hashstructure.Hashable:
// Let hashstructure handle it.
return v, nil
case keyer:
return reflect.ValueOf(t.Key()), nil
case identity.IdentityProvider:
return reflect.ValueOf(t.GetIdentity()), nil
}
return v, nil
}
func getHashOpts() *hashstructure.HashOptions {
return hashOptsPool.Get().(*hashstructure.HashOptions)
}
@@ -145,15 +183,10 @@ func putHashOpts(opts *hashstructure.HashOptions) {
func HashUint64(vs ...any) uint64 {
var o any
if len(vs) == 1 {
o = toHashable(vs[0])
o = vs[0]
} else {
elements := make([]any, len(vs))
for i, e := range vs {
elements[i] = toHashable(e)
}
o = elements
o = vs
}
hash, err := Hash(o)
if err != nil {
panic(err)
@@ -176,19 +209,6 @@ type keyer interface {
Key() string
}
// For structs, hashstructure.Hash only works on the exported fields,
// so rewrite the input slice for known identity types.
func toHashable(v any) any {
switch t := v.(type) {
case keyer:
return t.Key()
case identity.IdentityProvider:
return t.GetIdentity()
default:
return v
}
}
type xxhashReadFrom struct {
buff []byte
*xxhash.Digest
+11
View File
@@ -178,3 +178,14 @@ func improveIfNilPointerMsg(inErr error) string {
s := fmt.Sprintf(" %s is nil; wrap it in if or with: {{ with %s }}{{ .%s }}{{ end }}", receiverName, receiver, field)
return nilPointerErrRe.ReplaceAllString(inErr.Error(), s)
}
// Or returns the first non-nil error from the given list of errors.
// If all errors are nil, it returns nil.
func Or(errs ...error) error {
for _, err := range errs {
if err != nil {
return err
}
}
return nil
}
+30
View File
@@ -0,0 +1,30 @@
// Copyright 2026 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 hexec
import (
_ "embed"
"encoding/base64"
"sync"
)
//go:embed esmloader.mjs
var esmLoaderSource string
// nodeESMLoaderImportArg returns a "--import=data:..." argument that installs
// a Node.js ESM resolver hook making NODE_PATH a fallback for failed bare
// imports. See esmloader.mjs for the rationale.
var nodeESMLoaderImportArg = sync.OnceValue(func() string {
return "--import=data:text/javascript;base64," + base64.StdEncoding.EncodeToString([]byte(esmLoaderSource))
})
+61
View File
@@ -0,0 +1,61 @@
// Node.js ESM resolver hook installed by Hugo.
//
// Node's ESM resolver does not consult NODE_PATH, unlike CJS require().
// That breaks postcss.config.js / babel.config.js / etc. files written in
// ESM and loaded from outside the project tree (typically the Hugo module
// cache): bare imports like `import x from "postcss-import"` cannot be
// resolved by walking up from the file's location.
//
// This hook makes the ESM resolver fall back to NODE_PATH for bare
// specifiers when Node's normal resolution fails. It is a no-op for
// relative/absolute paths and URL-scheme specifiers, and it never fires
// unless Node would itself have thrown ERR_MODULE_NOT_FOUND or
// ERR_ACCESS_DENIED.
//
// ERR_ACCESS_DENIED is handled because Node's resolver walks up the
// directory tree looking for node_modules. Under the permission model that
// walk can hit a node_modules outside the allow-list (e.g. Netlify stores
// its node_modules cache in the same tree as the Hugo file cache), aborting
// resolution even though the package is reachable via NODE_PATH. If the
// NODE_PATH fallback also fails we re-throw the original error so the
// access-denied resource is still reported.
//
// Uses the synchronous registerHooks API so it runs on the main thread and
// does not require --allow-worker under the Node permission model.
import { registerHooks, createRequire } from 'node:module';
import { pathToFileURL } from 'node:url';
const resolvers = [];
const np = process.env.NODE_PATH;
if (np) {
const sep = process.platform === 'win32' ? ';' : ':';
for (const p of np.split(sep)) {
if (p) resolvers.push(createRequire(p + '/_'));
}
}
function isBareSpecifier(s) {
if (!s) return false;
if (s.startsWith('.') || s.startsWith('/') || s.startsWith('#')) return false;
if (/^[a-z][a-z0-9+.-]*:/i.test(s)) return false;
return true;
}
registerHooks({
resolve(specifier, context, nextResolve) {
try {
return nextResolve(specifier, context);
} catch (err) {
if (err?.code !== 'ERR_MODULE_NOT_FOUND' && err?.code !== 'ERR_ACCESS_DENIED') throw err;
if (!isBareSpecifier(specifier)) throw err;
for (const r of resolvers) {
try {
const resolved = r.resolve(specifier);
return { url: pathToFileURL(resolved).href, shortCircuit: true, format: null };
} catch (_) { /* try next */ }
}
throw err;
}
},
});
+247 -79
View File
@@ -1,4 +1,4 @@
// Copyright 2020 The Hugo Authors. All rights reserved.
// Copyright 2026 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.
@@ -14,6 +14,7 @@
package hexec
import (
"bufio"
"bytes"
"context"
"errors"
@@ -23,8 +24,8 @@ import (
"os/exec"
"path/filepath"
"regexp"
"slices"
"strings"
"sync"
"github.com/bep/logg"
"github.com/gohugoio/hugo/common/hmaps"
@@ -98,11 +99,11 @@ func New(cfg security.Config, workingDir string, log loggers.Logger) *Exec {
}
return &Exec{
sc: cfg,
workingDir: workingDir,
infol: log.InfoCommand("exec"),
baseEnviron: baseEnviron,
newNPXRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
sc: cfg,
workingDir: workingDir,
infol: log.InfoCommand("exec"),
baseEnviron: baseEnviron,
nodeRunnerCache: hmaps.NewCache[string, func(arg ...any) (Runner, error)](),
}
}
@@ -121,9 +122,16 @@ type Exec struct {
// os.Environ filtered by the Exec.OsEnviron whitelist filter.
baseEnviron []string
newNPXRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)]
npxInit sync.Once
npxAvailable bool
// Additional absolute paths to allow reading from in the Node.js permission model.
nodeReadPaths []string
nodeRunnerCache *hmaps.Cache[string, func(arg ...any) (Runner, error)]
}
// SetNodeReadPaths sets additional absolute paths to allow reading from
// in the Node.js permission model (e.g. Hugo module cache directories).
func (e *Exec) SetNodeReadPaths(paths []string) {
e.nodeReadPaths = paths
}
func (e *Exec) New(name string, arg ...any) (Runner, error) {
@@ -155,8 +163,6 @@ func (b binaryLocation) String() string {
switch b {
case binaryLocationNodeModules:
return "node_modules/.bin"
case binaryLocationNpx:
return "npx"
case binaryLocationPath:
return "PATH"
}
@@ -165,64 +171,55 @@ func (b binaryLocation) String() string {
const (
binaryLocationNodeModules binaryLocation = iota + 1
binaryLocationNpx
binaryLocationPath
)
// Npx will in order:
// 1. Try fo find the binary in the WORKINGDIR/node_modules/.bin directory.
// 2. If not found, and npx is available, run npx --no-install <name> <args>.
// 3. Fall back to the PATH.
// If name is "tailwindcss", we will try the PATH as the second option.
// Npx finds and runs a Node.js tool. The binary is located first in
// WORKINGDIR/node_modules/.bin, then in PATH. The tool is always invoked via
// "node [--permission <flags>] <script> <args>"; the --permission flags are
// added when the Node.js permission model is enabled.
func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
if err := e.sc.CheckAllowedExec(name); err != nil {
return nil, err
}
if err := e.sc.CheckAllowedExec("node"); err != nil {
// Legacy path: We replaced npx with node in v0.161.0, and anyone using these tools with a custom security.exec.allow list
// would get an error when upgrading. To avoid this, check for npx as well.
if err2 := e.sc.CheckAllowedExec("npx"); err2 != nil {
return nil, err
}
}
newRunner, err := e.newNPXRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
type tryFunc func() func(...any) (Runner, error)
tryFuncs := map[binaryLocation]tryFunc{
binaryLocationNodeModules: func() func(...any) (Runner, error) {
nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
_, err := exec.LookPath(nodeBinFilename)
if err != nil {
return nil
}
return func(arg2 ...any) (Runner, error) {
return e.new(name, nodeBinFilename, arg2...)
}
},
binaryLocationNpx: func() func(...any) (Runner, error) {
e.checkNpx()
if !e.npxAvailable {
return nil
}
return func(arg2 ...any) (Runner, error) {
return e.npx(name, arg2...)
}
},
binaryLocationPath: func() func(...any) (Runner, error) {
if _, err := exec.LookPath(name); err != nil {
return nil
}
return func(arg2 ...any) (Runner, error) {
return e.New(name, arg2...)
}
},
newRunner, err := e.nodeRunnerCache.GetOrCreate(name, func() (func(...any) (Runner, error), error) {
var resolvedBin string
var loc binaryLocation
nodeBinFilename := filepath.Join(e.workingDir, nodeModulesBinPath, name)
if p, err := exec.LookPath(nodeBinFilename); err == nil {
resolvedBin = p
loc = binaryLocationNodeModules
} else if p, err := exec.LookPath(name); err == nil {
resolvedBin = p
loc = binaryLocationPath
} else {
return nil, &NotFoundError{name: name, method: "in PATH"}
}
locations := []binaryLocation{binaryLocationNodeModules, binaryLocationNpx, binaryLocationPath}
if name == "tailwindcss" {
// See https://github.com/gohugoio/hugo/issues/13221#issuecomment-2574801253
locations = []binaryLocation{binaryLocationNodeModules, binaryLocationPath, binaryLocationNpx}
scriptPath := resolveNodeBin(resolvedBin)
e.infol.WithFields(logg.Fields{
logg.Field{Name: "location", Value: loc},
logg.Field{Name: "bin", Value: resolvedBin},
logg.Field{Name: "script", Value: scriptPath},
}).Logf("resolve %q", name)
if scriptPath == "" {
return nil, fmt.Errorf("binary %q is not a Node.js script", name)
}
for _, loc := range locations {
if f := tryFuncs[loc](); f != nil {
e.infol.Logf("resolve %q using %s", name, loc)
return f, nil
}
}
return nil, &NotFoundError{name: name, method: fmt.Sprintf("in %s", locations[len(locations)-1])}
return func(arg2 ...any) (Runner, error) {
return e.newNode(name, scriptPath, arg2...)
}, nil
})
if err != nil {
return nil, err
@@ -231,22 +228,199 @@ func (e *Exec) Npx(name string, arg ...any) (Runner, error) {
return newRunner(arg...)
}
const (
npxNoInstall = "--no-install"
npxBinary = "npx"
nodeModulesBinPath = "node_modules/.bin"
)
// newNode runs a Node.js script via "node [--permission <flags>] <scriptPath> <args>".
func (e *Exec) newNode(name, scriptPath string, arg ...any) (Runner, error) {
var allArgs []any
for _, pa := range e.nodePermissionArgs(name, scriptPath) {
allArgs = append(allArgs, pa)
}
// Install an ESM resolver hook that makes NODE_PATH a fallback for failed
// bare imports, so postcss.config.js / babel.config.js / etc. written in
// ESM work when loaded from the Hugo module cache. See esmloader.mjs.
allArgs = append(allArgs, nodeESMLoaderImportArg())
allArgs = append(allArgs, scriptPath)
allArgs = append(allArgs, arg...)
// When the script lives outside the working dir (a globally installed
// tool), point NODE_PATH at the script's node_modules ancestor so Node's
// resolver (and tools that honor it, e.g. tailwindcss v4) can locate the
// tool's sibling packages. tailwindcss v4's CSS resolver treats NODE_PATH
// as a single path, not a list, so we don't concatenate with the local
// path here. For local installs the caller's NODE_PATH (set by
// hugo.GetExecEnviron to <workDir>/node_modules) already covers the need.
localNM := filepath.Join(e.workingDir, "node_modules")
if p := nodeScriptReadPath(scriptPath); p != "" && p != localNM {
allArgs = append(allArgs, WithEnviron([]string{"NODE_PATH=" + p}))
}
func (e *Exec) checkNpx() {
e.npxInit.Do(func() {
e.npxAvailable = InPath(npxBinary)
})
return e.New("node", allArgs...)
}
// npx is a convenience method to create a Runner running npx --no-install <name> <args.
func (e *Exec) npx(name string, arg ...any) (Runner, error) {
arg = append(arg[:0], append([]any{npxNoInstall, name}, arg[0:]...)...)
return e.New(npxBinary, arg...)
// nodePermissionArgs builds the Node.js --permission flags from the security config.
func (e *Exec) nodePermissionArgs(name, scriptPath string) []string {
perms := e.sc.Node.Permissions
if !perms.IsEnabled() {
return nil
}
args := []string{"--permission"}
for _, p := range e.resolveNodePermPaths(perms.AllowRead) {
args = append(args, "--allow-fs-read="+p)
}
for _, p := range e.nodeReadPaths {
args = append(args, "--allow-fs-read="+p)
}
if p := nodeScriptReadPath(scriptPath); p != "" {
args = append(args, "--allow-fs-read="+p)
}
for _, p := range e.resolveNodePermPaths(perms.AllowWrite) {
args = append(args, "--allow-fs-write="+p)
}
var silenceSecurityWarnings bool
if slices.Contains(perms.AllowAddons, name) {
silenceSecurityWarnings = true
args = append(args, "--allow-addons")
}
if slices.Contains(perms.AllowWorker, name) {
silenceSecurityWarnings = true
args = append(args, "--allow-worker")
}
if slices.Contains(perms.AllowChildProcess, name) {
silenceSecurityWarnings = true
args = append(args, "--allow-child-process")
}
if silenceSecurityWarnings {
// There are no more fine grained way to do this, see https://github.com/nodejs/node/issues/59818
// If the process is configured to allow either workers or addons, Node will print warnings that's not very helpful.
args = append(args, "--disable-warning=SecurityWarning")
}
return args
}
// resolveNodePermPaths resolves relative paths against the working directory.
func (e *Exec) resolveNodePermPaths(paths []string) []string {
resolved := make([]string, len(paths))
for i, p := range paths {
switch {
case p == "*":
resolved[i] = "*"
case filepath.IsAbs(p):
resolved[i] = p
default:
resolved[i] = filepath.Join(e.workingDir, p)
}
}
return resolved
}
const nodeModulesBinPath = "node_modules/.bin"
// nodeScriptReadPath returns a path to add to the Node.js read allow-list so
// a script can load its dependencies. For scripts inside a node_modules tree
// it returns the nearest ancestor "node_modules" directory, so both nested
// and hoisted deps are reachable. Otherwise the script's own directory.
func nodeScriptReadPath(scriptPath string) string {
if scriptPath == "" {
return ""
}
dir := filepath.Dir(scriptPath)
for {
if filepath.Base(dir) == "node_modules" {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
return filepath.Dir(scriptPath)
}
dir = parent
}
}
// resolveNodeBin resolves a binary path to the underlying Node.js script.
// Returns the path to the JS entry point, or "" if the binary is not a Node script.
func resolveNodeBin(path string) string {
// 1. If the file is a symlink, resolve it (macOS/Linux npm creates symlinks in node_modules/.bin).
if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 {
if resolved, err := filepath.EvalSymlinks(path); err == nil {
if hasJSExtension(resolved) || isNodeScript(resolved) {
return resolved
}
}
return ""
}
// 2. Check if the file itself is a Node script (e.g. globally installed with #!/usr/bin/env node).
if isNodeScript(path) {
return path
}
// 3. Try extracting JS entry point from an npm wrapper script (.cmd or shell).
return extractNodeEntryPoint(path)
}
// nodeEntryPointRe matches a relative path in npm-generated wrapper scripts.
// The entry may be a .js/.mjs/.cjs file or an extensionless Node shebang
// script (e.g. postcss-cli 7's bin/postcss). Local installs reference the
// entry via "..", global installs via "node_modules" (notably on Windows,
// where npm does not symlink global binaries).
// Examples:
//
// Local shell: "$basedir/../postcss-cli/index.js"
// Local cmd: "%dp0%\..\postcss-cli\index.js"
// Scoped: "$basedir/../@babel/cli/bin/babel.js"
// No ext: "%dp0%\..\postcss-cli\bin\postcss"
// Global shell: "$basedir/node_modules/postcss-cli/index.js"
// Global cmd: "%dp0%\node_modules\postcss-cli\index.js"
var nodeEntryPointRe = regexp.MustCompile(`[/\\]((?:\.\.|node_modules)[/\\][\w@][\w@./\\-]*)`)
// extractNodeEntryPoint reads an npm wrapper script and extracts the Node
// entry point path, validating that it's a JS file or a Node shebang script.
func extractNodeEntryPoint(wrapperPath string) string {
data, err := os.ReadFile(wrapperPath)
if err != nil {
return ""
}
m := nodeEntryPointRe.FindSubmatch(data)
if m == nil {
return ""
}
// Normalize backslashes from Windows .cmd wrappers.
relPath := strings.ReplaceAll(string(m[1]), "\\", "/")
resolved := filepath.Join(filepath.Dir(wrapperPath), relPath)
if _, err := os.Stat(resolved); err != nil {
return ""
}
if !hasJSExtension(resolved) && !isNodeScript(resolved) {
return ""
}
return resolved
}
func hasJSExtension(path string) bool {
switch filepath.Ext(path) {
case ".js", ".mjs", ".cjs":
return true
}
return false
}
// isNodeScript reports whether the file at path has a Node.js shebang.
func isNodeScript(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
r := bufio.NewReader(f)
line, err := r.ReadString('\n')
if err != nil && len(line) == 0 {
return false
}
return strings.HasPrefix(line, "#!") && strings.Contains(line, "node")
}
// Sec returns the security policies this Exec is configured with.
@@ -283,14 +457,8 @@ func (c *cmdWrapper) Run() error {
if err == nil {
return nil
}
name := c.name
method := "in PATH"
if name == npxBinary {
name = c.c.Args[2]
method = "using npx"
}
if notFoundRe.MatchString(c.outerr.String()) {
return &NotFoundError{name: name, method: method}
return &NotFoundError{name: c.name, method: "in PATH"}
}
return fmt.Errorf("failed to execute binary %q with args %v: %s", c.name, c.c.Args[1:], c.outerr.String())
}
+74
View File
@@ -0,0 +1,74 @@
// Copyright 2026 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 hexec_test
import (
"testing"
"github.com/gohugoio/hugo/htesting"
"github.com/gohugoio/hugo/hugolib"
)
func TestNPMGlobalInstalls(t *testing.T) {
if !htesting.IsRealCI() {
t.Skip("We only ever want to run this in CI.")
}
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
[security.exec]
allow = ['^(babel|node|postcss|tailwindcss)$']
-- package.json --
{}
-- hugo_stats.json --
-- assets/js/main.js --
console.log("Hello, world!");
-- assets/css/main1.css --
body { color: red }
-- assets/css/main2.css --
@import "tailwindcss";
@plugin "@tailwindcss/typography";
@source "hugo_stats.json";
body { color: blue }
-- layouts/home.html --
{{ with resources.Get "css/main1.css" }}
{{ with . | css.PostCSS }}
CSS1: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
{{ with resources.Get "css/main2.css" }}
{{ with . | css.TailwindCSS }}
CSS2: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
{{ with resources.Get "js/main.js" }}
{{ with . | js.Babel }}
JS: {{ .RelPermalink }}|{{ gt (.Content | len) 10 }}|
{{ end }}
{{ end }}
`
b := hugolib.Test(t, files, hugolib.TestOptOsFs(), hugolib.TestOptWithNpmInstallGlobal(
"postcss", "postcss-cli",
"@babel/core", "@babel/cli",
"tailwindcss", "@tailwindcss/cli", "@tailwindcss/typography",
))
b.AssertFileContent("public/index.html",
"CSS1: /css/main1.css|true|",
"CSS2: /css/main2.css|true|",
"JS: /js/main.js|true|",
)
}
+474
View File
@@ -0,0 +1,474 @@
// Copyright 2026 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 hexec
import (
"os"
"path/filepath"
"runtime"
"testing"
qt "github.com/frankban/quicktest"
"github.com/gohugoio/hugo/config/security"
)
func TestNodePermissionArgs(t *testing.T) {
c := qt.New(t)
// Use t.TempDir() so paths are absolute on any OS (avoids Windows volume assumptions).
base := t.TempDir()
site := filepath.Join(base, "site")
tmp := filepath.Join(base, "tmp")
cacheDir := filepath.Join(base, "home", "user", ".cache", "hugo_cache", "modules")
c.Run("Default config tailwindcss", func(c *qt.C) {
e := &Exec{
sc: security.DefaultConfig,
workingDir: site,
}
args := e.nodePermissionArgs("tailwindcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
"--allow-addons",
"--allow-worker",
"--allow-child-process",
"--disable-warning=SecurityWarning",
})
})
c.Run("Default config postcss", func(c *qt.C) {
e := &Exec{
sc: security.DefaultConfig,
workingDir: site,
}
args := e.nodePermissionArgs("postcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
})
})
c.Run("Multiple paths", func(c *qt.C) {
cfg := security.DefaultConfig
cfg.Node.Permissions.AllowRead = []string{".", tmp}
cfg.Node.Permissions.AllowWrite = []string{"."}
e := &Exec{
sc: cfg,
workingDir: site,
}
args := e.nodePermissionArgs("tailwindcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
"--allow-fs-read=" + tmp,
"--allow-fs-write=" + site,
"--allow-addons",
"--allow-worker",
"--allow-child-process",
"--disable-warning=SecurityWarning",
})
})
c.Run("Wildcard", func(c *qt.C) {
cfg := security.DefaultConfig
cfg.Node.Permissions.AllowRead = []string{"*"}
cfg.Node.Permissions.AllowWrite = []string{"*"}
e := &Exec{
sc: cfg,
workingDir: site,
}
args := e.nodePermissionArgs("tailwindcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=*",
"--allow-fs-write=*",
"--allow-addons",
"--allow-worker",
"--allow-child-process",
"--disable-warning=SecurityWarning",
})
})
c.Run("Disabled", func(c *qt.C) {
cfg := security.DefaultConfig
cfg.Node.Permissions.Disable = true
e := &Exec{
sc: cfg,
workingDir: site,
}
args := e.nodePermissionArgs("tailwindcss", "")
c.Assert(args, qt.IsNil)
})
c.Run("No fs flags", func(c *qt.C) {
cfg := security.DefaultConfig
cfg.Node.Permissions.AllowRead = nil
cfg.Node.Permissions.AllowAddons = nil
cfg.Node.Permissions.AllowWorker = nil
cfg.Node.Permissions.AllowChildProcess = nil
e := &Exec{
sc: cfg,
workingDir: site,
}
args := e.nodePermissionArgs("postcss", "")
c.Assert(args, qt.DeepEquals, []string{"--permission"})
})
c.Run("Read only", func(c *qt.C) {
cfg := security.DefaultConfig
cfg.Node.Permissions.AllowRead = []string{"."}
cfg.Node.Permissions.AllowWrite = nil
e := &Exec{
sc: cfg,
workingDir: site,
}
args := e.nodePermissionArgs("postcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
})
})
c.Run("With additional read paths", func(c *qt.C) {
e := &Exec{
sc: security.DefaultConfig,
workingDir: site,
nodeReadPaths: []string{cacheDir},
}
args := e.nodePermissionArgs("postcss", "")
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
"--allow-fs-read=" + cacheDir,
})
})
c.Run("Global install script path", func(c *qt.C) {
e := &Exec{
sc: security.DefaultConfig,
workingDir: site,
}
globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
script := filepath.Join(globalNM, "postcss-cli", "bin", "postcss")
args := e.nodePermissionArgs("postcss", script)
c.Assert(args, qt.DeepEquals, []string{
"--permission",
"--allow-fs-read=" + site,
"--allow-fs-read=" + globalNM,
})
})
}
func TestNodeScriptReadPath(t *testing.T) {
c := qt.New(t)
base := t.TempDir()
nm := filepath.Join(base, "node_modules")
globalNM := filepath.Join(base, "nvm", "lib", "node_modules")
c.Assert(nodeScriptReadPath(""), qt.Equals, "")
c.Assert(nodeScriptReadPath(filepath.Join(nm, "postcss-cli", "index.js")), qt.Equals, nm)
c.Assert(nodeScriptReadPath(filepath.Join(nm, "@babel", "cli", "bin", "babel.js")), qt.Equals, nm)
c.Assert(nodeScriptReadPath(filepath.Join(globalNM, "postcss-cli", "bin", "postcss")), qt.Equals, globalNM)
loose := filepath.Join(base, "tools", "script.js")
c.Assert(nodeScriptReadPath(loose), qt.Equals, filepath.Dir(loose))
}
func TestResolveNodeBin(t *testing.T) {
c := qt.New(t)
// Create a fake node_modules structure.
dir := t.TempDir()
nodeModules := filepath.Join(dir, "node_modules")
binDir := filepath.Join(nodeModules, ".bin")
// Create target JS files.
postcssJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
babelJS := filepath.Join(nodeModules, "@babel", "cli", "bin", "babel.js")
mkdirAndWrite(t, postcssJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
mkdirAndWrite(t, babelJS, "#!/usr/bin/env node\nconsole.log('babel');\n")
os.MkdirAll(binDir, 0o755)
c.Run("Symlink to JS file", func(c *qt.C) {
if runtime.GOOS == "windows" {
c.Skip("Symlinks may require elevated privileges on Windows")
}
link := filepath.Join(binDir, "postcss-link")
os.Remove(link)
c.Assert(os.Symlink(postcssJS, link), qt.IsNil)
resolved := resolveNodeBin(link)
t.Logf("Symlink: link=%q, resolved=%q", link, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
})
c.Run("Symlink to scoped package", func(c *qt.C) {
if runtime.GOOS == "windows" {
c.Skip("Symlinks may require elevated privileges on Windows")
}
link := filepath.Join(binDir, "babel-link")
os.Remove(link)
c.Assert(os.Symlink(babelJS, link), qt.IsNil)
resolved := resolveNodeBin(link)
t.Logf("Scoped symlink: link=%q, resolved=%q", link, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
})
c.Run("Shell wrapper", func(c *qt.C) {
wrapper := filepath.Join(binDir, "postcss-sh")
content := "#!/bin/sh\n" +
`basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")` + "\n" +
`exec node "$basedir/../postcss-cli/index.js" "$@"` + "\n"
mkdirAndWrite(t, wrapper, content)
resolved := resolveNodeBin(wrapper)
t.Logf("Shell wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
})
c.Run("Cmd wrapper", func(c *qt.C) {
wrapper := filepath.Join(binDir, "postcss.cmd")
content := "@ECHO off\r\n" +
"SETLOCAL\r\n" +
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
mkdirAndWrite(t, wrapper, content)
resolved := resolveNodeBin(wrapper)
t.Logf("Cmd wrapper: wrapper=%q, resolved=%q", wrapper, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, postcssJS), qt.IsTrue)
})
c.Run("Cmd wrapper scoped package", func(c *qt.C) {
wrapper := filepath.Join(binDir, "babel.cmd")
content := "@ECHO off\r\n" +
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\@babel\cli\bin\babel.js" %*` + "\r\n"
mkdirAndWrite(t, wrapper, content)
resolved := resolveNodeBin(wrapper)
t.Logf("Cmd wrapper (scoped): wrapper=%q, resolved=%q", wrapper, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, babelJS), qt.IsTrue)
})
c.Run("Node script with shebang", func(c *qt.C) {
script := filepath.Join(binDir, "node-global")
mkdirAndWrite(t, script, "#!/usr/bin/env node\nconsole.log('global');\n")
resolved := resolveNodeBin(script)
t.Logf("Node script: path=%q, resolved=%q", script, resolved)
c.Assert(resolved, qt.Equals, script)
})
c.Run("Native binary", func(c *qt.C) {
native := filepath.Join(binDir, "native-tool")
mkdirAndWrite(t, native, "\x7fELF\x00\x00\x00")
resolved := resolveNodeBin(native)
t.Logf("Native binary: path=%q, resolved=%q", native, resolved)
c.Assert(resolved, qt.Equals, "")
})
c.Run("Nonexistent file", func(c *qt.C) {
c.Assert(resolveNodeBin("/nonexistent/path"), qt.Equals, "")
})
c.Run("Wrapper with missing target", func(c *qt.C) {
wrapper := filepath.Join(binDir, "missing-target")
mkdirAndWrite(t, wrapper, "#!/bin/sh\nexec node \"$basedir/../no-such-pkg/index.js\" \"$@\"\n")
resolved := resolveNodeBin(wrapper)
t.Logf("Missing target: wrapper=%q, resolved=%q", wrapper, resolved)
c.Assert(resolved, qt.Equals, "")
})
}
func TestExtractNodeEntryPointRegex(t *testing.T) {
c := qt.New(t)
cases := []struct {
name string
content string
want string // expected capture group (with original separators)
}{
{"shell postcss", `"$basedir/../postcss-cli/index.js"`, "../postcss-cli/index.js"},
{"shell babel", `"$basedir/../@babel/cli/bin/babel.js"`, "../@babel/cli/bin/babel.js"},
{"shell tailwind mjs", `"$basedir/../@tailwindcss/cli/dist/index.mjs"`, "../@tailwindcss/cli/dist/index.mjs"},
{"cmd postcss", `"%dp0%\..\postcss-cli\index.js"`, `..\postcss-cli\index.js`},
{"cmd babel", `"%dp0%\..\@babel\cli\bin\babel.js"`, `..\@babel\cli\bin\babel.js`},
{"cmd postcss no ext", `"%dp0%\..\postcss-cli\bin\postcss"`, `..\postcss-cli\bin\postcss`},
{"shell postcss no ext", `"$basedir/../postcss-cli/bin/postcss"`, "../postcss-cli/bin/postcss"},
{"cmd postcss global", `"%dp0%\node_modules\postcss-cli\index.js"`, `node_modules\postcss-cli\index.js`},
{"cmd babel global", `"%dp0%\node_modules\@babel\cli\bin\babel.js"`, `node_modules\@babel\cli\bin\babel.js`},
{"shell postcss global", `"$basedir/node_modules/postcss-cli/index.js"`, "node_modules/postcss-cli/index.js"},
}
for _, tc := range cases {
c.Run(tc.name, func(c *qt.C) {
m := nodeEntryPointRe.FindStringSubmatch(tc.content)
t.Logf("regex match for %q: %v", tc.name, m)
c.Assert(m, qt.Not(qt.IsNil))
c.Assert(m[1], qt.Equals, tc.want)
})
}
c.Run("No match", func(c *qt.C) {
for _, s := range []string{"@ECHO off", "#!/bin/bash\necho hello", "\x7fELF"} {
c.Assert(nodeEntryPointRe.FindStringSubmatch(s), qt.IsNil)
}
})
}
// TestResolveNodeBinWindows tests wrapper resolution on all platforms
// by simulating Windows-style wrapper files. On Windows CI, this also
// tests the native .cmd resolution path.
func TestResolveNodeBinWindows(t *testing.T) {
c := qt.New(t)
dir := t.TempDir()
nodeModules := filepath.Join(dir, "node_modules")
binDir := filepath.Join(nodeModules, ".bin")
// Create target JS file.
targetJS := filepath.Join(nodeModules, "postcss-cli", "index.js")
mkdirAndWrite(t, targetJS, "#!/usr/bin/env node\nconsole.log('postcss');\n")
os.MkdirAll(binDir, 0o755)
// Simulate what npm creates on Windows: a .cmd wrapper and a shell script.
cmdWrapper := filepath.Join(binDir, "postcss.cmd")
cmdContent := "@ECHO off\r\n" +
"SETLOCAL\r\n" +
"CALL :find_dp0\r\n" +
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\..\postcss-cli\index.js" %*` + "\r\n"
mkdirAndWrite(t, cmdWrapper, cmdContent)
shWrapper := filepath.Join(binDir, "postcss")
shContent := "#!/bin/sh\n" +
`exec node "$basedir/../postcss-cli/index.js" "$@"` + "\n"
mkdirAndWrite(t, shWrapper, shContent)
t.Logf("GOOS=%s", runtime.GOOS)
t.Logf("cmd wrapper: %s", cmdWrapper)
t.Logf("sh wrapper: %s", shWrapper)
t.Logf("target JS: %s", targetJS)
c.Run("cmd wrapper resolves to JS", func(c *qt.C) {
resolved := resolveNodeBin(cmdWrapper)
t.Logf("resolveNodeBin(%q) = %q", cmdWrapper, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
})
c.Run("sh wrapper resolves to JS", func(c *qt.C) {
resolved := resolveNodeBin(shWrapper)
t.Logf("resolveNodeBin(%q) = %q", shWrapper, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, targetJS), qt.IsTrue)
})
// Simulate postcss-cli 7, whose wrappers point to an extensionless Node
// shebang script (bin/postcss) rather than a .js file.
targetNoExt := filepath.Join(nodeModules, "postcss-cli", "bin", "postcss")
mkdirAndWrite(t, targetNoExt, "#!/usr/bin/env node\nrequire('../');\n")
cmdNoExt := filepath.Join(binDir, "postcssne.cmd")
mkdirAndWrite(t, cmdNoExt, "@ECHO off\r\n"+
`"%dp0%\..\postcss-cli\bin\postcss" %*`+"\r\n")
shNoExt := filepath.Join(binDir, "postcssne")
mkdirAndWrite(t, shNoExt, "#!/bin/sh\n"+
`exec node "$basedir/../postcss-cli/bin/postcss" "$@"`+"\n")
c.Run("cmd wrapper resolves to extensionless script", func(c *qt.C) {
resolved := resolveNodeBin(cmdNoExt)
t.Logf("resolveNodeBin(%q) = %q", cmdNoExt, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
})
c.Run("sh wrapper resolves to extensionless script", func(c *qt.C) {
resolved := resolveNodeBin(shNoExt)
t.Logf("resolveNodeBin(%q) = %q", shNoExt, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, targetNoExt), qt.IsTrue)
})
// Simulate `npm install -g` on Windows: the wrapper sits at the npm
// global prefix and references node_modules as a child (no `..`).
globalDir := filepath.Join(dir, "global")
globalTarget := filepath.Join(globalDir, "node_modules", "postcss-cli", "index.js")
mkdirAndWrite(t, globalTarget, "#!/usr/bin/env node\nconsole.log('postcss');\n")
globalCmd := filepath.Join(globalDir, "postcss.cmd")
mkdirAndWrite(t, globalCmd, "@ECHO off\r\n"+
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\postcss-cli\index.js" %*`+"\r\n")
globalSh := filepath.Join(globalDir, "postcss")
mkdirAndWrite(t, globalSh, "#!/bin/sh\n"+
`exec node "$basedir/node_modules/postcss-cli/index.js" "$@"`+"\n")
c.Run("global cmd wrapper resolves to JS", func(c *qt.C) {
resolved := resolveNodeBin(globalCmd)
t.Logf("resolveNodeBin(%q) = %q", globalCmd, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
})
c.Run("global sh wrapper resolves to JS", func(c *qt.C) {
resolved := resolveNodeBin(globalSh)
t.Logf("resolveNodeBin(%q) = %q", globalSh, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, globalTarget), qt.IsTrue)
})
globalScopedTarget := filepath.Join(globalDir, "node_modules", "@babel", "cli", "bin", "babel.js")
mkdirAndWrite(t, globalScopedTarget, "#!/usr/bin/env node\nconsole.log('babel');\n")
globalScopedCmd := filepath.Join(globalDir, "babel.cmd")
mkdirAndWrite(t, globalScopedCmd, "@ECHO off\r\n"+
`endLocal & goto #_undefined_# 2>NUL || title %COMSPEC% & "%_prog%" "%dp0%\node_modules\@babel\cli\bin\babel.js" %*`+"\r\n")
c.Run("global cmd wrapper scoped package", func(c *qt.C) {
resolved := resolveNodeBin(globalScopedCmd)
t.Logf("resolveNodeBin(%q) = %q", globalScopedCmd, resolved)
c.Assert(resolved, qt.Not(qt.Equals), "")
c.Assert(sameFile(t, resolved, globalScopedTarget), qt.IsTrue)
})
}
func sameFile(t *testing.T, a, b string) bool {
t.Helper()
infoA, errA := os.Stat(a)
infoB, errB := os.Stat(b)
if errA != nil || errB != nil {
t.Logf("sameFile: stat errors: a=%v, b=%v", errA, errB)
return false
}
return os.SameFile(infoA, infoB)
}
func mkdirAndWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o755); err != nil {
t.Fatal(err)
}
}
+17
View File
@@ -43,6 +43,23 @@ type ImageConfigProvider interface {
GetImageConfig() image.Config
}
// ColorPropertiesProvider provides access to CICP color properties (for HDR images).
// Images implementing this interface preserve color space information through processing.
type ColorPropertiesProvider interface {
GetColorPrimaries() int
GetTransferCharacteristics() int
GetMatrixCoefficients() int
}
// HasColorProperties returns true if the image has non-zero color properties that should be preserved.
func HasColorProperties(img image.Image) bool {
if cpp, ok := img.(ColorPropertiesProvider); ok {
// Consider it as having properties if any value is non-zero.
return cpp.GetColorPrimaries() > 0 || cpp.GetTransferCharacteristics() > 0 || cpp.GetMatrixCoefficients() > 0
}
return false
}
// FrameDurationsToGifDelays converts frame durations in milliseconds to
// GIF delays in 100ths of a second.
func FrameDurationsToGifDelays(frameDurations []int) []int {
+1 -1
View File
@@ -1,7 +1,7 @@
package hiter
// Common iterator functions.
// Some of these are are based on this discsussion: https://github.com/golang/go/issues/61898
// Some of these are based on this discsussion: https://github.com/golang/go/issues/61898
import "iter"
-129
View File
@@ -1,129 +0,0 @@
// Copyright 2026 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 hmaps
import (
"iter"
"sync"
)
func NewMap[K comparable, T any]() *Map[K, T] {
return &Map[K, T]{
m: make(map[K]T),
}
}
// Map is a thread safe map backed by a Go map.
type Map[K comparable, T any] struct {
m map[K]T
mu sync.RWMutex
}
// Get gets the value for the given key.
// It returns the zero value of T if the key is not found.
func (m *Map[K, T]) Get(key K) T {
v, _ := m.Lookup(key)
return v
}
// Lookup looks up the given key in the map.
// It returns the value and a boolean indicating whether the key was found.
func (m *Map[K, T]) Lookup(key K) (T, bool) {
m.mu.RLock()
v, found := m.m[key]
m.mu.RUnlock()
return v, found
}
// GetOrCreate gets the value for the given key if it exists, or creates it if not.
func (m *Map[K, T]) GetOrCreate(key K, create func() (T, error)) (T, error) {
v, found := m.Lookup(key)
if found {
return v, nil
}
m.mu.Lock()
defer m.mu.Unlock()
v, found = m.m[key]
if found {
return v, nil
}
v, err := create()
if err != nil {
return v, err
}
m.m[key] = v
return v, nil
}
// Set sets the given key to the given value.
func (m *Map[K, T]) Set(key K, value T) {
m.mu.Lock()
m.m[key] = value
m.mu.Unlock()
}
// Delete deletes the given key from the map.
// It returns true if the key was found and deleted, false otherwise.
func (m *Map[K, T]) Delete(key K) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, found := m.m[key]; found {
delete(m.m, key)
return true
}
return false
}
// WithWriteLock executes the given function with a write lock on the map.
func (m *Map[K, T]) WithWriteLock(f func(m map[K]T) error) error {
m.mu.Lock()
defer m.mu.Unlock()
return f(m.m)
}
// SetIfAbsent sets the given key to the given value if the key does not already exist in the map.
// It returns true if the value was set, false otherwise.
func (m *Map[K, T]) SetIfAbsent(key K, value T) bool {
m.mu.RLock()
if _, found := m.m[key]; !found {
m.mu.RUnlock()
return m.doSetIfAbsent(key, value)
}
m.mu.RUnlock()
return false
}
func (m *Map[K, T]) doSetIfAbsent(key K, value T) bool {
m.mu.Lock()
defer m.mu.Unlock()
if _, found := m.m[key]; !found {
m.m[key] = value
return true
}
return false
}
// All returns an iterator over all key/value pairs in the map.
// A read lock is held during the iteration.
func (m *Map[K, T]) All() iter.Seq2[K, T] {
return func(yield func(K, T) bool) {
m.mu.RLock()
defer m.mu.RUnlock()
for k, v := range m.m {
if !yield(k, v) {
return
}
}
}
}
-72
View File
@@ -1,72 +0,0 @@
// Copyright 2026 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 hmaps
import (
"testing"
qt "github.com/frankban/quicktest"
)
func TestMap(t *testing.T) {
c := qt.New(t)
m := NewMap[string, int]()
m.Set("b", 42)
v, found := m.Lookup("b")
c.Assert(found, qt.Equals, true)
c.Assert(v, qt.Equals, 42)
v = m.Get("b")
c.Assert(v, qt.Equals, 42)
v, found = m.Lookup("c")
c.Assert(found, qt.Equals, false)
c.Assert(v, qt.Equals, 0)
v = m.Get("c")
c.Assert(v, qt.Equals, 0)
v, err := m.GetOrCreate("d", func() (int, error) {
return 100, nil
})
c.Assert(err, qt.IsNil)
c.Assert(v, qt.Equals, 100)
v, found = m.Lookup("d")
c.Assert(found, qt.Equals, true)
c.Assert(v, qt.Equals, 100)
v, err = m.GetOrCreate("d", func() (int, error) {
return 200, nil
})
c.Assert(err, qt.IsNil)
c.Assert(v, qt.Equals, 100)
wasSet := m.SetIfAbsent("e", 300)
c.Assert(wasSet, qt.Equals, true)
v, found = m.Lookup("e")
c.Assert(found, qt.Equals, true)
c.Assert(v, qt.Equals, 300)
wasSet = m.SetIfAbsent("e", 400)
c.Assert(wasSet, qt.Equals, false)
v, found = m.Lookup("e")
c.Assert(found, qt.Equals, true)
c.Assert(v, qt.Equals, 300)
m.WithWriteLock(func(m map[string]int) error {
m["f"] = 500
return nil
})
v, found = m.Lookup("f")
c.Assert(found, qt.Equals, true)
c.Assert(v, qt.Equals, 500)
}
+8
View File
@@ -120,6 +120,14 @@ func (p Params) merge(ps ParamsMergeStrategy, pp Params) {
if vvv, ok := vv.(Params); ok {
if pv, ok := v.(Params); ok {
vvv.merge(ms, pv)
} else if vvv.IsZero() {
// The existing value is an empty Params (just merge metadata)
// and the incoming value is a non-Params type (e.g. a slice).
// If the user has set an explicit non-none merge strategy,
// honor it by using the incoming value.
if s, found := vvv.GetMergeStrategy(); found && s != ParamsMergeStrategyNone {
p[k] = v
}
}
}
} else if !noUpdate {
+3 -7
View File
@@ -14,7 +14,6 @@
package hstore
import (
"reflect"
"sync"
"testing"
@@ -69,9 +68,8 @@ func TestScratchAddSlice(t *testing.T) {
sl := scratch.Get("intSlice")
expected := []int{1, 2, 3}
if !reflect.DeepEqual(expected, sl) {
t.Errorf("Slice difference, go %q expected %q", sl, expected)
}
c.Assert(sl, qt.DeepEquals, expected)
_, err = scratch.Add("intSlice", []int{4, 5})
c.Assert(err, qt.IsNil)
@@ -79,9 +77,7 @@ func TestScratchAddSlice(t *testing.T) {
sl = scratch.Get("intSlice")
expected = []int{1, 2, 3, 4, 5}
if !reflect.DeepEqual(expected, sl) {
t.Errorf("Slice difference, go %q expected %q", sl, expected)
}
c.Assert(sl, qt.DeepEquals, expected)
}
// https://github.com/gohugoio/hugo/issues/5275
+16 -36
View File
@@ -19,8 +19,8 @@ import (
"slices"
"sort"
"strings"
"sync"
"github.com/bep/helpers/maphelpers"
"github.com/gohugoio/hugo/compare"
)
@@ -55,46 +55,17 @@ func EqualAny(a string, b ...string) bool {
return slices.Contains(b, a)
}
// regexpCache represents a cache of regexp objects protected by a mutex.
type regexpCache struct {
mu sync.RWMutex
re map[string]*regexp.Regexp
}
func (rc *regexpCache) getOrCompileRegexp(pattern string) (re *regexp.Regexp, err error) {
var ok bool
if re, ok = rc.get(pattern); !ok {
re, err = regexp.Compile(pattern)
if err != nil {
return nil, err
}
rc.set(pattern, re)
}
return re, nil
}
func (rc *regexpCache) get(key string) (re *regexp.Regexp, ok bool) {
rc.mu.RLock()
re, ok = rc.re[key]
rc.mu.RUnlock()
return
}
func (rc *regexpCache) set(key string, re *regexp.Regexp) {
rc.mu.Lock()
rc.re[key] = re
rc.mu.Unlock()
}
var reCache = regexpCache{re: make(map[string]*regexp.Regexp)}
var reCache = *maphelpers.NewConcurrentMap[string, *regexp.Regexp]()
// GetOrCompileRegexp retrieves a regexp object from the cache based upon the pattern.
// If the pattern is not found in the cache, the pattern is compiled and added to
// the cache.
func GetOrCompileRegexp(pattern string) (re *regexp.Regexp, err error) {
return reCache.getOrCompileRegexp(pattern)
return reCache.GetOrCreate(pattern,
func() (*regexp.Regexp, error) {
return regexp.Compile(pattern)
},
)
}
// HasAnyPrefix checks if the string s has any of the prefixes given.
@@ -107,6 +78,15 @@ func HasAnyPrefix(s string, prefixes ...string) bool {
return false
}
func HasUppercase(s string) bool {
for _, r := range s {
if 'A' <= r && r <= 'Z' {
return true
}
}
return false
}
// InSlice checks if a string is an element of a slice of strings
// and returns a boolean value.
func InSlice(arr []string, el string) bool {
+10
View File
@@ -71,6 +71,16 @@ func TestUniqueStringsSorted(t *testing.T) {
c.Assert(UniqueStringsSorted(nil), qt.IsNil)
}
func TestHasUppercase(t *testing.T) {
c := qt.New(t)
c.Assert(HasUppercase("abc"), qt.Equals, false)
c.Assert(HasUppercase("Abc"), qt.Equals, true)
c.Assert(HasUppercase("aBc"), qt.Equals, true)
c.Assert(HasUppercase("abC"), qt.Equals, true)
c.Assert(HasUppercase("ABC"), qt.Equals, true)
}
// Note that these cannot use b.Loop() because of golang/go#27217.
func BenchmarkUniqueStrings(b *testing.B) {
input := []string{"a", "b", "d", "e", "d", "h", "a", "i"}
+3
View File
@@ -82,6 +82,9 @@ func CopyDir(fs afero.Fs, from, to string, shouldCopy func(filename string) bool
return err
}
} else {
if shouldCopy != nil && !shouldCopy(fromFilename) {
continue
}
if err := CopyFile(fs, fromFilename, toFilename); err != nil {
return err
}
+51 -28
View File
@@ -21,8 +21,9 @@ import (
type HasBytesWriter struct {
Patterns []*HasBytesPattern
i int
done bool
// The tail of the bytes written so far, retained so we can detect a
// pattern that straddles the boundary between two Write calls.
buff []byte
}
@@ -31,10 +32,13 @@ type HasBytesPattern struct {
Pattern []byte
}
func (h *HasBytesWriter) patternLen() int {
// maxPatternLen returns the length of the longest pattern.
func (h *HasBytesWriter) maxPatternLen() int {
l := 0
for _, p := range h.Patterns {
l += len(p.Pattern)
if len(p.Pattern) > l {
l = len(p.Pattern)
}
}
return l
}
@@ -44,36 +48,55 @@ func (h *HasBytesWriter) Write(p []byte) (n int, err error) {
return len(p), nil
}
if len(h.buff) == 0 {
h.buff = make([]byte, h.patternLen()*2)
keep := h.maxPatternLen() - 1
// Join the tail retained from previous Writes with the head of this chunk
// so a pattern straddling the boundary is still detected. Only the
// boundary window is copied; the chunk itself is scanned in place below.
var boundary []byte
if keep > 0 && len(h.buff) > 0 {
head := p
if len(head) > keep {
head = head[:keep]
}
boundary = make([]byte, 0, len(h.buff)+len(head))
boundary = append(boundary, h.buff...)
boundary = append(boundary, head...)
}
for i := range p {
h.buff[h.i] = p[i]
h.i++
if h.i == len(h.buff) {
// Shift left.
copy(h.buff, h.buff[len(h.buff)/2:])
h.i = len(h.buff) / 2
// Scan each not-yet-matched pattern once per Write instead of once per byte.
done := true
for _, pp := range h.Patterns {
if pp.Match {
continue
}
for _, pp := range h.Patterns {
if bytes.Contains(h.buff, pp.Pattern) {
pp.Match = true
done := true
for _, ppp := range h.Patterns {
if !ppp.Match {
done = false
break
}
}
if done {
h.done = true
}
return len(p), nil
}
if bytes.Contains(p, pp.Pattern) || bytes.Contains(boundary, pp.Pattern) {
pp.Match = true
continue
}
done = false
}
if done {
// All patterns found; no need to look at any more data.
h.done = true
h.buff = nil
return len(p), nil
}
// Retain the last keep bytes of (previous tail + this chunk) to detect a
// pattern straddling into the next Write.
switch {
case keep <= 0:
h.buff = h.buff[:0]
case len(p) >= keep:
h.buff = append(h.buff[:0], p[len(p)-keep:]...)
default:
// Chunk shorter than keep: slide the window over the retained tail.
if total := len(h.buff) + len(p); total > keep {
h.buff = h.buff[total-keep:]
}
h.buff = append(h.buff, p...)
}
return len(p), nil
+58
View File
@@ -65,3 +65,61 @@ func TestHasBytesWriter(t *testing.T) {
fmt.Fprintf(w, "__foo")
c.Assert(h.Patterns[0].Match, qt.Equals, true)
}
func TestHasBytesWriterMultiplePatterns(t *testing.T) {
c := qt.New(t)
neww := func() (*HasBytesWriter, io.Writer) {
var b bytes.Buffer
h := &HasBytesWriter{
Patterns: []*HasBytesPattern{
{Pattern: []byte("__hdeferred/")},
{Pattern: []byte("__h_pp_l1")},
},
}
return h, io.MultiWriter(&b, h)
}
// Neither pattern present.
h, w := neww()
fmt.Fprint(w, "the quick brown fox jumps over the lazy dog")
c.Assert(h.Patterns[0].Match, qt.Equals, false)
c.Assert(h.Patterns[1].Match, qt.Equals, false)
c.Assert(h.done, qt.Equals, false)
// Only the second pattern present; the writer must not report a match
// for the first, and must not prematurely mark itself done.
h, w = neww()
fmt.Fprint(w, "prefix __h_pp_l1 suffix")
c.Assert(h.Patterns[0].Match, qt.Equals, false)
c.Assert(h.Patterns[1].Match, qt.Equals, true)
c.Assert(h.done, qt.Equals, false)
// Both patterns present across multiple writes; done once all match.
h, w = neww()
fmt.Fprint(w, "aaa __hdef")
fmt.Fprint(w, "erred/xyz bbb __h_p")
fmt.Fprint(w, "p_l1 ccc")
c.Assert(h.Patterns[0].Match, qt.Equals, true)
c.Assert(h.Patterns[1].Match, qt.Equals, true)
c.Assert(h.done, qt.Equals, true)
}
func BenchmarkHasBytesWriter(b *testing.B) {
// A large chunk of output containing neither pattern is the common case
// (a normal rendered page): the writer must scan all of it.
content := []byte(strings.Repeat("<div class=\"nav\"><a href=\"/foo/bar\">baz</a></div>\n", 4000))
b.ResetTimer()
for range b.N {
h := &HasBytesWriter{
Patterns: []*HasBytesPattern{
{Pattern: []byte("__hdeferred/")},
{Pattern: []byte("__h_pp_l1")},
},
}
if _, err := h.Write(content); err != nil {
b.Fatal(err)
}
}
}
+13 -3
View File
@@ -33,6 +33,7 @@ import (
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/version"
"github.com/gohugoio/hugo/hugofs/files"
"github.com/gohugoio/hugo/internal/warpc"
"github.com/spf13/afero"
@@ -61,6 +62,11 @@ type BuildInfo struct {
GoVersion string
}
// GetBuildDate returns the build date if set by -ldflags="-X github.com/gohugoio/hugo/common/hugo.buildDate="
func GetBuildDate() string {
return buildDate
}
// GetBuildInfo returns the build info for the current binary.
func GetBuildInfo() *BuildInfo {
bi := getBuildInfo()
@@ -105,7 +111,7 @@ func GetExecEnviron(workDir string, cfg config.AllProvider, fs afero.Fs) []strin
var env []string
nodepath := filepath.Join(workDir, "node_modules")
if np := os.Getenv("NODE_PATH"); np != "" {
nodepath = workDir + string(os.PathListSeparator) + np
nodepath = nodepath + string(os.PathListSeparator) + np
}
config.SetEnvVars(&env, "NODE_PATH", nodepath)
config.SetEnvVars(&env, "PWD", workDir)
@@ -206,7 +212,10 @@ func GetDependencyList() []string {
// GetDependencyListNonGo returns a list of non-Go dependencies.
func GetDependencyListNonGo() []string {
deps := []string{formatDep("github.com/webmproject/libwebp", "v1.6.0")} // via WASM. TODO(bep) get versions from the plugin setup.
var deps []string
for _, dep := range warpc.GetWASMDeps() {
deps = append(deps, formatDep(dep[0], dep[1]))
}
if IsExtended {
deps = append(
@@ -220,7 +229,8 @@ func GetDependencyListNonGo() []string {
if IsDartSassGeV2() {
dartSassPath = "github.com/sass/dart-sass"
}
deps = append(deps,
deps = append(
deps,
formatDep(dartSassPath+"/protocol", dartSass.ProtocolVersion),
formatDep(dartSassPath+"/compiler", dartSass.CompilerVersion),
formatDep(dartSassPath+"/implementation", dartSass.ImplementationVersion),
+2 -2
View File
@@ -35,8 +35,8 @@ func TestDeprecationLogLevelFromVersion(t *testing.T) {
c.Assert(deprecationLogLevelFromVersion(ver.String()), qt.Equals, logg.LevelError)
// Added just to find the threshold for where we can remove deprecated items.
// Subtract 5 from the minor version of the first ERRORed version => 0.136.0.
c.Assert(deprecationLogLevelFromVersion("0.141.0"), qt.Equals, logg.LevelError)
// Subtract 5 from the minor version of the first ERRORed version => 0.145.0.
c.Assert(deprecationLogLevelFromVersion("0.150.0"), qt.Equals, logg.LevelError)
}
func TestMarkupScope(t *testing.T) {
+2 -2
View File
@@ -19,7 +19,7 @@ import "github.com/gohugoio/hugo/common/version"
// This should be the only one.
var CurrentVersion = version.Version{
Major: 0,
Minor: 160,
Minor: 166,
PatchLevel: 0,
Suffix: "",
Suffix: "-DEV",
}
+28 -26
View File
@@ -20,6 +20,7 @@ import (
"sync"
"sync/atomic"
"testing"
"testing/synctest"
"time"
"github.com/gohugoio/hugo/htesting"
@@ -28,18 +29,16 @@ import (
)
func TestPara(t *testing.T) {
if runtime.NumCPU() < 4 {
t.Skipf("skip para test, CPU count is %d", runtime.NumCPU())
}
// TODO(bep)
if htesting.IsCI() {
t.Skip("skip para test when running on CI")
}
c := qt.New(t)
c.Run("Order", func(c *qt.C) {
if runtime.NumCPU() < 4 {
c.Skipf("skip Order subtest, CPU count is %d", runtime.NumCPU())
}
if htesting.IsCI() {
c.Skip("skip Order subtest when running on CI")
}
n := 500
ints := make([]int, n)
for i := range n {
@@ -68,28 +67,31 @@ func TestPara(t *testing.T) {
})
c.Run("Time", func(c *qt.C) {
const n = 100
synctest.Test(c.TB.(*testing.T), func(t *testing.T) {
c := qt.New(t)
const n = 100
p := New(5)
r, _ := p.Start(context.Background())
p := New(5)
r, _ := p.Start(context.Background())
start := time.Now()
start := time.Now()
var counter int64
var counter int64
for range n {
r.Run(func() error {
atomic.AddInt64(&counter, 1)
time.Sleep(1 * time.Millisecond)
return nil
})
}
for range n {
r.Run(func() error {
atomic.AddInt64(&counter, 1)
time.Sleep(1 * time.Millisecond)
return nil
})
}
c.Assert(r.Wait(), qt.IsNil)
c.Assert(counter, qt.Equals, int64(n))
c.Assert(r.Wait(), qt.IsNil)
c.Assert(counter, qt.Equals, int64(n))
since := time.Since(start)
limit := n / 2 * time.Millisecond
c.Assert(since < limit, qt.Equals, true, qt.Commentf("%s >= %s", since, limit))
since := time.Since(start)
limit := n / 2 * time.Millisecond
c.Assert(since < limit, qt.Equals, true, qt.Commentf("%s >= %s", since, limit))
})
})
}
-82
View File
@@ -100,19 +100,6 @@ func AddLeadingAndTrailingSlash(path string) string {
return AddTrailingSlash(AddLeadingSlash(path))
}
// MakeTitle converts the path given to a suitable title, trimming whitespace
// and replacing hyphens with whitespace.
func MakeTitle(inpath string) string {
return strings.Replace(strings.TrimSpace(inpath), "-", " ", -1)
}
// ReplaceExtension takes a path and an extension, strips the old extension
// and returns the path with the new extension.
func ReplaceExtension(path string, newExt string) string {
f, _ := fileAndExt(path, fpb)
return f + "." + newExt
}
func makePathRelative(inPath string, possibleDirectories ...string) (string, error) {
for _, currentPath := range possibleDirectories {
if after, ok := strings.CutPrefix(inPath, currentPath); ok {
@@ -201,75 +188,6 @@ func extractFilename(in, ext, base, pathSeparator string) (name string) {
return
}
// GetRelativePath returns the relative path of a given path.
func GetRelativePath(path, base string) (final string, err error) {
if filepath.IsAbs(path) && base == "" {
return "", errors.New("source: missing base directory")
}
name := filepath.Clean(path)
base = filepath.Clean(base)
name, err = filepath.Rel(base, name)
if err != nil {
return "", err
}
if strings.HasSuffix(filepath.FromSlash(path), FilePathSeparator) && !strings.HasSuffix(name, FilePathSeparator) {
name += FilePathSeparator
}
return name, nil
}
func prettifyPath(in string, b filepathPathBridge) string {
if filepath.Ext(in) == "" {
// /section/name/ -> /section/name/index.html
if len(in) < 2 {
return b.Separator()
}
return b.Join(in, "index.html")
}
name, ext := fileAndExt(in, b)
if name == "index" {
// /section/name/index.html -> /section/name/index.html
return b.Clean(in)
}
// /section/name.html -> /section/name/index.html
return b.Join(b.Dir(in), name, "index"+ext)
}
// CommonDirPath returns the common directory of the given paths.
func CommonDirPath(path1, path2 string) string {
if path1 == "" || path2 == "" {
return ""
}
hadLeadingSlash := strings.HasPrefix(path1, "/") || strings.HasPrefix(path2, "/")
path1 = TrimLeading(path1)
path2 = TrimLeading(path2)
p1 := strings.Split(path1, "/")
p2 := strings.Split(path2, "/")
var common []string
for i := 0; i < len(p1) && i < len(p2); i++ {
if p1[i] == p2[i] {
common = append(common, p1[i])
} else {
break
}
}
s := strings.Join(common, "/")
if hadLeadingSlash && s != "" {
s = "/" + s
}
return s
}
// Sanitize sanitizes string to be used in Hugo's file paths and URLs, allowing only
// a predefined set of special Unicode characters.
//
-100
View File
@@ -20,38 +20,6 @@ import (
qt "github.com/frankban/quicktest"
)
func TestGetRelativePath(t *testing.T) {
tests := []struct {
path string
base string
expect any
}{
{filepath.FromSlash("/a/b"), filepath.FromSlash("/a"), filepath.FromSlash("b")},
{filepath.FromSlash("/a/b/c/"), filepath.FromSlash("/a"), filepath.FromSlash("b/c/")},
{filepath.FromSlash("/c"), filepath.FromSlash("/a/b"), filepath.FromSlash("../../c")},
{filepath.FromSlash("/c"), "", false},
}
for i, this := range tests {
// ultimately a fancy wrapper around filepath.Rel
result, err := GetRelativePath(this.path, this.base)
if b, ok := this.expect.(bool); ok && !b {
if err == nil {
t.Errorf("[%d] GetRelativePath didn't return an expected error", i)
}
} else {
if err != nil {
t.Errorf("[%d] GetRelativePath failed: %s", i, err)
continue
}
if result != this.expect {
t.Errorf("[%d] GetRelativePath got %v but expected %v", i, result, this.expect)
}
}
}
}
func TestMakePathRelative(t *testing.T) {
type test struct {
inPath, path1, path2, output string
@@ -75,54 +43,6 @@ func TestMakePathRelative(t *testing.T) {
}
}
func TestMakeTitle(t *testing.T) {
type test struct {
input, expected string
}
data := []test{
{"Make-Title", "Make Title"},
{"MakeTitle", "MakeTitle"},
{"make_title", "make_title"},
}
for i, d := range data {
output := MakeTitle(d.input)
if d.expected != output {
t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
}
}
}
// Replace Extension is probably poorly named, but the intent of the
// function is to accept a path and return only the file name with a
// new extension. It's intentionally designed to strip out the path
// and only provide the name. We should probably rename the function to
// be more explicit at some point.
func TestReplaceExtension(t *testing.T) {
type test struct {
input, newext, expected string
}
data := []test{
// These work according to the above definition
{"/some/random/path/file.xml", "html", "file.html"},
{"/banana.html", "xml", "banana.xml"},
{"./banana.html", "xml", "banana.xml"},
{"banana/pie/index.html", "xml", "index.xml"},
{"../pies/fish/index.html", "xml", "index.xml"},
// but these all fail
{"filename-without-an-ext", "ext", "filename-without-an-ext.ext"},
{"/filename-without-an-ext", "ext", "filename-without-an-ext.ext"},
{"/directory/mydir/", "ext", ".ext"},
{"mydir/", "ext", ".ext"},
}
for i, d := range data {
output := ReplaceExtension(filepath.FromSlash(d.input), d.newext)
if d.expected != output {
t.Errorf("Test %d failed. Expected %q got %q", i, d.expected, output)
}
}
}
func TestExtNoDelimiter(t *testing.T) {
c := qt.New(t)
c.Assert(ExtNoDelimiter(filepath.FromSlash("/my/data.json")), qt.Equals, "json")
@@ -263,26 +183,6 @@ func TestFieldsSlash(t *testing.T) {
c.Assert(FieldsSlash(""), qt.DeepEquals, []string{})
}
func TestCommonDirPath(t *testing.T) {
c := qt.New(t)
for _, this := range []struct {
a, b, expected string
}{
{"/a/b/c", "/a/b/d", "/a/b"},
{"/a/b/c", "a/b/d", "/a/b"},
{"a/b/c", "/a/b/d", "/a/b"},
{"a/b/c", "a/b/d", "a/b"},
{"/a/b/c", "/a/b/c", "/a/b/c"},
{"/a/b/c", "/a/b/c/d", "/a/b/c"},
{"/a/b/c", "/a/b", "/a/b"},
{"/a/b/c", "/a", "/a"},
{"/a/b/c", "/d/e/f", ""},
} {
c.Assert(CommonDirPath(this.a, this.b), qt.Equals, this.expected, qt.Commentf("a: %s b: %s", this.a, this.b))
}
}
func TestIsSameFilePath(t *testing.T) {
c := qt.New(t)
+272 -54
View File
@@ -25,7 +25,6 @@ import (
"github.com/gohugoio/hugo/common/types"
"github.com/gohugoio/hugo/hugofs/files"
"github.com/gohugoio/hugo/hugolib/sitesmatrix"
"github.com/gohugoio/hugo/identity"
"github.com/gohugoio/hugo/resources/kinds"
)
@@ -34,6 +33,16 @@ const (
identifierCustomWrapper = "_"
)
// Known prefixes for ._prefix_value_. identifiers.
const (
prefixLanguage = "language_"
prefixVersion = "version_"
prefixRole = "role_"
prefixOutputFormat = "outputformat_"
prefixKind = "kind_"
prefixLayout = "layout_"
)
// isCustomWrapperIdentifier tells whether a supplied path is of the form _xyz_.
// must have non-empty content between the identifierCustomWrapper's to pass.
func isCustomWrapperIdentifier(s string) bool {
@@ -42,6 +51,135 @@ func isCustomWrapperIdentifier(s string) bool {
strings.HasSuffix(s, identifierCustomWrapper)
}
// parsePrefixIdentifier parses inner content of a wrapper block (with outer underscores stripped)
// and returns the prefix. The prefix match is case-insensitive.
// Returns empty string if not a known prefix.
func parsePrefixIdentifier(inner string) string {
innerLower := strings.ToLower(inner)
for _, p := range []string{prefixLanguage, prefixVersion, prefixRole, prefixOutputFormat, prefixKind, prefixLayout} {
if strings.HasPrefix(innerLower, p) {
return p
}
}
return ""
}
// findWrapperDotPositions returns positions of dots inside ._..._. wrapper blocks.
// These dots should be skipped during the main backward dot-scanning loop,
// so that wrapper blocks are treated as single opaque segments.
func findWrapperDotPositions(s string) []int {
lastSlash := strings.LastIndex(s, "/")
if lastSlash == -1 {
return nil
}
var skipDots []int
i := lastSlash
for i < len(s) {
// Look for ._ which could start a wrapper block.
if i+2 < len(s) && s[i] == '.' && s[i+1] == '_' {
// Find the closing _. or _ at end of string.
end := -1
for j := i + 2; j < len(s); j++ {
if s[j] == '/' {
break
}
if s[j] == '_' && (j+1 >= len(s) || s[j+1] == '.') {
end = j
break
}
}
if end != -1 {
// Record dot positions inside the wrapper block.
for j := i + 2; j < end; j++ {
if s[j] == '.' {
skipDots = append(skipDots, j)
}
}
i = end + 1
continue
}
}
i++
}
return skipDots
}
// isSkippedDot reports whether position i is in the sorted skipDots slice.
func isSkippedDot(skipDots []int, i int) bool {
for _, d := range skipDots {
if d == i {
return true
}
if d > i {
break
}
}
return false
}
// applyPrefixIdentifier validates and stores a prefix identifier.
// id is the value-only LowHigh (e.g. pointing at "v1.0.0" in p.s, not the full wrapper).
// Returns true if the identifier was recognized and applied.
func (pp *PathParser) applyPrefixIdentifier(component, prefix string, p *Path, id types.LowHigh[string]) bool {
value := strings.ToLower(p.s[id.Low:id.High])
switch prefix {
case prefixLanguage:
if pp.LanguageIndex != nil {
if _, ok := pp.LanguageIndex[value]; ok {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierPrefixLanguages = append(p.posIdentifierPrefixLanguages, len(p.identifiersKnown)-1)
return true
}
if pp.IsLangDisabled != nil && pp.IsLangDisabled(value) {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierPrefixLanguages = append(p.posIdentifierPrefixLanguages, len(p.identifiersKnown)-1)
p.disabled = true
return true
}
}
case prefixVersion:
if pp.ConfiguredDimensions != nil {
if idx := pp.ConfiguredDimensions.ConfiguredVersions.ResolveIndex(value); idx >= 0 {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierVersions = append(p.posIdentifierVersions, len(p.identifiersKnown)-1)
return true
}
}
case prefixRole:
if pp.ConfiguredDimensions != nil {
if idx := pp.ConfiguredDimensions.ConfiguredRoles.ResolveIndex(value); idx >= 0 {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierRoles = append(p.posIdentifierRoles, len(p.identifiersKnown)-1)
return true
}
}
case prefixOutputFormat:
if component == files.ComponentFolderLayouts && pp.IsOutputFormat != nil {
if pp.IsOutputFormat(value, "") {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierPrefixOutputFormat = len(p.identifiersKnown) - 1
return true
}
}
case prefixKind:
if component == files.ComponentFolderLayouts {
if kinds.GetKindMain(value) != "" {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierPrefixKind = len(p.identifiersKnown) - 1
return true
}
}
case prefixLayout:
if component == files.ComponentFolderLayouts {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierPrefixLayout = len(p.identifiersKnown) - 1
return true
}
}
return false
}
// PathParser parses and manages paths.
type PathParser struct {
// Maps the language code to its index in the languages/sites slice.
@@ -84,14 +222,36 @@ func NormalizePathStringBasic(s string) string {
func (pp *PathParser) SitesMatrixFromPath(p *Path) sitesmatrix.VectorStore {
pp.init()
lang := p.Lang()
v, _ := pp.sitesMatrixCache.GetOrCreate(lang, func() (sitesmatrix.VectorStore, error) {
langs := p.Langs()
versions := p.Versions()
roles := p.Roles()
lang := p.Lang() // First or dot-based language.
// Cache by the full site-selection identity derived from the path:
// languages, selected language, versions, and roles.
cacheKey := strings.Join(langs, ",") + "/" + lang + "/" + strings.Join(versions, ",") + "|" + strings.Join(roles, ",")
v, _ := pp.sitesMatrixCache.GetOrCreate(cacheKey, func() (sitesmatrix.VectorStore, error) {
builder := sitesmatrix.NewIntSetsBuilder(pp.ConfiguredDimensions)
if lang != "" {
if len(langs) > 0 {
for _, l := range langs {
if idx, ok := pp.LanguageIndex[l]; ok {
builder.WithLanguageIndices(idx)
}
}
} else if lang != "" {
if idx, ok := pp.LanguageIndex[lang]; ok {
builder.WithLanguageIndices(idx)
}
}
for _, version := range versions {
if idx := pp.ConfiguredDimensions.ConfiguredVersions.ResolveIndex(version); idx >= 0 {
builder.WithVersionIndices(idx)
}
}
for _, role := range roles {
if idx := pp.ConfiguredDimensions.ConfiguredRoles.ResolveIndex(role); idx >= 0 {
builder.WithRoleIndices(idx)
}
}
switch p.Component() {
case files.ComponentFolderContent:
@@ -108,13 +268,6 @@ func (pp *PathParser) SitesMatrixFromPath(p *Path) sitesmatrix.VectorStore {
return v
}
// ParseIdentity parses component c with path s into a StringIdentity.
func (pp *PathParser) ParseIdentity(c, s string) identity.StringIdentity {
p := pp.parsePooled(c, s)
defer putPath(p)
return identity.StringIdentity(p.IdentifierBase())
}
// ParseBaseAndBaseNameNoIdentifier parses component c with path s into a base and a base name without any identifier.
func (pp *PathParser) ParseBaseAndBaseNameNoIdentifier(c, s string) (string, string) {
p := pp.parsePooled(c, s)
@@ -197,12 +350,24 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot,
sid := p.s[id.Low:id.High]
if isCustomWrapperIdentifier(sid) {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierCustom = len(p.identifiersKnown) - 1
found = true
inner := sid[1 : len(sid)-1]
if prefix := parsePrefixIdentifier(inner); prefix != "" {
// Value-only LowHigh: skip leading _ + prefix, trailing _
valueID := types.LowHigh[string]{Low: id.Low + 1 + len(prefix), High: id.High - 1}
if pp.applyPrefixIdentifier(component, prefix, p, valueID) {
found = true
}
}
if !found {
p.identifiersKnown = append(p.identifiersKnown, id)
p.posIdentifierCustom = len(p.identifiersKnown) - 1
found = true
}
}
if len(p.identifiersKnown) == 0 {
if found {
// Already handled (e.g. prefix wrapper).
} else if len(p.identifiersKnown) == 0 {
// The first is always the extension.
p.identifiersKnown = append(p.identifiersKnown, id)
found = true
@@ -276,6 +441,18 @@ func (pp *PathParser) parseIdentifier(component, s string, p *Path, i, lastDot,
}
}
if found {
if isLast {
// The isLast identifier starts right at the container boundary.
// Treat it as part of the name (e.g. layout name "list" in list.no.html).
if p.posNameHigh <= 0 {
p.posNameHigh = lastDot
}
} else {
p.posNameHigh = i // The '.' before this identifier.
}
}
}
func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
@@ -300,10 +477,14 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
}
p.s = s
// Find dots inside ._..._. wrapper blocks that must be skipped.
skipDots := findWrapperDotPositions(s)
slashCount := 0
lastDot := 0
lastSlashIdx := strings.LastIndex(s, "/")
numDots := strings.Count(s[lastSlashIdx+1:], ".")
numDots := strings.Count(s[lastSlashIdx+1:], ".") - len(skipDots)
if strings.Contains(s, "/_shortcodes/") {
p.pathType = TypeShortcode
}
@@ -313,6 +494,9 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
switch c {
case '.':
if isSkippedDot(skipDots, i) {
continue
}
pp.parseIdentifier(component, s, p, i, lastDot, numDots, false)
lastDot = i
case '/':
@@ -331,13 +515,19 @@ func (pp *PathParser) doParse(component, s string, p *Path) (*Path, error) {
}
}
// Compute the name boundary.
if p.posNameHigh >= p.posContainerHigh {
p.posIdentifierName = types.LowHigh[string]{Low: p.posContainerHigh, High: p.posNameHigh}
} else {
p.posIdentifierName = types.LowHigh[string]{Low: p.posContainerHigh, High: len(p.s)}
}
if len(p.identifiersKnown) > 0 {
isContentComponent := p.component == files.ComponentFolderContent || p.component == files.ComponentFolderArchetypes
isContent := isContentComponent && pp.IsContentExt(p.Ext())
id := p.identifiersKnown[len(p.identifiersKnown)-1]
if id.Low > p.posContainerHigh {
b := p.s[p.posContainerHigh : id.Low-1]
if p.posIdentifierName.Low >= p.posContainerHigh && p.posIdentifierName.High > p.posIdentifierName.Low {
b := p.s[p.posIdentifierName.Low:p.posIdentifierName.High]
if isContent {
switch b {
case "index":
@@ -445,7 +635,22 @@ type Path struct {
posIdentifierLayout int
posIdentifierBaseof int
posIdentifierCustom int
disabled bool
// Prefix identifier positions (indices into identifiersKnown).
posIdentifierPrefixLanguages []int
posIdentifierVersions []int
posIdentifierRoles []int
posIdentifierPrefixOutputFormat int
posIdentifierPrefixKind int
posIdentifierPrefixLayout int
// Name boundary, computed during parse.
posIdentifierName types.LowHigh[string]
// Position of the dot before the leftmost known identifier.
// Set during parseIdentifier, used to compute posIdentifierName.
posNameHigh int
disabled bool
trimLeadingSlash bool
@@ -483,6 +688,14 @@ func (p *Path) reset() {
p.posIdentifierLayout = -1
p.posIdentifierBaseof = -1
p.posIdentifierCustom = -1
p.posIdentifierPrefixLanguages = p.posIdentifierPrefixLanguages[:0]
p.posIdentifierVersions = p.posIdentifierVersions[:0]
p.posIdentifierRoles = p.posIdentifierRoles[:0]
p.posIdentifierPrefixOutputFormat = -1
p.posIdentifierPrefixKind = -1
p.posIdentifierPrefixLayout = -1
p.posIdentifierName = types.LowHigh[string]{}
p.posNameHigh = -1
p.disabled = false
p.trimLeadingSlash = false
p.unnormalized = nil
@@ -574,16 +787,6 @@ func (p *Path) NameNoExt() string {
return p.s[p.posContainerHigh:]
}
// Name returns the last element of path without any language identifier.
func (p *Path) NameNoLang() string {
i := p.identifierIndex(p.posIdentifierLanguage)
if i == -1 {
return p.Name()
}
return p.s[p.posContainerHigh:p.identifiersKnown[i].Low-1] + p.s[p.identifiersKnown[i].High:]
}
// BaseNameNoIdentifier returns the logical base name for a resource without any identifier (e.g. no extension).
// For bundles this will be the containing directory's name, e.g. "blog".
func (p *Path) BaseNameNoIdentifier() string {
@@ -600,21 +803,7 @@ func (p *Path) NameNoIdentifier() string {
}
func (p *Path) nameLowHigh() types.LowHigh[string] {
if len(p.identifiersKnown) > 0 {
lastID := p.identifiersKnown[len(p.identifiersKnown)-1]
if p.posContainerHigh == lastID.Low {
// The last identifier is the name.
return lastID
}
return types.LowHigh[string]{
Low: p.posContainerHigh,
High: p.identifiersKnown[len(p.identifiersKnown)-1].Low - 1,
}
}
return types.LowHigh[string]{
Low: p.posContainerHigh,
High: len(p.s),
}
return p.posIdentifierName
}
// Dir returns all but the last element of path, typically the path's directory.
@@ -646,6 +835,9 @@ func (p *Path) Unnormalized() *Path {
// PathNoLang returns the Path but with any language identifier removed.
func (p *Path) PathNoLang() string {
if len(p.posIdentifierPrefixLanguages) > 0 {
return p.base(true, false)
}
if p.identifierIndex(p.posIdentifierLanguage) == -1 {
return p.Path()
}
@@ -725,11 +917,6 @@ func (p *Path) BaseReTyped(typ string) (d string) {
return
}
// BaseNoLeadingSlash returns the base path without the leading slash.
func (p *Path) BaseNoLeadingSlash() string {
return p.Base()[1:]
}
func (p *Path) base(preserveExt, isBundle bool) string {
if len(p.identifiersKnown) == 0 {
return p.norm(p.s)
@@ -767,29 +954,60 @@ func (p *Path) Ext() string {
}
func (p *Path) OutputFormat() string {
if p.posIdentifierPrefixOutputFormat != -1 {
return p.identifierAsString(p.posIdentifierPrefixOutputFormat)
}
return p.identifierAsString(p.posIdentifierOutputFormat)
}
func (p *Path) Kind() string {
if p.posIdentifierPrefixKind != -1 {
return p.identifierAsString(p.posIdentifierPrefixKind)
}
return p.identifierAsString(p.posIdentifierKind)
}
func (p *Path) Layout() string {
if p.posIdentifierPrefixLayout != -1 {
return p.identifierAsString(p.posIdentifierPrefixLayout)
}
return p.identifierAsString(p.posIdentifierLayout)
}
func (p *Path) Lang() string {
if len(p.posIdentifierPrefixLanguages) > 0 {
return p.identifierAsString(p.posIdentifierPrefixLanguages[0])
}
return p.identifierAsString(p.posIdentifierLanguage)
}
func (p *Path) Langs() []string {
return p.identifiersAsStrings(p.posIdentifierPrefixLanguages)
}
func (p *Path) Versions() []string {
return p.identifiersAsStrings(p.posIdentifierVersions)
}
func (p *Path) Roles() []string {
return p.identifiersAsStrings(p.posIdentifierRoles)
}
func (p *Path) identifiersAsStrings(positions []int) []string {
if len(positions) == 0 {
return nil
}
ids := make([]string, len(positions))
for i, pos := range positions {
ids[i] = p.identifierAsString(pos)
}
return ids
}
func (p *Path) Custom() string {
return strings.TrimSuffix(strings.TrimPrefix(p.identifierAsString(p.posIdentifierCustom), identifierCustomWrapper), identifierCustomWrapper)
}
func (p *Path) Identifier(i int) string {
return p.identifierAsString(i)
}
func (p *Path) Disabled() bool {
return p.disabled
}
+273 -18
View File
@@ -15,6 +15,7 @@ package paths
import (
"path/filepath"
"reflect"
"testing"
"github.com/gohugoio/hugo/hugofs/files"
@@ -25,7 +26,7 @@ import (
)
func newTestParser() *PathParser {
dims := sitesmatrix.NewTestingDimensions([]string{"en", "no", "fr"}, []string{"v1", "v2", "v3"}, []string{"admin", "editor", "viewer", "guest"})
dims := sitesmatrix.NewTestingDimensions([]string{"en", "no", "fr"}, []string{"v1", "v2", "v3", "v1.0.0"}, []string{"admin", "editor", "viewer", "guest"})
return &PathParser{
LanguageIndex: map[string]int{
@@ -141,7 +142,6 @@ func TestParse(t *testing.T) {
func(c *qt.C, p *Path) {
c.Assert(p.Name(), qt.Equals, "b.md")
c.Assert(p.Base(), qt.Equals, "/a/b")
c.Assert(p.BaseNoLeadingSlash(), qt.Equals, "a/b")
c.Assert(p.Section(), qt.Equals, "a")
c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b")
@@ -178,11 +178,9 @@ func TestParse(t *testing.T) {
func(c *qt.C, p *Path) {
c.Assert(p.Name(), qt.Equals, "b.a.b.no.txt")
c.Assert(p.NameNoIdentifier(), qt.Equals, "b.a.b")
c.Assert(p.NameNoLang(), qt.Equals, "b.a.b.txt")
c.Assert(p.Identifiers(), qt.DeepEquals, []string{"txt", "no"})
c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"b", "a", "b"})
c.Assert(p.Base(), qt.Equals, "/a/b.a.b.txt")
c.Assert(p.BaseNoLeadingSlash(), qt.Equals, "a/b.a.b.txt")
c.Assert(p.Path(), qt.Equals, "/a/b.a.b.no.txt")
c.Assert(p.PathNoLang(), qt.Equals, "/a/b.a.b.txt")
c.Assert(p.Ext(), qt.Equals, "txt")
@@ -223,7 +221,6 @@ func TestParse(t *testing.T) {
c.Assert(p.Lang(), qt.Equals, "")
c.Assert(p.NameNoExt(), qt.Equals, "index")
c.Assert(p.NameNoIdentifier(), qt.Equals, "index")
c.Assert(p.NameNoLang(), qt.Equals, "index.md")
c.Assert(p.Section(), qt.Equals, "")
},
},
@@ -245,7 +242,6 @@ func TestParse(t *testing.T) {
c.Assert(p.Lang(), qt.Equals, "no")
c.Assert(p.NameNoExt(), qt.Equals, "index.no")
c.Assert(p.NameNoIdentifier(), qt.Equals, "index")
c.Assert(p.NameNoLang(), qt.Equals, "index.md")
c.Assert(p.Path(), qt.Equals, "/a/b/index.no.md")
c.Assert(p.PathNoLang(), qt.Equals, "/a/b/index.md")
c.Assert(p.Section(), qt.Equals, "a")
@@ -265,7 +261,6 @@ func TestParse(t *testing.T) {
c.Assert(p.IsBundle(), qt.IsTrue)
c.Assert(p.IsLeafBundle(), qt.IsFalse)
c.Assert(p.NameNoExt(), qt.Equals, "_index.no")
c.Assert(p.NameNoLang(), qt.Equals, "_index.md")
},
},
{
@@ -389,6 +384,142 @@ func TestParse(t *testing.T) {
c.Assert(p.Custom(), qt.Equals, "myid")
},
},
{
"Prefix language",
"/a/b/p1._language_no_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Lang(), qt.Equals, "no")
c.Assert(p.Ext(), qt.Equals, "md")
c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "no"})
},
},
{
"Prefix version",
"/a/b/p1._version_v1_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1"})
c.Assert(p.Ext(), qt.Equals, "md")
c.Assert(p.Identifiers(), qt.DeepEquals, []string{"md", "v1"})
},
},
{
"Prefix version with dots",
"/a/b/p1._version_v1.0.0_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1.0.0"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Prefix role",
"/a/b/p1._role_admin_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Roles(), qt.DeepEquals, []string{"admin"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Multiple prefixes",
"/a/b/p1._language_no_._role_admin_._version_v2_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Lang(), qt.Equals, "no")
c.Assert(p.Roles(), qt.DeepEquals, []string{"admin"})
c.Assert(p.Versions(), qt.DeepEquals, []string{"v2"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Prefix version with dots and role",
"/a/b/p1._version_v1.0.0_._role_editor_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1.0.0"})
c.Assert(p.Roles(), qt.DeepEquals, []string{"editor"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Prefix with dot-based lang",
"/a/b/p1._version_v1_.no.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1"})
c.Assert(p.Lang(), qt.Equals, "no")
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Prefix leaf bundle with version",
"/a/b/index._version_v1_.md",
func(c *qt.C, p *Path) {
c.Assert(p.IsLeafBundle(), qt.IsTrue)
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1"})
c.Assert(p.Base(), qt.Equals, "/a/b")
c.Assert(p.BaseNameNoIdentifier(), qt.Equals, "b")
},
},
{
"Prefix branch bundle with role",
"/a/b/_index._role_admin_.md",
func(c *qt.C, p *Path) {
c.Assert(p.IsBranchBundle(), qt.IsTrue)
c.Assert(p.Roles(), qt.DeepEquals, []string{"admin"})
c.Assert(p.Base(), qt.Equals, "/a/b")
},
},
{
"Prefix with mixed case, unnormalized",
"/a/b/My Page._Version_V1_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/my-page")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v1"})
pp := p.Unnormalized()
c.Assert(pp.BaseNameNoIdentifier(), qt.Equals, "My Page")
},
},
{
"Multiple languages",
"/a/b/p1._language_en_._language_fr_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
// Right-to-left parse order.
c.Assert(p.Lang(), qt.Equals, "fr")
c.Assert(p.Langs(), qt.DeepEquals, []string{"fr", "en"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Multiple roles",
"/a/b/p1._role_guest_._role_admin_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Roles(), qt.DeepEquals, []string{"admin", "guest"})
c.Assert(p.Ext(), qt.Equals, "md")
},
},
{
"Multiple versions",
"/a/b/p1._version_v1_._version_v2_.md",
func(c *qt.C, p *Path) {
c.Assert(p.Base(), qt.Equals, "/a/b/p1")
c.Assert(p.Versions(), qt.DeepEquals, []string{"v2", "v1"})
},
},
{
"Unknown prefix not extracted",
"/a/b/p1._unknown_foo_.md",
func(c *qt.C, p *Path) {
// Unknown prefix should be left in path and treated as custom wrapper.
c.Assert(p.Custom(), qt.Equals, "unknown_foo")
c.Assert(p.Versions(), qt.IsNil)
c.Assert(p.Roles(), qt.IsNil)
},
},
}
parser := newTestParser()
for _, test := range tests {
@@ -607,20 +738,81 @@ func TestParseLayouts(t *testing.T) {
func(c *qt.C, p *Path) {
c.Assert(p.Lang(), qt.Equals, "")
c.Assert(p.Layout(), qt.Equals, "index")
c.Assert(p.NameNoLang(), qt.Equals, "index.xy.html")
c.Assert(p.PathNoLang(), qt.Equals, "/foo/index.xy.html")
c.Assert(p.Identifiers(), qt.DeepEquals, []string{"html", "index"})
c.Assert(p.IdentifiersUnknown(), qt.DeepEquals, []string{"xy"})
},
},
{
"Prefix language layout",
"/page._language_no_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Lang(), qt.Equals, "no")
c.Assert(p.Ext(), qt.Equals, "html")
c.Assert(p.OutputFormat(), qt.Equals, "html")
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"Prefix outputformat layout",
"/page._outputformat_amp_.html",
func(c *qt.C, p *Path) {
c.Assert(p.OutputFormat(), qt.Equals, "amp")
c.Assert(p.Ext(), qt.Equals, "html")
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"Prefix kind layout",
"/page._kind_section_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Kind(), qt.Equals, kinds.KindSection)
c.Assert(p.Ext(), qt.Equals, "html")
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"Prefix layout layout",
"/page._layout_list_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Layout(), qt.Equals, "list")
c.Assert(p.Ext(), qt.Equals, "html")
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"All prefix identifiers in layout",
"/page._language_fr_._kind_section_._outputformat_amp_._layout_list_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Lang(), qt.Equals, "fr")
c.Assert(p.Kind(), qt.Equals, kinds.KindSection)
c.Assert(p.OutputFormat(), qt.Equals, "amp")
c.Assert(p.Layout(), qt.Equals, "list")
c.Assert(p.Ext(), qt.Equals, "html")
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"Prefix version in layout",
"/page._version_v2_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Versions(), qt.DeepEquals, []string{"v2"})
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
{
"Prefix role in layout",
"/page._role_guest_.html",
func(c *qt.C, p *Path) {
c.Assert(p.Roles(), qt.DeepEquals, []string{"guest"})
c.Assert(p.Base(), qt.Equals, "/page.html")
},
},
}
parser := newTestParser()
for _, test := range tests {
c.Run(test.name, func(c *qt.C) {
if test.name != "Not lang" {
return
}
test.assert(c, parser.Parse(files.ComponentFolderLayouts, test.path))
})
}
@@ -635,22 +827,85 @@ func TestHasExt(t *testing.T) {
c.Assert(HasExt("/a/b.c/d"), qt.IsFalse)
}
func BenchmarkParseIdentity(b *testing.B) {
parser := newTestParser()
for b.Loop() {
parser.ParseIdentity(files.ComponentFolderAssets, "/a/b.css")
}
}
func TestSitesMatrixFromPath(t *testing.T) {
c := qt.New(t)
parser := newTestParser()
p := parser.Parse(files.ComponentFolderContent, "/a/b/c.fr.md")
v := parser.SitesMatrixFromPath(p)
c.Assert(v.HasLanguage(2), qt.IsTrue)
c.Assert(v.LenVectors(), qt.Equals, 1)
c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 0, 0})
// With version prefix.
p = parser.Parse(files.ComponentFolderContent, "/a/b/c._version_v2_.fr.md")
v = parser.SitesMatrixFromPath(p)
c.Assert(v.HasLanguage(2), qt.IsTrue)
c.Assert(v.HasVersion(1), qt.IsTrue) // v2 is index 1
c.Assert(v.LenVectors(), qt.Equals, 1)
c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 1, 0})
// With version and role prefixes.
p = parser.Parse(files.ComponentFolderContent, "/a/b/c._version_v1_._role_editor_.fr.md")
v = parser.SitesMatrixFromPath(p)
c.Assert(v.HasLanguage(2), qt.IsTrue)
c.Assert(v.HasVersion(0), qt.IsTrue) // v1 is index 0
c.Assert(v.HasRole(1), qt.IsTrue) // editor is index 1
c.Assert(v.LenVectors(), qt.Equals, 1)
c.Assert(v.VectorSample(), qt.Equals, sitesmatrix.Vector{2, 0, 1})
// With multiple roles.
p = parser.Parse(files.ComponentFolderContent, "/a/b/c._role_guest_._role_admin_.fr.md")
v = parser.SitesMatrixFromPath(p)
c.Assert(v.HasLanguage(2), qt.IsTrue)
c.Assert(v.HasRole(0), qt.IsTrue) // admin is index 0
c.Assert(v.HasRole(3), qt.IsTrue) // guest is index 3
c.Assert(v.LenVectors(), qt.Equals, 2)
// With multiple languages via prefix.
p = parser.Parse(files.ComponentFolderContent, "/a/b/c._language_en_._language_fr_.md")
v = parser.SitesMatrixFromPath(p)
c.Assert(v.HasLanguage(0), qt.IsFalse) // no is index 0, not included
c.Assert(v.HasLanguage(1), qt.IsTrue) // en is index 1
c.Assert(v.HasLanguage(2), qt.IsTrue) // fr is index 2
c.Assert(v.LenVectors(), qt.Equals, 2)
}
func FuzzParsePath(f *testing.F) {
componentPaths := []struct {
component string
path string
}{
{files.ComponentFolderContent, "/a/b/c.fr.md"},
{files.ComponentFolderContent, "/a/b/c._version_v2_.fr.md"},
{files.ComponentFolderContent, "/a/b/c._version_v1_._role_editor_.fr.md"},
{files.ComponentFolderContent, "/a/b/c._role_guest_._role_admin_.fr.md"},
{files.ComponentFolderContent, "/a/b/c._language_en_._language_fr_.md"},
{files.ComponentFolderLayouts, "/list.no.html"},
{files.ComponentFolderLayouts, "/page._language_fr_._kind_section_._outputformat_amp_._layout_list_.html"},
}
for _, cp := range componentPaths {
f.Add(cp.component, cp.path)
}
parser := newTestParser()
f.Fuzz(func(t *testing.T, c, s string) {
p := parser.Parse(c, s)
if p == nil {
t.Fatalf("Parse returned nil for path: %q", s)
}
// Execute all the methods using reflection to ensure they don't panic.
v := reflect.ValueOf(p)
for i := 0; i < v.NumMethod(); i++ {
method := v.Type().Method(i)
if method.Type.NumIn() == 1 {
method.Func.Call([]reflect.Value{v})
}
}
})
}
func BenchmarkSitesMatrixFromPath(b *testing.B) {
+66
View File
@@ -79,6 +79,72 @@ disablePathToLower = true
b.AssertFileContent("public/fr/MySection/MyBundle/index.html", "fr|Single")
}
func TestPrefixIdentifiersContent(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = true
[languages]
[languages.en]
weight = 1
[languages.fr]
weight = 2
-- content/p1.md --
---
title: p1 default
---
-- content/p1._language_fr_.md --
---
title: p1 french
---
-- layouts/single.html --
{{ .Language.Lang }}|{{ .Title }}|Single.
-- layouts/list.html --
List
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/en/p1/index.html", "en|p1 default|Single")
b.AssertFileContent("public/fr/p1/index.html", "fr|p1 french|Single")
}
func TestPrefixIdentifiersLayouts(t *testing.T) {
t.Parallel()
files := `
-- hugo.toml --
disableKinds = ["taxonomy", "term"]
defaultContentLanguage = "en"
defaultContentLanguageInSubdir = true
[languages]
[languages.en]
weight = 1
[languages.fr]
weight = 2
-- content/p1.md --
---
title: p1
---
-- content/p1.fr.md --
---
title: p1 fr
---
-- layouts/single.html --
default|{{ .Title }}|
-- layouts/single._language_fr_.html --
french layout|{{ .Title }}|
-- layouts/list.html --
List
`
b := hugolib.Test(t, files)
b.AssertFileContent("public/en/p1/index.html", "default|p1|")
b.AssertFileContent("public/fr/p1/index.html", "french layout|p1 fr|")
}
func TestIssue13596(t *testing.T) {
t.Parallel()
@@ -0,0 +1,2 @@
go test fuzz v1
string("fr..")
-58
View File
@@ -102,64 +102,6 @@ func AddContextRoot(baseURL, relativePath string) string {
return newPath
}
// URLizeAn
// PrettifyURL takes a URL string and returns a semantic, clean URL.
func PrettifyURL(in string) string {
x := PrettifyURLPath(in)
if path.Base(x) == "index.html" {
return path.Dir(x)
}
if in == "" {
return "/"
}
return x
}
// PrettifyURLPath takes a URL path to a content and converts it
// to enable pretty URLs.
//
// /section/name.html becomes /section/name/index.html
// /section/name/ becomes /section/name/index.html
// /section/name/index.html becomes /section/name/index.html
func PrettifyURLPath(in string) string {
return prettifyPath(in, pb)
}
// Uglify does the opposite of PrettifyURLPath().
//
// /section/name/index.html becomes /section/name.html
// /section/name/ becomes /section/name.html
// /section/name.html becomes /section/name.html
func Uglify(in string) string {
if path.Ext(in) == "" {
if len(in) < 2 {
return "/"
}
// /section/name/ -> /section/name.html
return path.Clean(in) + ".html"
}
name, ext := fileAndExt(in, pb)
if name == "index" {
// /section/name/index.html -> /section/name.html
d := path.Dir(in)
if len(d) > 1 {
return d + ext
}
return in
}
// /.xml -> /index.xml
if name == "" {
return path.Dir(in) + "index" + ext
}
// /section/name.html -> /section/name.html
return path.Clean(in)
}
// URLEscape escapes unicode letters.
func URLEscape(uri string) string {
// escape unicode letters
-34
View File
@@ -15,8 +15,6 @@ package paths
import (
"testing"
qt "github.com/frankban/quicktest"
)
func TestMakePermalink(t *testing.T) {
@@ -66,35 +64,3 @@ func TestAddContextRoot(t *testing.T) {
}
}
}
func TestPretty(t *testing.T) {
c := qt.New(t)
c.Assert("/section/name/index.html", qt.Equals, PrettifyURLPath("/section/name.html"))
c.Assert("/section/sub/name/index.html", qt.Equals, PrettifyURLPath("/section/sub/name.html"))
c.Assert("/section/name/index.html", qt.Equals, PrettifyURLPath("/section/name/"))
c.Assert("/section/name/index.html", qt.Equals, PrettifyURLPath("/section/name/index.html"))
c.Assert("/index.html", qt.Equals, PrettifyURLPath("/index.html"))
c.Assert("/name/index.xml", qt.Equals, PrettifyURLPath("/name.xml"))
c.Assert("/", qt.Equals, PrettifyURLPath("/"))
c.Assert("/", qt.Equals, PrettifyURLPath(""))
c.Assert("/section/name", qt.Equals, PrettifyURL("/section/name.html"))
c.Assert("/section/sub/name", qt.Equals, PrettifyURL("/section/sub/name.html"))
c.Assert("/section/name", qt.Equals, PrettifyURL("/section/name/"))
c.Assert("/section/name", qt.Equals, PrettifyURL("/section/name/index.html"))
c.Assert("/", qt.Equals, PrettifyURL("/index.html"))
c.Assert("/name/index.xml", qt.Equals, PrettifyURL("/name.xml"))
c.Assert("/", qt.Equals, PrettifyURL("/"))
c.Assert("/", qt.Equals, PrettifyURL(""))
}
func TestUgly(t *testing.T) {
c := qt.New(t)
c.Assert("/section/name.html", qt.Equals, Uglify("/section/name.html"))
c.Assert("/section/sub/name.html", qt.Equals, Uglify("/section/sub/name.html"))
c.Assert("/section/name.html", qt.Equals, Uglify("/section/name/"))
c.Assert("/section/name.html", qt.Equals, Uglify("/section/name/index.html"))
c.Assert("/index.html", qt.Equals, Uglify("/index.html"))
c.Assert("/name.xml", qt.Equals, Uglify("/name.xml"))
c.Assert("/", qt.Equals, Uglify("/"))
c.Assert("/", qt.Equals, Uglify(""))
}
-5
View File
@@ -134,11 +134,6 @@ func (l LowHigh[S]) Value(source S) S {
// This is only used for debugging purposes.
var InvocationCounter atomic.Int64
// NewTrue returns a pointer to b.
func NewBool(b bool) *bool {
return &b
}
// WeightProvider provides a weight.
type WeightProvider interface {
Weight() int
+24 -52
View File
@@ -181,7 +181,7 @@ type Config struct {
Minify minifiers.MinifyConfig `mapstructure:"-"`
// Permalink configuration.
Permalinks map[string]map[string]string `mapstructure:"-"`
Permalinks page.PermalinksConfig `mapstructure:"-"`
// Taxonomy configuration.
Taxonomies map[string]string `mapstructure:"-"`
@@ -458,35 +458,6 @@ func (c *Config) CompileConfig(logger loggers.Logger) error {
}
}
// Legacy privacy values.
if c.Privacy.Twitter.Disable {
hugo.DeprecateWithLogger("project config key privacy.twitter.disable", "Use privacy.x.disable instead.", "v0.141.0", logger.Logger())
c.Privacy.X.Disable = c.Privacy.Twitter.Disable
}
if c.Privacy.Twitter.EnableDNT {
hugo.DeprecateWithLogger("project config key privacy.twitter.enableDNT", "Use privacy.x.enableDNT instead.", "v0.141.0", logger.Logger())
c.Privacy.X.EnableDNT = c.Privacy.Twitter.EnableDNT
}
if c.Privacy.Twitter.Simple {
hugo.DeprecateWithLogger("project config key privacy.twitter.simple", "Use privacy.x.simple instead.", "v0.141.0", logger.Logger())
c.Privacy.X.Simple = c.Privacy.Twitter.Simple
}
// Legacy services values.
if c.Services.Twitter.DisableInlineCSS {
hugo.DeprecateWithLogger("project config key services.twitter.disableInlineCSS", "Use services.x.disableInlineCSS instead.", "v0.141.0", logger.Logger())
c.Services.X.DisableInlineCSS = c.Services.Twitter.DisableInlineCSS
}
// Legacy permalink tokens
vs := fmt.Sprintf("%v", c.Permalinks)
if strings.Contains(vs, ":filename") {
hugo.DeprecateWithLogger("the \":filename\" permalink token", "Use \":contentbasename\" instead.", "0.144.0", logger.Logger())
}
if strings.Contains(vs, ":slugorfilename") {
hugo.DeprecateWithLogger("the \":slugorfilename\" permalink token", "Use \":slugorcontentbasename\" instead.", "0.144.0", logger.Logger())
}
// Legacy render hook values.
alternativeDetails := fmt.Sprintf(
"Set to %q if previous value was false, or set to %q if previous value was true.",
@@ -1034,29 +1005,30 @@ func (c Configs) GetByLang(lang string) config.AllProvider {
func newDefaultConfig() *Config {
return &Config{
Taxonomies: map[string]string{"tag": "tags", "category": "categories"},
Sitemap: config.SitemapConfig{Priority: -1, Filename: "sitemap.xml"},
RootConfig: RootConfig{
Environment: hugo.EnvironmentProduction,
TitleCaseStyle: "AP",
PluralizeListTitles: true,
CapitalizeListTitles: true,
StaticDir: []string{"static"},
SummaryLength: 70,
Timeout: "60s",
Taxonomies: map[string]string{"tag": "tags", "category": "categories"},
Sitemap: config.SitemapConfig{Priority: -1, Filename: "sitemap.xml"},
Environment: hugo.EnvironmentProduction,
TitleCaseStyle: "AP",
PluralizeListTitles: true,
CapitalizeListTitles: true,
StaticDir: []string{"static"},
SummaryLength: 70,
Timeout: "60s",
CommonDirs: config.CommonDirs{
ArcheTypeDir: "archetypes",
ContentDir: "content",
ResourceDir: "resources",
PublishDir: "public",
ThemesDir: "themes",
AssetDir: "assets",
LayoutDir: "layouts",
I18nDir: "i18n",
DataDir: "data",
},
},
//lint:ignore SA1019 Keep as adapter for now.
ArcheTypeDir: "archetypes",
ContentDir: "content",
ResourceDir: "resources",
PublishDir: "public",
ThemesDir: "themes",
//lint:ignore SA1019 Keep as adapter for now.
AssetDir: "assets",
//lint:ignore SA1019 Keep as adapter for now.
LayoutDir: "layouts",
//lint:ignore SA1019 Keep as adapter for now.
I18nDir: "i18n",
//lint:ignore SA1019 Keep as adapter for now.
DataDir: "data",
}
}
+38 -16
View File
@@ -9,7 +9,6 @@ import (
"github.com/gohugoio/hugo/common/hugo"
"github.com/gohugoio/hugo/config/allconfig"
"github.com/gohugoio/hugo/hugolib"
gc "github.com/gohugoio/hugo/markup/goldmark/goldmark_config"
"github.com/gohugoio/hugo/media"
)
@@ -26,11 +25,13 @@ weight = 2
[[module.mounts]]
source = 'content/en'
target = 'content'
lang = 'en'
[module.mounts.sites.matrix]
languages = 'en'
[[module.mounts]]
source = 'content/sv'
target = 'content'
lang = 'sv'
[module.mounts.sites.matrix]
languages = 'sv'
-- content/en/p1.md --
---
title: "p1"
@@ -360,25 +361,46 @@ weight = 3
// Issue 13535
// We changed enablement of the embedded link and image render hooks from
// booleans to enums in v0.148.0.
// booleans to enums in v0.148.0. This should throw error with v0.163.0 and later.
func TestLegacyEmbeddedRenderHookEnablement(t *testing.T) {
files := `
-- hugo.toml --
[markup.goldmark.renderHooks.image]
#KEY_VALUE
#KEY_VALUE_IMAGE
[markup.goldmark.renderHooks.link]
#KEY_VALUE
#KEY_VALUE_LINK
`
f := strings.ReplaceAll(files, "#KEY_VALUE", "enableDefault = false")
b := hugolib.Test(t, f)
c := b.H.Configs.Base.Markup.Goldmark.RenderHooks
b.Assert(c.Link.UseEmbedded, qt.Equals, gc.RenderHookUseEmbeddedNever)
b.Assert(c.Image.UseEmbedded, qt.Equals, gc.RenderHookUseEmbeddedNever)
f = strings.ReplaceAll(files, "#KEY_VALUE", "enableDefault = true")
b = hugolib.Test(t, f)
c = b.H.Configs.Base.Markup.Goldmark.RenderHooks
b.Assert(c.Link.UseEmbedded, qt.Equals, gc.RenderHookUseEmbeddedFallback)
b.Assert(c.Image.UseEmbedded, qt.Equals, gc.RenderHookUseEmbeddedFallback)
replacer := strings.NewReplacer(
"#KEY_VALUE_IMAGE", "enableDefault = false",
"#KEY_VALUE_LINK", "",
)
f := replacer.Replace(files)
b, _ := hugolib.TestE(t, f)
b.AssertLogContains("ERROR deprecated")
replacer = strings.NewReplacer(
"#KEY_VALUE_IMAGE", "enableDefault = true",
"#KEY_VALUE_LINK", "",
)
f = replacer.Replace(files)
b, _ = hugolib.TestE(t, f)
b.AssertLogContains("ERROR deprecated")
replacer = strings.NewReplacer(
"#KEY_VALUE_IMAGE", "",
"#KEY_VALUE_LINK", "enableDefault = false",
)
f = replacer.Replace(files)
b, _ = hugolib.TestE(t, f)
b.AssertLogContains("ERROR deprecated")
replacer = strings.NewReplacer(
"#KEY_VALUE_IMAGE", "",
"#KEY_VALUE_LINK", "enableDefault = true",
)
f = replacer.Replace(files)
b, _ = hugolib.TestE(t, f)
b.AssertLogContains("ERROR deprecated")
}
+14 -2
View File
@@ -24,6 +24,7 @@ import (
"github.com/gohugoio/hugo/cache/httpcache"
"github.com/gohugoio/hugo/common/hmaps"
"github.com/gohugoio/hugo/common/hstrings"
"github.com/gohugoio/hugo/common/hugo"
"github.com/gohugoio/hugo/common/loggers"
"github.com/gohugoio/hugo/common/types"
"github.com/gohugoio/hugo/config"
@@ -87,8 +88,18 @@ var allDecoderSetups = map[string]decodeWeight{
"imaging": {
key: "imaging",
decode: func(d decodeWeight, p decodeConfig) error {
m := p.p.GetStringMap(d.key)
if _, found := m["quality"]; found {
hugo.DeprecateWithLogger("project config key imaging.quality", "Set the quality per format instead with imaging.jpeg.quality, imaging.webp.quality and/or imaging.avif.quality.", "v0.163.0", p.logger.Logger())
}
if _, found := m["compression"]; found {
hugo.DeprecateWithLogger("project config key imaging.compression", "Set the compression type per format instead with imaging.webp.compression and/or imaging.avif.compression.", "v0.163.0", p.logger.Logger())
}
if _, found := m["hint"]; found {
hugo.DeprecateWithLogger("project config key imaging.hint", "Set the hint per format instead with imaging.webp.hint and/or imaging.avif.hint.", "v0.163.0", p.logger.Logger())
}
var err error
p.c.Imaging, err = images.DecodeConfig(p.p.GetStringMap(d.key))
p.c.Imaging, err = images.DecodeConfig(m)
return err
},
},
@@ -307,9 +318,10 @@ var allDecoderSetups = map[string]decodeWeight{
key: "permalinks",
decode: func(d decodeWeight, p decodeConfig) error {
var err error
p.c.Permalinks, err = page.DecodePermalinksConfig(p.p.GetStringMap(d.key))
p.c.Permalinks, err = page.DecodePermalinksConfig(p.p.Get(d.key))
return err
},
getInitializer: func(c *Config) configInitializer { return c.Permalinks },
},
"sitemap": {
key: "sitemap",
+9
View File
@@ -67,6 +67,15 @@ func (c ConfigLanguage) BaseURLLiveReload() urls.BaseURL {
return c.config.C.BaseURLLiveReload
}
// AllBaseURLs returns the BaseURL for each enabled language, ordered as Languages().
func (c ConfigLanguage) AllBaseURLs() []urls.BaseURL {
bs := make([]urls.BaseURL, len(c.m.configLangs))
for i, p := range c.m.configLangs {
bs[i] = p.BaseURL()
}
return bs
}
func (c ConfigLanguage) Environment() string {
return c.config.Environment
}
+1 -1
View File
@@ -19,7 +19,7 @@ import (
"github.com/gohugoio/hugo/docshelper"
)
// This is is just some helpers used to create some JSON used in the Hugo docs.
// This is just a helper used to create some JSON used in the Hugo docs.
func init() {
docsProvider := func() docshelper.DocProvider {
cfg := config.New()
+8 -4
View File
@@ -19,19 +19,23 @@ defaultContentLanguage = 'en'
[[module.mounts]]
source = 'content/en'
target = 'content/en'
lang = 'en'
[module.mounts.sites.matrix]
languages = 'en'
[[module.mounts]]
source = 'content/nn'
target = 'content/nn'
lang = 'nn'
[module.mounts.sites.matrix]
languages = 'nn'
[[module.mounts]]
source = 'content/no'
target = 'content/no'
lang = 'no'
[module.mounts.sites.matrix]
languages = 'no'
[[module.mounts]]
source = 'content/sv'
target = 'content/sv'
lang = 'sv'
[module.mounts.sites.matrix]
languages = 'sv'
[[module.mounts]]
source = 'layouts'
target = 'layouts'
+1 -1
View File
@@ -88,7 +88,7 @@ var defaultBuild = BuildConfig{
CacheBusters: []CacheBuster{
{
Source: `(postcss|tailwind)\.config\.js`,
Source: `(postcss|tailwind)\.config\.(js|mjs|cjs)`,
Target: cssTargetCachebusterRe,
},
},
+13 -1
View File
@@ -173,9 +173,11 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid
}
var keyPath []string
var unwrapKey string
if !DefaultConfigNamesSet[name] {
// Can be params.jp, menus.en etc.
name, lang := paths.FileAndExtNoDelimiter(name)
unwrapKey = name
keyPath = []string{name}
@@ -190,13 +192,23 @@ func LoadConfigFromDir(sourceFs afero.Fs, configDir, environment string) (Provid
}
}
// TOML/YAML can't represent a headless top-level array, so allow a
// file to wrap its content under a single top-level key matching
// the basename (e.g. cascade.yaml with `cascade: [...]`).
var itemValue any = item
if unwrapKey != "" && len(item) == 1 {
if inner, ok := item[unwrapKey]; ok {
itemValue = inner
}
}
root := item
if len(keyPath) > 0 {
root = make(map[string]any)
m := root
for i, key := range keyPath {
if i >= len(keyPath)-1 {
m[key] = item
m[key] = itemValue
} else {
nm := make(map[string]any)
m[key] = nm
-14
View File
@@ -30,7 +30,6 @@ type Config struct {
Disqus Disqus
GoogleAnalytics GoogleAnalytics
Instagram Instagram
Twitter Twitter `json:"-"` // deprecated in favor of X in v0.141.0
Vimeo Vimeo
YouTube YouTube
X X
@@ -59,19 +58,6 @@ type Instagram struct {
Simple bool
}
// Twitter holds the privacy configuration settings related to the Twitter shortcode.
// Deprecated in favor of X in v0.141.0.
type Twitter struct {
Service `mapstructure:",squash"`
// When set to true, the Tweet and its embedded page on your site are not used
// for purposes that include personalized suggestions and personalized ads.
EnableDNT bool
// If simple mode is enabled, a static and no-JS version of the Tweet will be built.
Simple bool
}
// Vimeo holds the privacy configuration settings related to the Vimeo shortcode.
type Vimeo struct {
Service `mapstructure:",squash"`
+172 -9
View File
@@ -18,7 +18,11 @@ import (
"encoding/json"
"errors"
"fmt"
"net/netip"
"net/url"
"reflect"
"slices"
"strconv"
"strings"
"github.com/gohugoio/hugo/common/herrors"
@@ -35,12 +39,11 @@ const securityConfigKey = "security"
var DefaultConfig = Config{
Exec: Exec{
Allow: MustNewWhitelist(
"^(dart-)?sass(-embedded)?$", // sass, dart-sass, dart-sass-embedded.
"^go$", // for Go Modules
"^git$", // For Git info
"^npx$", // used by all Node tools (Babel, PostCSS).
"^(dart-)?sass$", // sass, dart-sass
"^go$", // for Go Modules
"^git$", // For Git info
"^node$", // Used as the runtime for Node tools.
"^postcss$",
"^tailwindcss$",
),
// These have been tested to work with Hugo's external programs
// on Windows, Linux and MacOS.
@@ -50,9 +53,34 @@ var DefaultConfig = Config{
Getenv: MustNewWhitelist("^HUGO_", "^CI$"),
},
HTTP: HTTP{
URLs: MustNewWhitelist(".*"),
// Allow URLs whose host starts with a letter (the typical
// "https://example.com" shape), deny anything that looks like
// localhost, and deny URLs with userinfo ("http://user@...") to
// foil the obvious SSRF bypass. Public IP literals are collateral
// blocks; users who need them can override security.http.urls.
URLs: MustNewWhitelist(
`(?i)^https?://[a-z0-9]`,
`! ^https?://\d+\.`,
`! (?i)localhost`,
`! (?i)^https?://[^/?#]*@`,
),
Methods: MustNewWhitelist("(?i)GET|POST"),
},
Node: Node{
Permissions: NodePermissions{
Disable: false,
AllowRead: []string{"."},
AllowWrite: []string{}, // No write access by default.
AllowAddons: []string{"tailwindcss"}, // tailwindcss does not work without addon permissions.
AllowWorker: []string{"tailwindcss"}, // tailwindcss needs worker access.
AllowChildProcess: []string{"tailwindcss"}, // detect-libc spawns getconf on some Linux setups.
},
},
// Content under /content is treated as untrusted. text/html bodies are
// emitted verbatim and are an XSS sink, so they are denied by default.
// Everything else is allowed because Whitelist treats a deny-only list as
// "allow anything not denied".
AllowContent: MustNewWhitelist("! ^text/html$"),
}
// Config is the top level security config.
@@ -68,6 +96,15 @@ type Config struct {
// Restricts access to resources.GetRemote, getJSON, getCSV.
HTTP HTTP `json:"http"`
// Node holds Node.js security settings.
Node Node `json:"node"`
// AllowContent restricts which content media types may be used for
// pages under /content. Matched against the full MIME type (e.g.
// "text/html"). text/html is denied by default because Hugo emits the
// body verbatim.
AllowContent Whitelist `json:"allowContent"`
// Allow inline shortcodes
EnableInlineShortcodes bool `json:"enableInlineShortcodes"`
}
@@ -95,6 +132,30 @@ type HTTP struct {
MediaTypes Whitelist `json:"mediaTypes"`
}
// Node holds Node.js security settings.
type Node struct {
// Permissions configures Node's --permission flag for file system access control.
Permissions NodePermissions `json:"permissions"`
}
// NodePermissions configures the Node.js permission model (--permission).
// Paths are relative to the working directory; "." means the working directory itself.
// Use "*" to allow all paths.
type NodePermissions struct {
// Disable turns off the Node.js permission model entirely.
Disable bool `json:"disable"`
AllowRead []string `json:"allowRead"`
AllowWrite []string `json:"allowWrite"`
AllowAddons []string `json:"allowAddons"`
AllowWorker []string `json:"allowWorker"`
AllowChildProcess []string `json:"allowChildProcess"`
}
// IsEnabled reports whether the Node.js permission model is active.
func (p NodePermissions) IsEnabled() bool {
return !p.Disable
}
// ToTOML converts c to TOML with [security] as the root.
func (c Config) ToTOML() string {
sec := c.ToSecurityMap()
@@ -130,17 +191,102 @@ func (c Config) CheckAllowedGetEnv(name string) error {
return nil
}
func (c Config) CheckAllowedHTTPURL(url string) error {
if !c.HTTP.URLs.Accept(url) {
func (c Config) CheckAllowedHTTPURL(u string) error {
deny := func(name string) error {
return &AccessDeniedError{
name: url,
name: name,
path: "security.http.urls",
policies: c.ToTOML(),
}
}
if !c.HTTP.URLs.Accept(u) {
return deny(u)
}
// A host can be written as an integer/hex/octal IPv4 literal
// (e.g. http://2130706433/ == http://127.0.0.1/) that has no dot and
// thus slips past IP-literal deny rules. Re-check the canonical form so
// the policy treats every encoding of the same address alike.
if canon, ok := canonicalIPv4URL(u); ok && !c.HTTP.URLs.Accept(canon) {
return deny(u)
}
return nil
}
// canonicalIPv4URL rewrites an integer/hex/octal IPv4 host in rawURL to its
// canonical dotted-decimal form (inet_aton semantics), returning ok=false when
// the host is a normal name or already dotted-decimal.
func canonicalIPv4URL(rawURL string) (string, bool) {
u, err := url.Parse(rawURL)
if err != nil {
return "", false
}
host := u.Hostname()
ip, ok := parseInetAtonIPv4(host)
if !ok || ip.String() == host {
return "", false
}
if port := u.Port(); port != "" {
u.Host = ip.String() + ":" + port
} else {
u.Host = ip.String()
}
return u.String(), true
}
// parseInetAtonIPv4 parses the inet_aton IPv4 forms (14 dot-separated parts,
// each decimal, octal "0..." or hex "0x..."), e.g. "2130706433", "0x7f.0.0.1".
func parseInetAtonIPv4(host string) (netip.Addr, bool) {
if host == "" {
return netip.Addr{}, false
}
parts := strings.Split(host, ".")
if len(parts) > 4 {
return netip.Addr{}, false
}
vals := make([]uint64, len(parts))
for i, p := range parts {
v, ok := parseCInt(p)
if !ok {
return netip.Addr{}, false
}
vals[i] = v
}
maxLast := []uint64{0xffffffff, 0xffffff, 0xffff, 0xff}[len(parts)-1]
var n uint64
for i, v := range vals {
if i == len(parts)-1 {
if v > maxLast {
return netip.Addr{}, false
}
n |= v
} else {
if v > 0xff {
return netip.Addr{}, false
}
n |= v << (8 * (3 - i))
}
}
return netip.AddrFrom4([4]byte{byte(n >> 24), byte(n >> 16), byte(n >> 8), byte(n)}), true
}
func parseCInt(s string) (uint64, bool) {
base := 10
switch {
case len(s) >= 2 && s[0] == '0' && (s[1] == 'x' || s[1] == 'X'):
base, s = 16, s[2:]
case len(s) >= 2 && s[0] == '0':
base, s = 8, s[1:]
}
if s == "" {
return 0, false
}
v, err := strconv.ParseUint(s, base, 64)
if err != nil {
return 0, false
}
return v, true
}
func (c Config) CheckAllowedHTTPMethod(method string) error {
if !c.HTTP.Methods.Accept(method) {
return &AccessDeniedError{
@@ -152,6 +298,17 @@ func (c Config) CheckAllowedHTTPMethod(method string) error {
return nil
}
func (c Config) CheckAllowedContent(mediaType string) error {
if !c.AllowContent.Accept(mediaType) {
return &AccessDeniedError{
name: mediaType,
path: "security.allowContent",
policies: c.ToTOML(),
}
}
return nil
}
// ToSecurityMap converts c to a map with 'security' as the root key.
func (c Config) ToSecurityMap() map[string]any {
// Take it to JSON and back to get proper casing etc.
@@ -170,6 +327,12 @@ func (c Config) ToSecurityMap() map[string]any {
// DecodeConfig creates a privacy Config from a given Hugo configuration.
func DecodeConfig(cfg config.Provider) (Config, error) {
sc := DefaultConfig
// Deep copy slices to prevent mapstructure from mutating DefaultConfig.
sc.Node.Permissions.AllowRead = slices.Clone(sc.Node.Permissions.AllowRead)
sc.Node.Permissions.AllowWrite = slices.Clone(sc.Node.Permissions.AllowWrite)
sc.Node.Permissions.AllowAddons = slices.Clone(sc.Node.Permissions.AllowAddons)
sc.Node.Permissions.AllowWorker = slices.Clone(sc.Node.Permissions.AllowWorker)
sc.Node.Permissions.AllowChildProcess = slices.Clone(sc.Node.Permissions.AllowChildProcess)
if cfg.IsSet(securityConfigKey) {
m := cfg.GetStringMap(securityConfigKey)
dec, err := mapstructure.NewDecoder(
+260 -3
View File
@@ -135,7 +135,7 @@ func TestToTOML(t *testing.T) {
got := DefaultConfig.ToTOML()
c.Assert(got, qt.Equals,
"[security]\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass(-embedded)?$', '^go$', '^git$', '^npx$', '^postcss$', '^tailwindcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['.*']",
"[security]\n allowContent = ['! ^text/html$']\n enableInlineShortcodes = false\n\n [security.exec]\n allow = ['^(dart-)?sass$', '^go$', '^git$', '^node$', '^postcss$']\n osEnv = ['(?i)^((HTTPS?|NO)_PROXY|PATH(EXT)?|APPDATA|TE?MP|TERM|GO\\w+|(XDG_CONFIG_)?HOME|USERPROFILE|SSH_AUTH_SOCK|DISPLAY|LANG|SYSTEMDRIVE|PROGRAMDATA)$']\n\n [security.funcs]\n getenv = ['^HUGO_', '^CI$']\n\n [security.http]\n methods = ['(?i)GET|POST']\n urls = ['(?i)^https?://[a-z0-9]', '! ^https?://\\d+\\.', '! (?i)localhost', '! (?i)^https?://[^/?#]*@']\n\n [security.node]\n [security.node.permissions]\n allowAddons = ['tailwindcss']\n allowChildProcess = ['tailwindcss']\n allowRead = ['.']\n allowWorker = ['tailwindcss']\n allowWrite = []\n disable = false",
)
}
@@ -147,8 +147,8 @@ func TestDecodeConfigDefault(t *testing.T) {
c.Assert(err, qt.IsNil)
c.Assert(pc, qt.Not(qt.IsNil))
c.Assert(pc.Exec.Allow.Accept("a"), qt.IsFalse)
c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsTrue)
c.Assert(pc.Exec.Allow.Accept("Npx"), qt.IsFalse)
c.Assert(pc.Exec.Allow.Accept("node"), qt.IsTrue)
c.Assert(pc.Exec.Allow.Accept("npx"), qt.IsFalse)
c.Assert(pc.HTTP.URLs.Accept("https://example.org"), qt.IsTrue)
c.Assert(pc.HTTP.Methods.Accept("POST"), qt.IsTrue)
@@ -164,4 +164,261 @@ func TestDecodeConfigDefault(t *testing.T) {
c.Assert(pc.Exec.OsEnv.Accept("a"), qt.IsFalse)
c.Assert(pc.Exec.OsEnv.Accept("e"), qt.IsFalse)
c.Assert(pc.Exec.OsEnv.Accept("MYSECRET"), qt.IsFalse)
c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"."})
c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{})
}
func TestCheckAllowedHTTPURLHardenedDefaultsIssue14792(t *testing.T) {
t.Parallel()
c := qt.New(t)
c.Run("Public URLs allowed by default", func(c *qt.C) {
c.Parallel()
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"https://example.org/",
"https://example.org:8443/foo",
"https://sub.example.org/path",
} {
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
}
})
c.Run("Private/loopback URLs denied by default", func(c *qt.C) {
c.Parallel()
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"http://localhost/",
"http://LOCALHOST:8080/",
"http://foo.localhost/",
"http://127.0.0.1/",
"http://127.1.2.3:8080/x",
"http://user:pass@127.0.0.1/", // userinfo must not sneak past the deny.
"http://10.0.0.1/",
"http://172.16.0.1/",
"http://192.168.1.1/",
"http://169.254.169.254/latest/meta-data/", // AWS/GCP metadata.
"http://0.0.0.0/",
"http://[::1]/",
"http://[fe80::1]/",
"http://[fc00::1]/",
// Public IP literals are blocked as collateral; users can override.
"http://93.184.216.34/",
"https://[2001:db8::1]/",
} {
err := pc.CheckAllowedHTTPURL(u)
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, qt.Commentf(u))
}
})
c.Run("Explicit user config bypasses hardening", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security.http]
urls = ['http://127\.0\.0\.1.*', 'http://localhost.*']
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.CheckAllowedHTTPURL("http://127.0.0.1:8080/foo"), qt.IsNil)
c.Assert(pc.CheckAllowedHTTPURL("http://localhost:1313/"), qt.IsNil)
})
c.Run("User can deny with the ! prefix", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security.http]
urls = ['.*', '! ^https?://evil\.example\.com']
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.CheckAllowedHTTPURL("https://good.example.com/"), qt.IsNil)
err = pc.CheckAllowedHTTPURL("https://evil.example.com/x")
c.Assert(err, qt.IsNotNil)
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`)
})
}
func TestCheckAllowedHTTPURLAtInPathIssue14825(t *testing.T) {
t.Parallel()
c := qt.New(t)
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"https://cdn.jsdelivr.net/npm/mermaid@latest/dist/mermaid.esm.min.mjs",
"https://unpkg.com/react@18/umd/react.production.min.js",
"https://example.org/foo@bar/baz",
} {
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
}
for _, u := range []string{
"http://user@127.0.0.1/",
"http://user:pass@example.org/",
"https://token@example.org/foo@bar",
} {
err := pc.CheckAllowedHTTPURL(u)
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
c.Assert(err, qt.ErrorMatches, `(?s).*is not whitelisted in policy "security\.http\.urls".*`, qt.Commentf(u))
}
}
func TestCheckAllowedHTTPURLDigitHostnameIssue14837(t *testing.T) {
t.Parallel()
c := qt.New(t)
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"https://1password.com/",
"https://37signals.com/foo",
} {
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
}
for _, u := range []string{
"http://127.0.0.1/",
"http://10.0.0.1/",
"http://192.168.1.1/",
"http://0.0.0.0/",
} {
err := pc.CheckAllowedHTTPURL(u)
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
}
}
// Integer/hex/octal IPv4 encodings must be denied just like their dotted-decimal
// literals; digit-leading hostnames must still be allowed. See issue 14856.
func TestCheckAllowedHTTPURLIntegerIPEncodings(t *testing.T) {
t.Parallel()
c := qt.New(t)
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, u := range []string{
"http://2130706433/", // 127.0.0.1 decimal
"http://2130706433:9777/x", // 127.0.0.1 decimal, port
"http://2852039166/", // 169.254.169.254 (cloud metadata)
"http://0x7f000001/", // 127.0.0.1 hex
"http://017700000001/", // 127.0.0.1 octal
"http://0x7f.0.0.1/", // 127.0.0.1 dotted hex
"http://0177.0.0.1/", // 127.0.0.1 dotted octal
"http://127.1/", // 127.0.0.1 short form
"http://0/", // 0.0.0.0
"http://0xa9fea9fe/", // 169.254.169.254 hex
} {
err := pc.CheckAllowedHTTPURL(u)
c.Assert(err, qt.IsNotNil, qt.Commentf(u))
}
for _, u := range []string{
"https://1password.com/",
"https://37signals.com/foo",
"https://3com.com/",
"https://0x.tools/",
} {
c.Assert(pc.CheckAllowedHTTPURL(u), qt.IsNil, qt.Commentf(u))
}
}
func TestCheckAllowedContent(t *testing.T) {
t.Parallel()
c := qt.New(t)
c.Run("text/html denied by default", func(c *qt.C) {
c.Parallel()
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
err = pc.CheckAllowedContent("text/html")
c.Assert(err, qt.IsNotNil)
c.Assert(err, qt.ErrorMatches, `(?s).*"text/html" is not whitelisted in policy "security\.allowContent".*`)
})
c.Run("Other content types allowed by default", func(c *qt.C) {
c.Parallel()
pc, err := DecodeConfig(config.New())
c.Assert(err, qt.IsNil)
for _, mt := range []string{
"text/markdown",
"text/asciidoc",
"text/x-org",
"text/rst",
"text/pandoc",
} {
c.Assert(pc.CheckAllowedContent(mt), qt.IsNil, qt.Commentf(mt))
}
})
c.Run("User can opt in to HTML", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security]
allowContent = ['.*']
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.CheckAllowedContent("text/html"), qt.IsNil)
})
}
func TestDecodeConfigNodePermissions(t *testing.T) {
c := qt.New(t)
c.Run("Custom paths", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security.node.permissions]
allowRead = ["/tmp", "."]
allowWrite = ["."]
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"/tmp", "."})
c.Assert(pc.Node.Permissions.AllowWrite, qt.DeepEquals, []string{"."})
})
c.Run("Disabled", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security.node.permissions]
disable = true
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsFalse)
})
c.Run("Wildcard", func(c *qt.C) {
c.Parallel()
tomlConfig := `
[security.node.permissions]
allowRead = ["*"]
allowWrite = ["*"]
`
cfg, err := config.FromConfigString(tomlConfig, "toml")
c.Assert(err, qt.IsNil)
pc, err := DecodeConfig(cfg)
c.Assert(err, qt.IsNil)
c.Assert(pc.Node.Permissions.IsEnabled(), qt.IsTrue)
c.Assert(pc.Node.Permissions.AllowRead, qt.DeepEquals, []string{"*"})
})
}
+45 -21
View File
@@ -18,18 +18,25 @@ import (
"fmt"
"regexp"
"strings"
"github.com/gohugoio/hugo/hugofs/hglob"
)
const (
acceptNoneKeyword = "none"
)
const acceptNoneKeyword = "none"
// Whitelist holds a whitelist.
//
// Patterns are regular expressions. A pattern prefixed with "! "
// (see hglob.NegationPrefix) is a deny rule: a name that matches any
// deny rule is rejected even if it matches an allow rule.
// A whitelist made up exclusively of deny rules implicitly allows
// names that do not match any of them.
type Whitelist struct {
acceptNone bool
patterns []*regexp.Regexp
allow []*regexp.Regexp
deny []*regexp.Regexp
// Store this for debugging/error reporting
// Store this for debugging/error reporting.
patternsStrings []string
}
@@ -44,14 +51,17 @@ func (w Whitelist) MarshalJSON() ([]byte, error) {
// NewWhitelist creates a new Whitelist from zero or more patterns.
// An empty patterns list or a pattern with the value 'none' will create
// a whitelist that will Accept none.
// a whitelist that will Accept none. Patterns prefixed with "! " act as
// deny rules; see Whitelist.
func NewWhitelist(patterns ...string) (Whitelist, error) {
if len(patterns) == 0 {
return Whitelist{acceptNone: true}, nil
}
var acceptSome bool
var patternsStrings []string
var (
acceptSome bool
patternsStrings []string
)
for _, p := range patterns {
if p == acceptNoneKeyword {
@@ -66,26 +76,28 @@ func NewWhitelist(patterns ...string) (Whitelist, error) {
}
if !acceptSome {
return Whitelist{
acceptNone: true,
}, nil
return Whitelist{acceptNone: true}, nil
}
var patternsr []*regexp.Regexp
for i := range patterns {
p := strings.TrimSpace(patterns[i])
if p == "" {
continue
var allow, deny []*regexp.Regexp
for _, p := range patternsStrings {
raw := p
negate := strings.HasPrefix(p, hglob.NegationPrefix)
if negate {
raw = p[len(hglob.NegationPrefix):]
}
re, err := regexp.Compile(p)
re, err := regexp.Compile(raw)
if err != nil {
return Whitelist{}, fmt.Errorf("failed to compile whitelist pattern %q: %w", p, err)
}
patternsr = append(patternsr, re)
if negate {
deny = append(deny, re)
} else {
allow = append(allow, re)
}
}
return Whitelist{patterns: patternsr, patternsStrings: patternsStrings}, nil
return Whitelist{allow: allow, deny: deny, patternsStrings: patternsStrings}, nil
}
// MustNewWhitelist creates a new Whitelist from zero or more patterns and panics on error.
@@ -103,7 +115,19 @@ func (w Whitelist) Accept(name string) bool {
return false
}
for _, p := range w.patterns {
for _, p := range w.deny {
if p.MatchString(name) {
return false
}
}
if len(w.allow) == 0 {
// A whitelist with only deny rules implicitly allows everything
// that is not denied. An empty (zero-value) whitelist rejects.
return len(w.deny) > 0
}
for _, p := range w.allow {
if p.MatchString(name) {
return true
}
+21
View File
@@ -43,4 +43,25 @@ func TestWhitelist(t *testing.T) {
c.Assert(w.Accept("bar"), qt.IsTrue)
c.Assert(w.Accept("mbar"), qt.IsFalse)
})
c.Run("Negation takes precedence", func(c *qt.C) {
w := MustNewWhitelist(".*", "! ^foo")
c.Assert(w.Accept("bar"), qt.IsTrue)
c.Assert(w.Accept("foo"), qt.IsFalse)
c.Assert(w.Accept("foobar"), qt.IsFalse)
})
c.Run("Negation only", func(c *qt.C) {
// A whitelist with only deny rules accepts everything else.
w := MustNewWhitelist("! ^foo")
c.Assert(w.Accept("bar"), qt.IsTrue)
c.Assert(w.Accept("foo"), qt.IsFalse)
})
c.Run("Bad pattern", func(c *qt.C) {
_, err := NewWhitelist("[invalid")
c.Assert(err, qt.IsNotNil)
_, err = NewWhitelist("! [invalid")
c.Assert(err, qt.IsNotNil)
})
}
-10
View File
@@ -31,7 +31,6 @@ type Config struct {
Disqus Disqus
GoogleAnalytics GoogleAnalytics
Instagram Instagram `json:"-"` // the embedded instagram shortcode no longer uses this
Twitter Twitter `json:"-"` // deprecated in favor of X in v0.141.0
X X
RSS RSS
}
@@ -61,15 +60,6 @@ type Instagram struct {
AccessToken string // this is no longer used by the embedded instagram shortcode
}
// Twitter holds the functional configuration settings related to the Twitter shortcodes.
// Deprecated in favor of X in v0.141.0.
type Twitter struct {
// The Simple variant of Twitter is decorated with a basic set of inline styles.
// This means that if you want to provide your own CSS, you want
// to disable the inline CSS provided by Hugo.
DisableInlineCSS bool
}
// X holds the functional configuration settings related to the X shortcodes.
type X struct {
// The Simple variant of X is decorated with a basic set of inline styles.
+3
View File
@@ -68,6 +68,9 @@ func GetTestDeps(fs afero.Fs, cfg config.Provider, beforeInit ...func(*deps.Deps
warpc.Options{
PoolSize: 1,
},
warpc.Options{
PoolSize: 1,
},
),
}
for _, f := range beforeInit {
+17 -5
View File
@@ -162,9 +162,8 @@ func (b *contentBuilder) buildDir() error {
if !b.dirMap.siteUsed {
// We don't need to build everything.
contentInclusionFilter = hglob.NewFilenameFilterForInclusionFunc(func(filename string) bool {
filename = strings.TrimPrefix(filename, string(os.PathSeparator))
for _, cn := range contentTargetFilenames {
if strings.Contains(cn, filename) {
if strings.HasSuffix(cn, filename) {
return true
}
}
@@ -219,6 +218,9 @@ func (b *contentBuilder) buildDir() error {
func (b *contentBuilder) buildFile() (string, error) {
contentPlaceholderAbsFilename, err := b.cf.CreateContentPlaceHolder(b.targetPath, b.force)
if err != nil {
if fi, serr := b.sourceFs.Stat(contentPlaceholderAbsFilename); serr == nil && !fi.IsDir() {
return "", errTargetConflict(contentPlaceholderAbsFilename)
}
return "", err
}
@@ -231,11 +233,17 @@ func (b *contentBuilder) buildFile() (string, error) {
if !usesSite {
// We don't need to build everything.
contentInclusionFilter = hglob.NewFilenameFilterForInclusionFunc(func(filename string) bool {
filename = strings.TrimPrefix(filename, string(os.PathSeparator))
return strings.Contains(contentPlaceholderAbsFilename, filename)
return strings.HasSuffix(contentPlaceholderAbsFilename, filename)
})
}
// If a directory with the target's name (sans extension) exists, this file
// would produce a URL conflict with the existing section or leaf bundle.
targetDir := strings.TrimSuffix(contentPlaceholderAbsFilename, filepath.Ext(contentPlaceholderAbsFilename))
if fi, err := b.sourceFs.Stat(targetDir); err == nil && fi.IsDir() {
return "", errTargetConflict(contentPlaceholderAbsFilename)
}
if err := b.h.Build(hugolib.BuildCfg{NoBuildLock: true, SkipRender: true, ContentInclusionFilter: contentInclusionFilter}); err != nil {
return "", err
}
@@ -268,10 +276,14 @@ func (b *contentBuilder) setArcheTypeFilenameToUse(ext string) {
}
}
func errTargetConflict(path string) error {
return fmt.Errorf("no page found for %q; the target path conflicts with existing content", path)
}
func (b *contentBuilder) applyArcheType(contentFilename string, archetypeFi hugofs.FileMetaInfo) error {
p := b.h.GetContentPage(contentFilename)
if p == nil {
return fmt.Errorf("no page found for %q; if a file with the same name but different case already exists, please use a different filename or remove the existing file", contentFilename)
return errTargetConflict(contentFilename)
}
f, err := b.sourceFs.Create(contentFilename)
+38 -7
View File
@@ -74,7 +74,6 @@ func TestNewContentFromFile(t *testing.T) {
c := qt.New(t)
for i, cas := range cases {
c.Run(cas.name, func(c *qt.C) {
c.Parallel()
@@ -109,7 +108,6 @@ func TestNewContentFromFile(t *testing.T) {
}
}
})
}
}
@@ -159,6 +157,37 @@ site RegularPages: {{ len site.RegularPages }}
cContains(c, readFileFromFs(t, fs.Source, filepath.Join("content", "mypage.md")), `draft: true`)
}
// See issue 15078.
func TestNewContentWithBuildCascade(t *testing.T) {
t.Parallel()
mm := afero.NewMemMapFs()
c := qt.New(t)
c.Assert(initFs(mm), qt.IsNil)
c.Assert(mm.MkdirAll(filepath.Join("content", "posts", "drafts"), 0o755), qt.IsNil)
c.Assert(afero.WriteFile(mm, filepath.Join("content", "posts", "drafts", "_index.md"), []byte(`---
title: Drafts
build:
render: never
cascade:
draft: true
build:
render: link
draft: true
---
`), 0o755), qt.IsNil)
cfg, fs := newTestCfg(c, mm)
conf := testconfig.GetTestConfigs(fs.Source, cfg)
h, err := hugolib.NewHugoSites(deps.DepsCfg{Configs: conf, Fs: fs})
c.Assert(err, qt.IsNil)
const target = "posts/drafts/trip-to-puebla/index.md"
c.Assert(create.NewContent(h, "", target, false), qt.IsNil)
c.Assert(readFileFromFs(c, fs.Source, filepath.Join("content", target)), qt.Contains, `title: "Trip to Puebla"`)
}
func initFs(fs afero.Fs) error {
perm := os.FileMode(0o755)
var err error
@@ -288,7 +317,7 @@ func readFileFromFs(t testing.TB, fs afero.Fs, filename string) string {
b, err := afero.ReadFile(fs, filename)
if err != nil {
// Print some debug info
root := strings.Split(filename, helpers.FilePathSeparator)[0]
root, _, _ := strings.Cut(filename, helpers.FilePathSeparator)
afero.Walk(fs, root, func(path string, info os.FileInfo, err error) error {
if info != nil && !info.IsDir() {
fmt.Println(" ", path)
@@ -308,10 +337,10 @@ theme = "mytheme"
[languages]
[languages.en]
weight = 1
languageName = "English"
label = "English"
[languages.nn]
weight = 2
languageName = "Nynorsk"
label = "Nynorsk"
[module]
[[module.mounts]]
@@ -320,11 +349,13 @@ languageName = "Nynorsk"
[[module.mounts]]
source = 'content'
target = 'content'
lang = 'en'
[module.mounts.sites.matrix]
languages = 'en'
[[module.mounts]]
source = 'content_nn'
target = 'content'
lang = 'nn'
[module.mounts.sites.matrix]
languages = 'nn'
`
if mm == nil {
mm = afero.NewMemMapFs()
+22 -2
View File
@@ -243,6 +243,8 @@ func (d *Deps) Init() error {
}
}
d.ExecHelper.SetNodeReadPaths(d.BaseFs.Assets.RealPaths(""))
if d.ContentSpec == nil {
contentSpec, err := helpers.NewContentSpec(d.Conf, d.Log, d.Content.Fs, d.ExecHelper)
if err != nil {
@@ -433,13 +435,31 @@ type DepsCfg struct {
// Build triggered by the IntegrationTest framework.
IsIntegrationTest bool
// TestCfg holds configuration used only in tests.
// It is a programming error to set this when IsIntegrationTest is not set,
// and doing so will panic.
TestCfg TestConfig
// ChangesFromBuild for changes passed back to the server/watch process.
ChangesFromBuild chan []identity.Identity
}
// TestConfig holds configuration used only in tests.
// See DepsCfg.TestCfg.
type TestConfig struct {
// WarpcMemory, if set, overrides the memory limit in MiB for the WASM based
// image processors (WebP and AVIF). Used to provoke memory allocation failures.
WarpcMemory int
}
// IsZero reports whether c holds no test configuration.
func (c TestConfig) IsZero() bool {
return c == TestConfig{}
}
// BuildState are state used during a build.
type BuildState struct {
counter uint64
counter atomic.Uint64
// Tracks invocations of the Build method.
BuildCounter atomic.Uint64
@@ -518,5 +538,5 @@ func (b *BuildState) GetFilenamesWithPostPrefix() []string {
}
func (b *BuildState) Incr() int {
return int(atomic.AddUint64(&b.counter, uint64(1)))
return int(b.counter.Add(uint64(1)))
}
+8 -5
View File
@@ -77,7 +77,7 @@
"unmarshaling",
"unmarshals",
// ------------------------------------------------------------------------
// cspell: ignore hugo terminology",
// cspell: ignore hugo terminology
// ------------------------------------------------------------------------
"alignx",
"aligny",
@@ -102,8 +102,10 @@
"unpublishdate",
"zgotmplz",
// ------------------------------------------------------------------------
// cspell: ignore foreign language words",
// cspell: ignore foreign language words
// ------------------------------------------------------------------------
"Bokmål",
"Norsk",
"bezpieczeństwo",
"blatt",
"buch",
@@ -130,7 +132,7 @@
"referenz",
"régime",
// ------------------------------------------------------------------------
// cspell: ignore names",
// cspell: ignore names
// ------------------------------------------------------------------------
"Atishay",
"Cosette",
@@ -149,8 +151,9 @@
"Vitter",
"WASI",
// ------------------------------------------------------------------------
// cspell: ignore operating systems and software packages",
// cspell: ignore operating systems and software packages
// ------------------------------------------------------------------------
"ananke",
"asciidoctor",
"brotli",
"cifs",
@@ -173,7 +176,7 @@
"rclone",
"xubuntu",
// ------------------------------------------------------------------------
// cspell: ignore miscellaneous",
// cspell: ignore miscellaneous
// ------------------------------------------------------------------------
"achristie",
"ccpa",
+61
View File
@@ -0,0 +1,61 @@
name: Lint
on:
workflow_dispatch:
push:
branches:
- master
pull_request:
permissions:
contents: read
jobs:
markdownlint:
name: Lint Markdown
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Run Markdown linter
uses: DavidAnson/markdownlint-cli2-action@ded1f9488f68a970bc66ea5619e13e9b52e601cd # v23.2.0
with:
globs: # set to null to override default of *.{md,markdown}
spellcheck:
name: Check spelling
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Check spelling with cspell
uses: streetsidesoftware/cspell-action@de2a73e963e7443969755b648a1008f77033c5b2 # v8.4.0
with:
incremental_files_only: true
strict: true
# cspell uses the .cspell.json configuration file
- name: Check spelling with codespell
uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
check_filenames: true
check_hidden: true
# codespell uses the .codespellrc file
template-formatting:
name: Check template formatting
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Install Go
uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0
with:
go-version: "1.26"
check-latest: true
cache: true
cache-dependency-path: |
**/go.sum
**/go.mod
- name: Install gotmplfmt
run: go install github.com/gohugoio/gotmplfmt@623175f49b3d07a11da381ff85228d0d03101880 # v0.4.1
- name: Check template formatting
run: "diff <(gotmplfmt -d layouts) <(printf '')"
-15
View File
@@ -1,15 +0,0 @@
name: Lint markdown
on:
workflow_dispatch:
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- name: Run Markdown linter
uses: DavidAnson/markdownlint-cli2-action@30a0e04f1870d58f8d717450cc6134995f993c63 # v21.0.0
with:
globs: # set to null to override default of *.{md,markdown}
continue-on-error: false
-25
View File
@@ -1,25 +0,0 @@
name: "Check spelling"
on:
push:
pull_request:
branches-ignore:
- "dependabot/**"
permissions:
contents: read
jobs:
spellcheck:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
- uses: streetsidesoftware/cspell-action@3294df585d3d639e30f3bc019cb11940b9866e95 # v8.0.0
with:
incremental_files_only: true
strict: true
# cspell uses the .cspell.json configuration file
- uses: codespell-project/actions-codespell@8f01853be192eb0f849a5c7d721450e7a467c579 # v2.2
with:
check_filenames: true
check_hidden: true
# codespell uses the .codespellrc file
-15
View File
@@ -1,18 +1,3 @@
# Ignore all SVG icons.
**/icons.html
# These are whitespace sensitive.
layouts/_markup/render-code*
layouts/_markup/render-table*
layouts/_shortcodes/glossary-term.html
layouts/_shortcodes/glossary.html
layouts/_shortcodes/highlighting-styles.html
layouts/_shortcodes/list-pages-in-section.html
layouts/_shortcodes/quick-reference.html
# No root node.
layouts/_partials/layouts/head/head.html
# Auto generated.
assets/css/components/chroma*.css
assets/jsconfig.json
-12
View File
@@ -1,17 +1,5 @@
{
"plugins": [
"prettier-plugin-go-template",
"@awmottaz/prettier-plugin-void-html"
],
"overrides": [
{
"files": ["*.html"],
"options": {
"parser": "go-template",
"goTemplateBracketSpacing": true,
"bracketSameLine": true
}
},
{
"files": ["*.js", "*.ts"],
"options": {
-7
View File
@@ -1,7 +0,0 @@
{
"recommendations": [
"DavidAnson.vscode-markdownlint",
"EditorConfig.EditorConfig",
"streetsidesoftware.code-spell-checker"
]
}
+1 -1
View File
@@ -1,7 +1,7 @@
---
title: {{ replace .File.ContentBaseName "-" " " }}
params:
reference:
reference:
---
<!--
+1 -1
View File
@@ -27,7 +27,7 @@
/* pre */
@apply prose-pre:text-gray-800 prose-pre:border-1 prose-pre:border-gray-100 prose-pre:bg-light dark:prose-pre:bg-dark dark:prose-pre:ring-1 dark:prose-pre:ring-slate-300/10;
/* code */
@apply prose-code:px-0.5 prose-code:text-gray-500 prose-code:dark:text-gray-300 border-none;
@apply prose-code:px-0.5 prose-code:text-gray-600 prose-code:dark:text-gray-300 border-none;
@apply prose-code:before:hidden prose-code:after:hidden prose-code:font-mono;
@apply prose-table:prose-th:prose-code:text-white;
/* tables */
+17 -13
View File
@@ -1,20 +1,24 @@
/* Opt in to native cross-document view transitions on navigation. */
@view-transition {
navigation: auto;
}
/* Global slight fade */
::view-transition-old(root),
::view-transition-new(root) {
animation-duration: 200ms;
}
::view-transition-old(qr),
::view-transition-new(qr) {
animation-duration: 800ms;
animation-delay: 250ms;
}
.view-transition-qr {
view-transition-name: qr;
}
/* Turbo styles */
.turbo-progress-bar {
visibility: hidden;
/*
* Persistent chrome (header/footer) is named so it gets its own snapshot
* instead of being part of the root crossfade. Holding it static prevents
* the flicker that the sticky header otherwise shows on every navigation.
*/
::view-transition-group(site-header),
::view-transition-group(site-footer),
::view-transition-old(site-header),
::view-transition-new(site-header),
::view-transition-old(site-footer),
::view-transition-new(site-footer) {
animation: none;
}
-123
View File
@@ -1,123 +0,0 @@
var debug = 0 ? console.log.bind(console, '[explorer]') : function () {};
// This is currently not used, but kept in case I change my mind.
export const explorer = (Alpine) => ({
uiState: {
containerScrollTop: -1,
lastActiveRef: '',
},
treeState: {
// The href of the current page.
currentNode: '',
// The state of each node in the tree.
nodes: {},
// We currently only list the sections, not regular pages, in the side bar.
// This strikes me as the right balance. The pages gets listed on the section pages.
// This array is sorted by length, so we can find the longest prefix of the current page
// without having to iterate over all the keys.
nodeRefsByLength: [],
},
async init() {
let keys = Reflect.ownKeys(this.$refs);
for (let key of keys) {
let n = {
open: false,
active: false,
};
this.treeState.nodes[key] = n;
this.treeState.nodeRefsByLength.push(key);
}
this.treeState.nodeRefsByLength.sort((a, b) => b.length - a.length);
this.setCurrentActive();
},
longestPrefix(ref) {
let longestPrefix = '';
for (let key of this.treeState.nodeRefsByLength) {
if (ref.startsWith(key)) {
longestPrefix = key;
break;
}
}
return longestPrefix;
},
setCurrentActive() {
let ref = this.longestPrefix(window.location.pathname);
let activeChanged = this.uiState.lastActiveRef !== ref;
debug('setCurrentActive', this.uiState.lastActiveRef, window.location.pathname, '=>', ref, activeChanged);
this.uiState.lastActiveRef = ref;
if (this.uiState.containerScrollTop === -1 && activeChanged) {
// Navigation outside of the explorer menu.
let el = document.querySelector(`[x-ref="${ref}"]`);
if (el) {
this.$nextTick(() => {
debug('scrolling to', ref);
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
});
}
}
this.treeState.currentNode = ref;
for (let key in this.treeState.nodes) {
let n = this.treeState.nodes[key];
n.active = false;
n.open = ref == key || ref.startsWith(key);
if (n.open) {
debug('open', key);
}
}
let n = this.treeState.nodes[this.longestPrefix(ref)];
if (n) {
n.active = true;
}
},
getScrollingContainer() {
return document.getElementById('leftsidebar');
},
onLoad() {
debug('onLoad', this.uiState.containerScrollTop);
if (this.uiState.containerScrollTop >= 0) {
debug('onLoad: scrolling to', this.uiState.containerScrollTop);
this.getScrollingContainer().scrollTo(0, this.uiState.containerScrollTop);
}
this.uiState.containerScrollTop = -1;
},
onBeforeRender() {
debug('onBeforeRender', this.uiState.containerScrollTop);
this.setCurrentActive();
},
toggleNode(ref) {
this.uiState.containerScrollTop = this.getScrollingContainer().scrollTop;
this.uiState.lastActiveRef = '';
debug('toggleNode', ref, this.uiState.containerScrollTop);
let node = this.treeState.nodes[ref];
if (!node) {
debug('node not found', ref);
return;
}
let wasOpen = node.open;
},
isCurrent(ref) {
let n = this.treeState.nodes[ref];
return n && n.active;
},
isOpen(ref) {
let node = this.treeState.nodes[ref];
if (!node) return false;
if (node.open) {
debug('isOpen', ref);
}
return node.open;
},
});
+7 -2
View File
@@ -10,7 +10,7 @@ const groupByLvl0 = (array) => {
}, {});
};
const applyHelperFuncs = (array) => {
const adjustHits = (array, isServer) => {
if (!array) return [];
return array.map((item) => {
item.getHeadingHTML = function () {
@@ -30,6 +30,11 @@ const applyHelperFuncs = (array) => {
return `${lvl2.value} <span class="text-gray-500">&nbsp;>&nbsp;</span> ${lvl3.value}`;
};
if (isServer) {
// Trim https://gohugo.io from the url to make it work locally.
item.url = item.url.replace('https://gohugo.io', '');
}
return item;
});
};
@@ -99,7 +104,7 @@ export const search = (Alpine, cfg) => ({
})
.then((response) => response.json())
.then((data) => {
this.result = groupByLvl0(applyHelperFuncs(data.results[0].hits));
this.result = groupByLvl0(adjustHits(data.results[0].hits, cfg.params.isServer));
this.cache.put(this.query, this.result);
});
},
@@ -1,67 +0,0 @@
export function bridgeTurboAndAlpine(Alpine) {
document.addEventListener('turbo:before-render', (event) => {
event.detail.newBody.querySelectorAll('[data-alpine-generated]').forEach((el) => {
if (el.hasAttribute('data-alpine-generated')) {
el.removeAttribute('data-alpine-generated');
el.remove();
}
});
});
document.addEventListener('turbo:render', () => {
if (document.documentElement.hasAttribute('data-turbo-preview')) {
return;
}
document.querySelectorAll('[data-alpine-ignored]').forEach((el) => {
el.removeAttribute('x-ignore');
el.removeAttribute('data-alpine-ignored');
});
document.body.querySelectorAll('[x-data]').forEach((el) => {
if (el.hasAttribute('data-turbo-permanent')) {
return;
}
Alpine.initTree(el);
});
Alpine.startObservingMutations();
});
// Cleanup Alpine state on navigation.
document.addEventListener('turbo:before-cache', () => {
// This will be restarted in turbo:render.
Alpine.stopObservingMutations();
document.body.querySelectorAll('[data-turbo-permanent]').forEach((el) => {
if (!el.hasAttribute('x-ignore')) {
el.setAttribute('x-ignore', true);
el.setAttribute('data-alpine-ignored', true);
}
});
document.body.querySelectorAll('[x-for],[x-if],[x-teleport]').forEach((el) => {
if (el.hasAttribute('x-for') && el._x_lookup) {
Object.values(el._x_lookup).forEach((el) => el.setAttribute('data-alpine-generated', true));
}
if (el.hasAttribute('x-if') && el._x_currentIfEl) {
el._x_currentIfEl.setAttribute('data-alpine-generated', true);
}
if (el.hasAttribute('x-teleport') && el._x_teleport) {
el._x_teleport.setAttribute('data-alpine-generated', true);
}
});
document.body.querySelectorAll('[x-data]').forEach((el) => {
if (!el.hasAttribute('data-turbo-permanent')) {
Alpine.destroyTree(el);
// Turbo leaks DOM elements via their data-turbo-permanent handling.
// That needs to be fixed upstream, but until then.
let clone = el.cloneNode(true);
el.replaceWith(clone);
}
});
});
}
+1 -1
View File
@@ -5,7 +5,7 @@ export const scrollToActive = (when) => {
}
els.forEach((el) => {
// Find scrolling container.
let container = el.closest('[data-turbo-preserve-scroll-container]');
let container = el.closest('[data-preserve-scroll-container]');
if (container) {
// Avoid scrolling if el is already in view.
if (el.offsetTop >= container.scrollTop && el.offsetTop <= container.scrollTop + container.clientHeight) {
-1
View File
@@ -1,3 +1,2 @@
export * from './bridgeTurboAndAlpine';
export * from './helpers';
export * from './lrucache';
+12 -36
View File
@@ -1,10 +1,10 @@
import Alpine from 'alpinejs';
import { registerMagics } from './alpinejs/magics/index';
import { navbar, search, toc } from './alpinejs/data/index';
import { navStore, initColorScheme } from './alpinejs/stores/index';
import { bridgeTurboAndAlpine } from './helpers/index';
import { navStore } from './alpinejs/stores/index';
import persist from '@alpinejs/persist';
import focus from '@alpinejs/focus';
import * as params from '@params';
var debug = 0 ? console.log.bind(console, '[index]') : function () {};
@@ -28,6 +28,7 @@ var debug = 0 ? console.log.bind(console, '[index]') : function () {};
index: 'hugodocs',
app_id: 'D1BPLZHGYQ',
api_key: '6df94e1e5d55d258c56f60d974d10314',
params: params,
};
Alpine.data('navbar', () => navbar(Alpine));
@@ -43,39 +44,14 @@ var debug = 0 ? console.log.bind(console, '[index]') : function () {};
// Start AlpineJS.
Alpine.start();
// Start the Turbo-Alpine bridge.
bridgeTurboAndAlpine(Alpine);
{
let containerScrollTops = {};
// To preserve scroll position in scrolling elements on navigation add data-turbo-preserve-scroll-container="somename" to the scrolling container.
addEventListener('turbo:click', () => {
document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((el2) => {
containerScrollTops[el2.dataset.turboPreserveScrollContainer] = el2.scrollTop;
});
// On cross-document navigation the browser snapshots the current page for
// the view transition. An open overlay (e.g. the search modal) would
// otherwise linger in that outgoing snapshot while the page crossfades.
// `pageswap` runs right before the snapshot is taken, so hide such
// elements here to make them disappear instantly on navigation.
window.addEventListener('pageswap', () => {
document.querySelectorAll('[data-hide-on-navigate]').forEach((el) => {
el.classList.add('hidden');
});
addEventListener('turbo:render', () => {
document.querySelectorAll('[data-turbo-preserve-scroll-container]').forEach((ele) => {
const containerScrollTop = containerScrollTops[ele.dataset.turboPreserveScrollContainer];
if (containerScrollTop) {
ele.scrollTop = containerScrollTop;
} else {
let els = ele.querySelectorAll('.scroll-active');
if (els.length) {
els.forEach((el) => {
// Avoid scrolling if el is already in view.
if (el.offsetTop >= ele.scrollTop && el.offsetTop <= ele.scrollTop + ele.clientHeight) {
return;
}
ele.scrollTop = el.offsetTop - ele.offsetTop;
});
}
}
});
containerScrollTops = {};
});
}
});
})();
-1
View File
@@ -1 +0,0 @@
import * as Turbo from '@hotwired/turbo';
-1
View File
@@ -1,6 +1,5 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"*"
@@ -0,0 +1,22 @@
---
_comment: Do not remove front matter.
---
`locale`
: (`string`) The language tag as described in [RFC 5646][]. This is the primary value used by the [`language.Translate`][] function to select a translation table, and for localization of dates, currencies, numbers, and percentages, falling back to the [language key][] in both cases.
Hugo also uses this value to populate:
- The `lang` attribute of the `html` element in the [embedded alias template][]
- The `language` element in the [embedded RSS template][]
- The `locale` property in the [embedded Open Graph template][]
Access this value from a template using the [`Language.Locale`][] method on a `Site` or `Page` object.
[RFC 5646]: https://datatracker.ietf.org/doc/html/rfc5646#section-2.1
[`Language.Locale`]: /methods/site/language/#locale
[`language.Translate`]: /functions/lang/translate/
[embedded Open Graph template]: <{{% eturl opengraph %}}>
[embedded RSS template]: <{{% eturl rss %}}>
[embedded alias template]: <{{% eturl alias %}}>
[language key]: /configuration/languages/#language-keys
@@ -0,0 +1,22 @@
---
_comment: Do not remove front matter.
---
A _page matcher_ filters pages by logical path, page kind, environment, or site. Specify filtering criteria using any combination of the following keywords.
`environment`
: (`string`) A [glob pattern](g) matching the build [environment](g). For example: `{staging,production}`.
`kind`
: (`string`) A [glob pattern](g) matching the [page kind](g). For example: `{taxonomy,term}`.
`lang`
: {{< deprecated-in 0.153.0 />}}
: Use the [`sites`](#sites) setting instead.
`path`
: (`string`) A [glob pattern](g) matching the page's [logical path](g). For example: `{/books,/books/**}`.
`sites`
: {{< new-in 0.153.0 />}}
: (`map`) A [sites matrix](g) matching any combination of [content dimensions](g) including language, version, and role.
@@ -0,0 +1,7 @@
---
_comment: Do not remove front matter.
---
When the `images` front matter parameter is set, Hugo processes each value. For internal paths, it searches page resources then global resources, using the resource permalink if found or converting the path to an absolute URL if not. External URLs are used as-is.
When `images` is not set, Hugo searches page resources for a name matching `*feature*`, falling back to `*cover*` or `*thumbnail*` if none is found. If still no image is found, Hugo uses the first entry in the site configuration's `params.images` array, if present, and processes it as described above.
+2 -2
View File
@@ -2,7 +2,7 @@
_comment: Do not remove front matter.
---
> [!note]
> The [page collections quick reference guide] describes methods and functions to filter, sort, and group page collections.
> [!NOTE]
> The [page collections quick reference guide][] describes methods and functions to filter, sort, and group page collections.
[page collections quick reference guide]: /quick-reference/page-collections/
@@ -2,6 +2,6 @@
_comment: Do not remove front matter.
---
The documentation for Go's [fmt] package describes the structure and content of the format string.
The documentation for Go's [`fmt`][] package describes the structure and content of the format string.
[fmt]: https://pkg.go.dev/fmt
[`fmt`]: https://pkg.go.dev/fmt
@@ -10,5 +10,5 @@ By default, Hugo uses the `html/template` package when rendering HTML files.
To generate HTML output that is safe against code injection, the `html/template` package escapes strings in certain contexts.
[`text/template`]: https://pkg.go.dev/text/template
[`html/template`]: https://pkg.go.dev/html/template
[`text/template`]: https://pkg.go.dev/text/template
@@ -2,9 +2,7 @@
_comment: Do not remove front matter.
---
Apply the filter using the [`images.Filter`] function:
[`images.Filter`]: /functions/images/filter/
Apply the filter using the [`images.Filter`][] function:
```go-html-template
{{ with resources.Get "images/original.jpg" }}
@@ -14,9 +12,7 @@ Apply the filter using the [`images.Filter`] function:
{{ end }}
```
You can also apply the filter using the [`Filter`] method on a `Resource` object:
[`Filter`]: /methods/resource/filter/
You can also apply the filter using the [`Filter`][] method on a `Resource` object:
```go-html-template
{{ with resources.Get "images/original.jpg" }}
@@ -25,3 +21,6 @@ You can also apply the filter using the [`Filter`] method on a `Resource` object
{{ end }}
{{ end }}
```
[`Filter`]: /methods/resource/filter/
[`images.Filter`]: /functions/images/filter/
+16 -16
View File
@@ -2,13 +2,13 @@
_comment: Do not remove front matter.
---
params
`params`
: (`map` or `slice`) Params that can be imported as JSON in your JS files, e.g.
```go-html-template
{{ $js := resources.Get "js/main.js" | js.Build (dict "params" (dict "api" "https://example.org/api")) }}
```
And then in your JS file:
```js
@@ -17,17 +17,17 @@ params
Note that this is meant for small data sets, e.g., configuration settings. For larger data sets, please put/mount the files into `assets` and import them directly.
minify
: (`bool`) Whether to minify the generated CSS code. Default is `false`.
`minify`
: (`bool`) Whether to minify the generated JS code. Default is `false`.
loaders
`loaders`
: {{< new-in 0.140.0 />}}
: (`map`) Configuring a loader for a given file type lets you load that file type with an `import` statement or a `require` call. For example, configuring the `.png` file extension to use the data URL loader means importing a `.png` file gives you a data URL containing the contents of that image. Loaders available are `none`, `base64`, `binary`, `copy`, `css`, `dataurl`, `default`, `empty`, `file`, `global-css`, `js`, `json`, `jsx`, `local-css`, `text`, `ts`, `tsx`. See <https://esbuild.github.io/api/#loader>.
inject
`inject`
: (`slice`) This option allows you to automatically replace a global variable with an import from another file. The path names must be relative to `assets`. See <https://esbuild.github.io/api/#inject>.
shims
`shims`
: (`map`) This option allows swapping out a component with another. A common use case is to load dependencies like React from a CDN (with _shims_) when in production, but running with the full bundled `node_modules` dependency during development:
```go-html-template
@@ -54,39 +54,39 @@ shims
import * as ReactDOM from 'react-dom/client';
```
target
`target`
: (`string`) The language target. One of: `es5`, `es2015`, `es2016`, `es2017`, `es2018`, `es2019`, `es2020`, `es2021`, `es2022`, `es2023`, `es2024`, or `esnext`. Default is `esnext`.
platform
`platform`
: {{< new-in 0.140.0 />}}
: (`string`) One of `browser`, `node`, `neutral`. Default is `browser`. See <https://esbuild.github.io/api/#platform>.
externals
`externals`
: (`slice`) External dependencies. Use this to trim dependencies you know will never be executed. See <https://esbuild.github.io/api/#external>.
defines
`defines`
: (`map`) This option allows you to define a set of string replacements to be performed when building. It must be a map where each key will be replaced by its value.
```go-html-template
{{ $defines := dict "process.env.NODE_ENV" `"development"` }}
```
drop
`drop`
: {{< new-in 0.144.0 />}}
: (`string`) Edit your source code before building to drop certain constructs: One of `debugger` or `console`.
: See <https://esbuild.github.io/api/#drop>
sourceMap
`sourceMap`
: (`string`) The type of source map to generate. One of `external`, `inline`, `linked`, or `none`. Default is `none`. Linked and external source maps will be written to the target with the output file name + ".map". When `linked` a `sourceMappingURL` will also be written to the output file.
sourcesContent
`sourcesContent`
: {{< new-in 0.140.0 />}}
: (`bool`) Whether to include the content of the source files in the source map. Default is `true`.
JSX
`JSX`
: (`string`) How to handle/transform JSX syntax. One of: `transform`, `preserve`, `automatic`. Default is `transform`. Notably, the `automatic` transform was introduced in React 17+ and will cause the necessary JSX helper functions to be imported automatically. See <https://esbuild.github.io/api/#jsx>.
JSXImportSource
`JSXImportSource`
: (`string`) Which library to use to automatically import its JSX helper functions from. This only works if `JSX` is set to `automatic`. The specified library needs to be installed through npm and expose certain exports. See <https://esbuild.github.io/api/#jsx-import-source>.
The combination of `JSX` and `JSXImportSource` is helpful if you want to use a non-React JSX library like Preact, e.g.:

Some files were not shown because too many files have changed in this diff Show More