mirror of
https://github.com/gohugoio/hugo.git
synced 2026-08-25 15:58:53 +00:00
Compare commits
262 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a25af7facf | |||
| 723579ff54 | |||
| 85ad5e48eb | |||
| 7b5199fdef | |||
| e31ff547d2 | |||
| 87260e4a60 | |||
| 8405b802cf | |||
| bf05832d14 | |||
| a05736cb9f | |||
| 49dceb19f5 | |||
| 5e7099256e | |||
| 0805c734a4 | |||
| 76a5e1880a | |||
| 0bb337b22a | |||
| 03dc9170b3 | |||
| c829b736cc | |||
| 995a2159e5 | |||
| f88f0a9f59 | |||
| dd3f2731e0 | |||
| f772998fa3 | |||
| 8a55df7af2 | |||
| 52c9bd7908 | |||
| 44da086082 | |||
| 2ffaf1fc1e | |||
| a808f6e40d | |||
| 94f3908ec6 | |||
| 75fcc7524a | |||
| 33d1f2c853 | |||
| 64da6d7c62 | |||
| 70db201ed4 | |||
| 8a468df065 | |||
| b5fa03d3aa | |||
| 9da472dd3b | |||
| 615e45d6e8 | |||
| 635532a20f | |||
| a243a6154c | |||
| 9c71f600bf | |||
| 420527fc6b | |||
| 7fe786e3b0 | |||
| 03b244fc0b | |||
| 961181334f | |||
| e35b7f049d | |||
| 7d90277a28 | |||
| 0796fa7ace | |||
| 6bf15241a1 | |||
| 861ede6d10 | |||
| f228c87d41 | |||
| 1b701b72ca | |||
| a32d70b712 | |||
| 948cfb98f9 | |||
| 8930802ef2 | |||
| 7df45f615a | |||
| f961093ea9 | |||
| 89b8c32200 | |||
| 984358f047 | |||
| d1f191c58d | |||
| a198116669 | |||
| ce2470e701 | |||
| 921db7b52c | |||
| 786ce71e59 | |||
| 5ad2846161 | |||
| 36ad9f583a | |||
| 7c0a0bc979 | |||
| d83ce27ae0 | |||
| 5a5f4a5495 | |||
| c6acc246ab | |||
| 29ed932513 | |||
| 7b4ddd1863 | |||
| 671897ae91 | |||
| a879ebfaaa | |||
| 499794d19e | |||
| feb3d494b9 | |||
| 65c82178b7 | |||
| 332d5ec823 | |||
| 212cc11ada | |||
| dfb35dcd7a | |||
| ef053faa6a | |||
| af91eb1997 | |||
| a5ec542393 | |||
| 884439b9a2 | |||
| 128fb17c2b | |||
| e46d37a984 | |||
| 790a8aa474 | |||
| fe06735214 | |||
| d15baf53a9 | |||
| 4d22555aeb | |||
| ce1a7e0bce | |||
| c86d9f4aa8 | |||
| e8988c3141 | |||
| 70a9068aa6 | |||
| 9d66d513ce | |||
| f013346667 | |||
| 96e06e1ab8 | |||
| 19a5cec0b9 | |||
| 134674f00d | |||
| 147f605f7d | |||
| 1f35beb918 | |||
| 2a4fd58818 | |||
| 93c8c7d345 | |||
| 95e5e9f4ab | |||
| a00b5c72ac | |||
| cf9c8f93ca | |||
| 11f09f59fc | |||
| 2602796cf1 | |||
| 72495f9fba | |||
| 4a9485336a | |||
| 1d018ef857 | |||
| 121bc6ceb2 | |||
| 0d29fc81bb | |||
| bb57404f3d | |||
| 781fabf4e4 | |||
| cf18b827e2 | |||
| 98ad9b3c03 | |||
| ff2903a931 | |||
| 7d1b1fb33d | |||
| b89e7fe675 | |||
| e8fefc8388 | |||
| a043d3ec63 | |||
| 77a1147056 | |||
| 341f575d2d | |||
| 248241b6e1 | |||
| 4e47d95db9 | |||
| 03b4b54220 | |||
| 79be0532f1 | |||
| 0f440460c8 | |||
| 4e17421ec2 | |||
| b01ecd4cd4 | |||
| ca68936d61 | |||
| 45c00b7c16 | |||
| 28d882ab70 | |||
| b0fd7ee2e1 | |||
| bba860e3ed | |||
| 59f35cd985 | |||
| 21e9868fe2 | |||
| 2760288c6d | |||
| c270975049 | |||
| ea8b48af64 | |||
| 076dfe13d0 | |||
| e41a06447d | |||
| 90d9f812b2 | |||
| 80e60847fb | |||
| df5421918a | |||
| aeb9a5cc02 | |||
| c4bbc2805c | |||
| d8c70218b7 | |||
| ee4f1acd93 | |||
| b6133657e0 | |||
| 4bc7caea14 | |||
| 7711ba4d0c | |||
| d2c821b5c7 | |||
| 4f444c810c | |||
| fe6c72652f | |||
| 4ed7600fd6 | |||
| cbe4339a5f | |||
| 6475d308ec | |||
| 67aede4364 | |||
| 87f194b249 | |||
| 5f01b0603d | |||
| c23d97904f | |||
| 5d51b82a7e | |||
| d81e3c29c0 | |||
| 7c65a4dbc0 | |||
| d31a9275c6 | |||
| 6a2a03806d | |||
| cf1de598d9 | |||
| c36608c584 | |||
| 1e4037cade | |||
| 2f361a8e25 | |||
| 81d77620c6 | |||
| 5559263326 | |||
| 97f990cc4f | |||
| 656fc04035 | |||
| a20cb5b1c0 | |||
| b99634e253 | |||
| 4d775cbe95 | |||
| fdd977e95d | |||
| ae7bf74b3e | |||
| 123018de21 | |||
| ba5d812673 | |||
| be4a0df3a2 | |||
| e4cf565c0d | |||
| 9e64953338 | |||
| f0cfc28c00 | |||
| 16e854a437 | |||
| f8b5fa09a6 | |||
| 86fbb0f7a8 | |||
| 7d4af7a179 | |||
| 28147cb040 | |||
| db40fada48 | |||
| b88fa8cc66 | |||
| 88d838a971 | |||
| e51e761d9c | |||
| 7011239205 | |||
| 694906f6f1 | |||
| d27b9c06bd | |||
| 62cef3678b | |||
| ff22c62a32 | |||
| 7fd65e16e4 | |||
| ea8f66a7ce | |||
| c4eba92863 | |||
| 8b40a96b6e | |||
| d65af84d15 | |||
| 454450a647 | |||
| 2bfcc6b994 | |||
| 98d396c16a | |||
| d4ae662d59 | |||
| 9ede5fb9e0 | |||
| 833a878eef | |||
| 7622dd86ce | |||
| 0814059bb6 | |||
| 8920d56e95 | |||
| 633cc772e0 | |||
| 90d8bf34ae | |||
| 4c40c6d5ca | |||
| d2594db670 | |||
| ab2de51e07 | |||
| 72b85d5f9c | |||
| 75f61832c2 | |||
| 4c03129fcf | |||
| 080970bc6b | |||
| 1b7495bc49 | |||
| 79f030be5b | |||
| 896bc89ab8 | |||
| 100dde53ad | |||
| bdebb79705 | |||
| a54c398b93 | |||
| f5fce935e7 | |||
| 52123ae23f | |||
| 38b8afdc90 | |||
| 4169c1f70f | |||
| 7574e35b40 | |||
| 927666005c | |||
| 790f4084a1 | |||
| de6955bab3 | |||
| a77bd527fd | |||
| 017a7cd63a | |||
| e3413d927f | |||
| b01cc14703 | |||
| 8ee19ff9a3 | |||
| 6436deb3e1 | |||
| 547ab29ced | |||
| 0d58e4286f | |||
| bbb42b5a6a | |||
| 1eea9fba0b | |||
| 9a5c7e0d24 | |||
| 6613b08eb6 | |||
| 582c26ef42 | |||
| a4f2a8a579 | |||
| 8d6145f3c3 | |||
| ce2a156a4e | |||
| 9747724222 | |||
| a17bdbc5fa | |||
| 8f94d65cac | |||
| d6bc8165e6 | |||
| 8b00030b34 | |||
| c48551677c | |||
| 161d0d4757 | |||
| 45e4596630 | |||
| 58927aa14a | |||
| ce009e3aa9 | |||
| 0755872424 | |||
| 6b5554bac9 |
@@ -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
|
||||
|
||||
@@ -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: []
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -4,4 +4,6 @@ imports.*
|
||||
dist/
|
||||
public/
|
||||
.DS_Store
|
||||
cache/filecache/_gen/
|
||||
cache/filecache/_gen/
|
||||
.claude/
|
||||
dump.txt
|
||||
@@ -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
@@ -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 you’re 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`. It’s 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
@@ -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
|
||||
|
||||
@@ -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][],
|
||||
|
||||
[](https://godoc.org/github.com/gohugoio/hugo)
|
||||
[](https://github.com/gohugoio/hugo/actions?query=workflow%3ATest)
|
||||
[](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
|
||||
|
||||
[](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
@@ -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.
|
||||
|
||||
Vendored
+4
-13
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+54
-14
@@ -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
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
@@ -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
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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|",
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
@@ -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))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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..")
|
||||
@@ -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
|
||||
|
||||
@@ -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(""))
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 (1–4 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(
|
||||
|
||||
@@ -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{"*"})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
Vendored
+22
-2
@@ -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
@@ -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",
|
||||
|
||||
Vendored
+61
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Vendored
-7
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"DavidAnson.vscode-markdownlint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"streetsidesoftware.code-spell-checker"
|
||||
]
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
title: {{ replace .File.ContentBaseName "-" " " }}
|
||||
params:
|
||||
reference:
|
||||
reference:
|
||||
---
|
||||
|
||||
<!--
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
@@ -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"> > </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);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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,3 +1,2 @@
|
||||
export * from './bridgeTurboAndAlpine';
|
||||
export * from './helpers';
|
||||
export * from './lrucache';
|
||||
|
||||
+12
-36
@@ -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 +0,0 @@
|
||||
import * as Turbo from '@hotwired/turbo';
|
||||
@@ -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,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/
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user